Просмотр исходного кода

feat(config): per-agent skills_add/skills_remove directives

Aveer 2 дней назад
Родитель
Сommit
a5d988f6cc

+ 2 - 0
docs/configuration.md

@@ -122,6 +122,8 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `presets.<name>.<agent>.displayName` | string | - | Custom user-facing alias for the agent (e.g. `"advisor"` for `oracle`) |
 | `presets.<name>.<agent>.color` | string | - | Agent display color as `#RRGGBB` or a theme color: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info` |
 | `presets.<name>.<agent>.skills` | string[] | - | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
+| `presets.<name>.<agent>.skills_add` | string[] | - | Skill names added to the effective skills list at config resolution (applies to `agents.<agent>` entries too). Removal via `skills_remove` wins. Folded into `skills` and stripped; see [Skills Assignment](skills.md#adding-or-removing-skills-on-top-of-an-inherited-list) |
+| `presets.<name>.<agent>.skills_remove` | string[] | - | Skill names removed from the effective skills list at config resolution (applies to `agents.<agent>` entries too). Wins over `skills_add`. Folded into `skills` and stripped; see [Skills Assignment](skills.md#adding-or-removing-skills-on-top-of-an-inherited-list) |
 | `presets.<name>.<agent>.mcps` | string[] | - | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.options` | object | - | Provider-specific model options passed to the AI SDK (e.g., `textVerbosity`, `thinking` budget) |
 | `agents.<customAgent>.model` | string\|array | - | Required for custom agents inferred from unknown `agents` keys |

+ 34 - 0
docs/project-local-customization.md

@@ -48,6 +48,40 @@ The root `agents.*` configuration (defined at the top level of user or project c
 
 ---
 
+## Additive and Subtractive Skill Configuration
+
+The `skills` array is replacement-based: when a project config defines `agents.<agent>.skills`, it replaces the inherited list wholesale. To add project-specific skills on top of an inherited list — or remove inherited skills — without duplicating that list, use the `skills_add` and `skills_remove` directives:
+
+```jsonc
+// ~/.config/opencode/oh-my-opencode-slim.jsonc (global)
+{
+  "agents": {
+    "oracle": {
+      "skills": ["codemap", "deepwork"]
+    }
+  }
+}
+```
+
+```jsonc
+// <project>/.opencode/oh-my-opencode-slim.jsonc (project-local)
+{
+  "agents": {
+    "oracle": {
+      "skills_add": ["project-architecture", "project-testing"]
+    }
+  }
+}
+```
+
+Effective result: `codemap`, `deepwork`, `project-architecture`, `project-testing` — the global list is not duplicated.
+
+Resolution order is deterministic: resolve the inherited/configured `skills` list, then apply `skills_add`, then apply `skills_remove`. Duplicates are removed (first occurrence wins), and `skills_remove` wins over `skills_add` for the same skill. When the effective list contains `"*"`, removals are expressed with the existing `!name` exclusion syntax (e.g. effective `["*", "!codemap"]`). The directives are folded into `skills` during config resolution and stripped from the final agent configuration, so agent definitions and hooks only ever see a plain `skills` list.
+
+See [Skills Assignment](skills.md#adding-or-removing-skills-on-top-of-an-inherited-list) for the full rule set, including behavior when no `skills` list is configured.
+
+---
+
 ## Prompt Lookup Precedence
 
 When looking up markdown prompt template files (such as `<agent>.md` or `<agent>_append.md`), oh-my-opencode-slim searches directories in a strict hierarchical order. Precedence is evaluated for the replacement prompt file and the append prompt file **independently** in the following sequence:

+ 25 - 0
docs/skills.md

@@ -309,3 +309,28 @@ Control which skills each agent can use in `~/.config/opencode/oh-my-opencode-sl
   }
 }
 ```
+
+### Adding or removing skills on top of an inherited list
+
+`skills` is replaced wholesale when multiple config layers (user, project, preset) define it. To adjust an inherited list instead, use the `skills_add` / `skills_remove` directives. They are folded into the effective `skills` array during config resolution and stripped afterwards, so agents and hooks only ever see plain `skills`:
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `skills_add` | string[] | Skill names appended to the effective list (after `skills`) |
+| `skills_remove` | string[] | Skill names removed from the effective list; removal wins over addition |
+
+```json
+{
+  "agents": {
+    "oracle": {
+      "skills_add": ["nexus-backend"],
+      "skills_remove": ["deepwork"]
+    }
+  }
+}
+```
+
+**Rules:**
+- Duplicates are removed (first occurrence wins) before removals are applied
+- If the result contains `"*"`, each removed name is appended as `"!<name>"` so the exclusion beats the wildcard grant
+- A removal on an agent without a `skills` list starts from that agent's default grants (orchestrator: all skills)

+ 28 - 0
oh-my-opencode-slim.schema.json

@@ -86,6 +86,20 @@
                 "type": "string"
               }
             },
+            "skills_add": {
+              "description": "Skill names to add to this agent's effective skills list. Applied after the resolved `skills` list during config resolution; removal via `skills_remove` wins. Folded into `skills` at resolution time.",
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
+            "skills_remove": {
+              "description": "Skill names to remove from this agent's effective skills list. Applied after `skills_add` during config resolution, so removal wins over addition. Folded into `skills` at resolution time.",
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            },
             "mcps": {
               "type": "array",
               "items": {
@@ -553,6 +567,20 @@
               "type": "string"
             }
           },
+          "skills_add": {
+            "description": "Skill names to add to this agent's effective skills list. Applied after the resolved `skills` list during config resolution; removal via `skills_remove` wins. Folded into `skills` at resolution time.",
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "skills_remove": {
+            "description": "Skill names to remove from this agent's effective skills list. Applied after `skills_add` during config resolution, so removal wins over addition. Folded into `skills` at resolution time.",
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
           "mcps": {
             "type": "array",
             "items": {

+ 80 - 17
src/cli/skills.ts

@@ -26,6 +26,26 @@ export const PERMISSION_ONLY_SKILLS: PermissionOnlySkill[] = [
   },
 ];
 
+/**
+ * Names of the skills an agent is granted by default when no explicit
+ * `skills` list is configured: bundled custom skills plus
+ * externally-managed skills whose `allowedAgents` includes `'*'` or the
+ * agent name. Order follows the registries: CUSTOM_SKILLS first, then
+ * PERMISSION_ONLY_SKILLS.
+ */
+export function getDefaultGrantedSkillNames(agentName: string): string[] {
+  const names: string[] = [];
+  for (const skill of [...CUSTOM_SKILLS, ...PERMISSION_ONLY_SKILLS]) {
+    if (
+      skill.allowedAgents.includes('*') ||
+      skill.allowedAgents.includes(agentName)
+    ) {
+      names.push(skill.name);
+    }
+  }
+  return names;
+}
+
 /**
  * Get permission presets for a specific agent based on bundled skills.
  * @param agentName - The name of the agent
@@ -64,23 +84,11 @@ export function getSkillPermissionsForAgent(
     return permissions;
   }
 
-  // Apply permissions from bundled custom skills
-  for (const skill of CUSTOM_SKILLS) {
-    const isAllowed =
-      skill.allowedAgents.includes('*') ||
-      skill.allowedAgents.includes(agentName);
-    if (isAllowed && !disabledSkills.has(skill.name)) {
-      permissions[skill.name] = 'allow';
-    }
-  }
-
-  // Apply permissions for externally-managed skills (not installed by this plugin)
-  for (const skill of PERMISSION_ONLY_SKILLS) {
-    const isAllowed =
-      skill.allowedAgents.includes('*') ||
-      skill.allowedAgents.includes(agentName);
-    if (isAllowed && !disabledSkills.has(skill.name)) {
-      permissions[skill.name] = 'allow';
+  // Apply permissions for the skills the agent is granted by default
+  // (bundled custom skills + externally-managed skills)
+  for (const name of getDefaultGrantedSkillNames(agentName)) {
+    if (!disabledSkills.has(name)) {
+      permissions[name] = 'allow';
     }
   }
 
@@ -90,3 +98,58 @@ export function getSkillPermissionsForAgent(
 
   return permissions;
 }
+
+/**
+ * Fold per-agent skill directives into an effective skills list.
+ *
+ * 1. Without a base `skills` list and without additions, there is nothing
+ *    to resolve unless a removal is requested. A removal without a base
+ *    starts from the agent's default grants (orchestrator defaults to
+ *    allow-all, so its working base is `['*']`). Additions without a base
+ *    start from an empty list - they do not inherit default grants.
+ * 2. The working base and `add` are concatenated, deduped (first
+ *    occurrence wins), then removed names are filtered out.
+ * 3. If the result contains `'*'`, every deduped removal that is not
+ *    itself a member of the result is appended as `'!name'` so the
+ *    exclusion beats the wildcard grant.
+ *
+ * Returns `undefined` when nothing is configured to resolve (no base, no
+ * additions, no removals) so the agent keeps its default skill behavior.
+ */
+export function resolveEffectiveSkills(
+  agentName: string,
+  base: readonly string[] | undefined,
+  add: readonly string[] | undefined,
+  remove: readonly string[] | undefined,
+): string[] | undefined {
+  const addList = Array.isArray(add) ? add : [];
+  const removeList = Array.isArray(remove) ? remove : [];
+  if (base === undefined && addList.length === 0 && removeList.length === 0) {
+    return undefined;
+  }
+
+  // A removal without a base list starts from the agent's default grants
+  // (orchestrator defaults to allow-all). Additions without a base start
+  // from an empty list and do not inherit default grants.
+  const workingBase =
+    base ??
+    (addList.length === 0
+      ? agentName === 'orchestrator'
+        ? ['*']
+        : getDefaultGrantedSkillNames(agentName)
+      : []);
+
+  const removeSet = new Set(removeList);
+  let list = [...new Set([...workingBase, ...addList])];
+  list = list.filter((skill) => !removeSet.has(skill));
+
+  if (list.includes('*')) {
+    for (const name of new Set(removeList)) {
+      if (!list.includes(name)) {
+        list.push(`!${name}`);
+      }
+    }
+  }
+
+  return list;
+}

+ 1 - 0
src/config/index.ts

@@ -10,4 +10,5 @@ export {
   getAcpAgentNames,
   getAgentOverride,
   getCustomAgentNames,
+  normalizeAgentSkillDirectives,
 } from './utils';

+ 7 - 0
src/config/loader.ts

@@ -11,6 +11,7 @@ import {
   PluginConfigSchema,
   WebfetchConfigSchema,
 } from './schema';
+import { normalizeAgentSkillDirectives } from './utils';
 
 /**
  * Warning kinds produced during config loading.
@@ -728,6 +729,12 @@ export function loadPluginConfig(
     }
   }
 
+  // Fold per-agent skill directives (skills_add/skills_remove) into the
+  // effective skills list so downstream consumers see plain `skills`.
+  if (config.agents) {
+    config.agents = normalizeAgentSkillDirectives(config.agents);
+  }
+
   // Normalize companion config defaults
   if (config.companion) {
     config.companion = {

+ 5 - 5
src/config/runtime.ts

@@ -41,7 +41,7 @@ import type {
   PluginConfig,
   WebfetchConfig,
 } from './schema';
-import { getCustomAgentNames } from './utils';
+import { getCustomAgentNames, normalizeAgentSkillDirectives } from './utils';
 
 /** A single agent entry from the host opencode.json config. */
 export interface HostAgentConfig {
@@ -270,10 +270,10 @@ export class RuntimeConfig {
       base = mergeAgentOverrides(filePreset, base);
     }
     const runtimePreset = this.runtimePresetAgents();
-    if (!runtimePreset) {
-      return base;
-    }
-    return mergeAgentOverrides(base, runtimePreset);
+    const merged = runtimePreset
+      ? mergeAgentOverrides(base, runtimePreset)
+      : base;
+    return normalizeAgentSkillDirectives(merged);
   }
 
   /**

+ 12 - 0
src/config/schema.ts

@@ -80,6 +80,18 @@ export const AgentOverrideConfigSchema = z
     temperature: z.number().min(0).max(2).optional(),
     variant: z.string().optional().catch(undefined),
     skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
+    skills_add: z
+      .array(z.string())
+      .optional()
+      .describe(
+        "Skill names to add to this agent's effective skills list. Applied after the resolved `skills` list during config resolution; removal via `skills_remove` wins. Folded into `skills` at resolution time.",
+      ),
+    skills_remove: z
+      .array(z.string())
+      .optional()
+      .describe(
+        "Skill names to remove from this agent's effective skills list. Applied after `skills_add` during config resolution, so removal wins over addition. Folded into `skills` at resolution time.",
+      ),
     mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
     prompt: z.string().min(1).optional(),
     orchestratorPrompt: z.string().min(1).optional(),

+ 390 - 0
src/config/skills-add-remove.test.ts

@@ -0,0 +1,390 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { createAgents } from '../agents';
+import {
+  getDefaultGrantedSkillNames,
+  resolveEffectiveSkills,
+} from '../cli/skills';
+import { loadPluginConfig } from './loader';
+import { RuntimeConfig } from './runtime';
+import {
+  type AgentOverrideConfig,
+  type PluginConfig,
+  PluginConfigSchema,
+} from './schema';
+
+const RUNTIME_TEST_DIRECTORY = 'skills-add-remove-runtime';
+
+function runtimeFor(config: PluginConfig | undefined = {}) {
+  RuntimeConfig.reset(RUNTIME_TEST_DIRECTORY);
+  RuntimeConfig.init(RUNTIME_TEST_DIRECTORY, config ?? {});
+  return RuntimeConfig.get(RUNTIME_TEST_DIRECTORY);
+}
+
+describe('skills_add / skills_remove directives', () => {
+  let tempDir: string;
+  let projectDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-add-remove-test-'));
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
+    process.env.XDG_CONFIG_HOME = tempDir;
+    projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+    RuntimeConfig.reset(RUNTIME_TEST_DIRECTORY);
+  });
+
+  function writeUserConfig(config: unknown): void {
+    const userDir = path.join(tempDir, 'opencode');
+    fs.mkdirSync(userDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userDir, 'oh-my-opencode-slim.jsonc'),
+      JSON.stringify(config, null, 2),
+    );
+  }
+
+  function writeProjectConfig(config: unknown): void {
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.jsonc'),
+      JSON.stringify(config, null, 2),
+    );
+  }
+
+  function expectNoDirectiveKeys(entry: AgentOverrideConfig | undefined): void {
+    expect(entry).toBeDefined();
+    expect('skills_add' in (entry ?? {})).toBe(false);
+    expect('skills_remove' in (entry ?? {})).toBe(false);
+  }
+
+  // Resolver unit tests -----------------------------------------------------
+
+  test('resolveEffectiveSkills: base + add with remove winning over add', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['a', 'b'], ['b', 'c', 'd'], ['b', 'd']),
+    ).toEqual(['a', 'c']);
+  });
+
+  test('resolveEffectiveSkills: dedupes within base and across add', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['a', 'a', 'b'], ['b', 'c'], undefined),
+    ).toEqual(['a', 'b', 'c']);
+  });
+
+  test('resolveEffectiveSkills: wildcard base with removal', () => {
+    expect(resolveEffectiveSkills('oracle', ['*'], undefined, ['foo'])).toEqual(
+      ['*', '!foo'],
+    );
+  });
+
+  test('resolveEffectiveSkills: additions without a base', () => {
+    expect(
+      resolveEffectiveSkills('oracle', undefined, ['x', 'y'], undefined),
+    ).toEqual(['x', 'y']);
+  });
+
+  test('resolveEffectiveSkills: removal only, orchestrator defaults to allow-all', () => {
+    expect(
+      resolveEffectiveSkills('orchestrator', undefined, undefined, ['foo']),
+    ).toEqual(['*', '!foo']);
+  });
+
+  test('resolveEffectiveSkills: removal only, other agents start from default grants', () => {
+    expect(
+      resolveEffectiveSkills('explorer', undefined, undefined, ['codemap']),
+    ).toEqual(
+      getDefaultGrantedSkillNames('explorer').filter((n) => n !== 'codemap'),
+    );
+  });
+
+  test('resolveEffectiveSkills: removing an ungranted name without wildcard is a no-op', () => {
+    expect(resolveEffectiveSkills('oracle', ['a'], undefined, ['b'])).toEqual([
+      'a',
+    ]);
+  });
+
+  test('resolveEffectiveSkills: empty skills_add, no base, no remove', () => {
+    expect(
+      resolveEffectiveSkills('oracle', undefined, [], undefined),
+    ).toBeUndefined();
+  });
+
+  test('resolveEffectiveSkills: nothing configured returns undefined', () => {
+    expect(
+      resolveEffectiveSkills('oracle', undefined, undefined, undefined),
+    ).toBeUndefined();
+  });
+
+  test('resolveEffectiveSkills: duplicate remove entries yield one exclusion', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*'], undefined, ['foo', 'foo']),
+    ).toEqual(['*', '!foo']);
+  });
+
+  test('resolveEffectiveSkills: removed base entry still excluded under wildcard', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*', 'foo'], undefined, ['foo']),
+    ).toEqual(['*', '!foo']);
+  });
+
+  test('getDefaultGrantedSkillNames: oracle grants in registry order', () => {
+    const grants = getDefaultGrantedSkillNames('oracle');
+    expect(grants[0]).toBe('simplify');
+    expect(grants).toContain('requesting-code-review');
+    expect(grants).not.toContain('codemap');
+  });
+
+  // Loader E2E ---------------------------------------------------------------
+
+  test('loader: global skills + project skills_add', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['codemap', 'deepwork'] } },
+    });
+    writeProjectConfig({
+      agents: {
+        oracle: { skills_add: ['nexus-backend', 'nexus-frontend'] },
+      },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual([
+      'codemap',
+      'deepwork',
+      'nexus-backend',
+      'nexus-frontend',
+    ]);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: global skills + project skills_remove', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['codemap', 'deepwork'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_remove: ['deepwork'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['codemap']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: simultaneous add + remove with duplicates in one layer', () => {
+    writeUserConfig({
+      agents: {
+        oracle: {
+          skills: ['a', 'b'],
+          skills_add: ['b', 'c', 'd'],
+          skills_remove: ['b', 'd'],
+        },
+      },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['a', 'c']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: agent without existing skills gains skills via skills_add', () => {
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['x', 'y'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['x', 'y']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: removal only, no base list', () => {
+    writeProjectConfig({
+      agents: {
+        orchestrator: { skills_remove: ['foo'] },
+        oracle: { skills_remove: ['codemap'] },
+      },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.orchestrator?.skills).toEqual(['*', '!foo']);
+    expectNoDirectiveKeys(loaded.agents?.orchestrator);
+    expect(loaded.agents?.oracle?.skills).toEqual(
+      getDefaultGrantedSkillNames('oracle').filter((n) => n !== 'codemap'),
+    );
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: custom agent inherits project skills_add', () => {
+    writeUserConfig({
+      agents: { 'my-agent': { model: 'openai/gpt-4o' } },
+    });
+    writeProjectConfig({
+      agents: { 'my-agent': { skills_add: ['proj-skill'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.['my-agent']?.skills).toEqual(['proj-skill']);
+    expectNoDirectiveKeys(loaded.agents?.['my-agent']);
+  });
+
+  test('loader: preset skills + project skills_add', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: { p1: { oracle: { skills: ['a', 'b'] } } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['c'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['a', 'b', 'c']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: preset skills + project skills_remove', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: { p1: { oracle: { skills: ['a', 'b'] } } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_remove: ['b'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['a']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: root skills replace preset skills, directive still applies', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: { p1: { oracle: { skills: ['a', 'b'] } } },
+      agents: { oracle: { skills: ['x'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['c'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['x', 'c']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: preset-layer removal survives field-level merge', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: {
+        p1: {
+          oracle: { skills: ['a', 'b'], skills_remove: ['a'] },
+        },
+      },
+      agents: { oracle: { skills: ['x'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['x']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: wildcard base + project removal', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['*'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_remove: ['foo'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle?.skills).toEqual(['*', '!foo']);
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  test('loader: plain skills entry without directives is unchanged', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['*'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle).toEqual({ skills: ['*'] });
+    expectNoDirectiveKeys(loaded.agents?.oracle);
+  });
+
+  // Schema validation --------------------------------------------------------
+
+  test('schema: rejects invalid skills_add / skills_remove values', () => {
+    for (const invalid of [
+      { agents: { oracle: { skills_add: 'foo' } } },
+      { agents: { oracle: { skills_add: [1, 2] } } },
+      { agents: { oracle: { skills_remove: 'foo' } } },
+      { agents: { oracle: { skills_remove: [1, 2] } } },
+      { presets: { p1: { oracle: { skills_add: 'foo' } } } },
+    ]) {
+      expect(
+        PluginConfigSchema.safeParse(invalid).success,
+        JSON.stringify(invalid),
+      ).toBe(false);
+    }
+  });
+
+  test('schema: accepts string arrays in root agents and presets', () => {
+    const valid = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: { skills_add: ['a'], skills_remove: ['b'] },
+      },
+      presets: {
+        p1: { oracle: { skills_add: ['a'], skills_remove: ['b'] } },
+      },
+    });
+    expect(valid.success).toBe(true);
+  });
+
+  // RuntimeConfig -------------------------------------------------------------
+
+  test('runtime: agents() folds preset and runtime-preset directives', () => {
+    const config: PluginConfig = {
+      preset: 'p1',
+      agents: { oracle: { skills: ['a'] } },
+      presets: {
+        p1: { oracle: { skills_add: ['b'] } },
+        p2: { oracle: { skills_remove: ['a'] } },
+      },
+    };
+    const runtime = runtimeFor(config);
+
+    const initial = runtime.agents().oracle;
+    expect(initial.skills).toEqual(['a', 'b']);
+    expectNoDirectiveKeys(initial);
+
+    runtime.setRuntimePreset('p2');
+    const switched = runtime.agents().oracle;
+    expect(switched.skills).toEqual(['b']);
+    expectNoDirectiveKeys(switched);
+  });
+
+  // createAgents integration ----------------------------------------------------
+
+  test('createAgents: folded skills become permission grants', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { skills: ['simplify'], skills_add: ['my-skill'] },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle).toBeDefined();
+    const skillPermissions = (
+      oracle?.config.permission as Record<string, unknown>
+    )?.skill as Record<string, unknown> | undefined;
+    expect(skillPermissions?.['my-skill']).toBe('allow');
+    expect(skillPermissions?.simplify).toBe('allow');
+  });
+});

+ 43 - 0
src/config/utils.ts

@@ -1,3 +1,4 @@
+import { resolveEffectiveSkills } from '../cli/skills';
 import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import type { AgentOverrideConfig, PluginConfig } from './schema';
 
@@ -44,3 +45,45 @@ export function getCustomAgentNames(
 export function getAcpAgentNames(config: PluginConfig | undefined): string[] {
   return Object.keys(config?.acpAgents ?? {});
 }
+
+/**
+ * Fold per-agent skill directives (`skills_add` / `skills_remove`) into the
+ * effective `skills` list so downstream consumers (agent factories, hooks)
+ * only ever see a plain `skills` array. Entries without directives keep
+ * their original reference; the input record is returned unchanged when no
+ * entry needs folding.
+ */
+export function normalizeAgentSkillDirectives(
+  agents: Record<string, AgentOverrideConfig>,
+): Record<string, AgentOverrideConfig> {
+  let changed = false;
+  const result: Record<string, AgentOverrideConfig> = {};
+  for (const [name, override] of Object.entries(agents)) {
+    if (
+      override.skills_add === undefined &&
+      override.skills_remove === undefined
+    ) {
+      result[name] = override;
+      continue;
+    }
+    changed = true;
+    const effective = resolveEffectiveSkills(
+      name,
+      override.skills,
+      override.skills_add,
+      override.skills_remove,
+    );
+    const {
+      skills: _skills,
+      skills_add: _skillsAdd,
+      skills_remove: _skillsRemove,
+      ...rest
+    } = override;
+    const entry: AgentOverrideConfig = { ...rest };
+    if (effective !== undefined) {
+      entry.skills = effective;
+    }
+    result[name] = entry;
+  }
+  return changed ? result : agents;
+}