Explorar el Código

Merge pull request #1190 from Aveer/feat/skills-add-remove

feat(config): per-agent skills_add/skills_remove directives
Alvin hace 1 día
padre
commit
438776e312

+ 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 agent resolution — after all layers (user config, project config, presets, runtime `/preset` switching) have determined the effective `skills` value — and stripped from the final agent configuration, so agent definitions and hooks only ever see a plain `skills` list. On an agent without a `skills` list, directives resolve against that agent's default grants, so `skills_add` keeps the defaults and appends.
+
+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:

+ 27 - 0
docs/skills.md

@@ -309,3 +309,30 @@ 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
+- On an agent without a `skills` list, directives resolve against that agent's default grants (orchestrator: all skills), so `skills_add` keeps the defaults and appends, and `skills_remove` prunes from them
+- `skills_add` never overrides an inherited exclusion: adding a name that the effective list already excludes (as `"!<name>"`) is a no-op. To re-allow it, lift the exclusion with a `skills_remove` entry of the form `"!<name>"`
+- A removal entry of the form `"!<name>"` removes the exclusion token itself (it lifts an existing exclusion); the `"*"` token is never expanded

+ 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": {

+ 109 - 17
src/cli/skills.ts

@@ -1,3 +1,4 @@
+import { AGENT_ALIASES } from '../config/constants';
 import { CUSTOM_SKILLS } from './custom-skills';
 
 /**
@@ -26,6 +27,27 @@ 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
+ * canonical agent name. Order follows the registries: CUSTOM_SKILLS first,
+ * then PERMISSION_ONLY_SKILLS.
+ */
+export function getDefaultGrantedSkillNames(agentName: string): string[] {
+  const canonicalAgentName = AGENT_ALIASES[agentName] ?? agentName;
+  const names: string[] = [];
+  for (const skill of [...CUSTOM_SKILLS, ...PERMISSION_ONLY_SKILLS]) {
+    if (
+      skill.allowedAgents.includes('*') ||
+      skill.allowedAgents.includes(canonicalAgentName)
+    ) {
+      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 +86,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 +100,85 @@ export function getSkillPermissionsForAgent(
 
   return permissions;
 }
+
+/**
+ * Fold per-agent skill directives into an effective skills list.
+ *
+ * 1. Without a base `skills` list the working base is the agent's default
+ *    grants (orchestrator defaults to allow-all, so its working base is
+ *    `['*']`), so `skills_add` alone keeps the defaults and appends, and
+ *    `skills_remove` alone prunes from the defaults.
+ * 2. Explicit token removals are applied to the working base first. This
+ *    lets `skills_remove: ['!name']` lift an inherited exclusion before
+ *    additions are evaluated.
+ * 3. Remaining exclusion (`'!name'`) tokens prevent `skills_add` from
+ *    re-granting the excluded skill. Plain-name removals also suppress an
+ *    addition of the same name, so removal wins over addition.
+ * 4. If the result contains `'*'`, a plain-name removal is granted
+ *    implicitly by the wildcard, so it is made explicit by appending the
+ *    existing `'!name'` exclusion token - unless already present.
+ *
+ * The returned token list is consumed by the existing skill permission
+ * resolver (`getSkillPermissionsForAgent`), which continues to interpret
+ * `'*'` and `'!name'` as before.
+ *
+ * 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;
+  }
+
+  // Without a base list the working base is the agent's default grants
+  // (orchestrator defaults to allow-all), so additions build on top of
+  // what the agent already gets and removals prune from it.
+  const workingBase =
+    base ??
+    (agentName === 'orchestrator'
+      ? ['*']
+      : getDefaultGrantedSkillNames(agentName));
+
+  // Apply explicit token removals before deriving active exclusions. This
+  // is what makes removing '!foo' genuinely lift that inherited exclusion.
+  const removeSet = new Set(removeList);
+  const baseAfterRemoval = workingBase.filter((token) => !removeSet.has(token));
+
+  // Additions must not override exclusions that remain after removals.
+  const excluded = new Set(
+    baseAfterRemoval
+      .filter((token) => token.startsWith('!'))
+      .map((token) => token.slice(1)),
+  );
+
+  const list = [
+    ...new Set([
+      ...baseAfterRemoval,
+      ...addList.filter((name) => !excluded.has(name) && !removeSet.has(name)),
+    ]),
+  ];
+
+  // A plain-name removal that the list grants implicitly via '*' must be
+  // made explicit with the existing '!name' exclusion token, unless the
+  // exclusion is already present.
+  if (list.includes('*')) {
+    for (const name of new Set(removeList)) {
+      if (name.startsWith('!')) {
+        continue;
+      }
+      const exclusion = `!${name}`;
+      if (!list.includes(exclusion)) {
+        list.push(exclusion);
+      }
+    }
+  }
+
+  return list;
+}

+ 1 - 0
src/config/index.ts

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

+ 6 - 0
src/config/loader.ts

@@ -728,6 +728,12 @@ export function loadPluginConfig(
     }
   }
 
+  // Note: per-agent skill directives (skills_add/skills_remove) are left
+  // raw in the returned config. They are folded into the effective skills
+  // list by RuntimeConfig.agents(), the single resolution point, so runtime
+  // /preset switching re-resolves them from the raw preset layers instead
+  // of operating on an already-baked skills array.
+
   // 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(),

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

@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  getDefaultGrantedSkillNames,
+  resolveEffectiveSkills,
+} from '../cli/skills';
+
+describe('skills_add / skills_remove review regressions', () => {
+  test('lifting an inherited exclusion allows the same skill to be added', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['a', '!foo'], ['foo'], ['!foo']),
+    ).toEqual(['a', 'foo']);
+  });
+
+  test('plain-name removal still wins over adding the same skill', () => {
+    expect(resolveEffectiveSkills('oracle', ['a'], ['foo'], ['foo'])).toEqual([
+      'a',
+    ]);
+  });
+
+  test('legacy agent aliases resolve the same default grants as canonical names', () => {
+    expect(getDefaultGrantedSkillNames('explore')).toEqual(
+      getDefaultGrantedSkillNames('explorer'),
+    );
+    expect(getDefaultGrantedSkillNames('frontend-ui-ux-engineer')).toEqual(
+      getDefaultGrantedSkillNames('designer'),
+    );
+  });
+});

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

@@ -0,0 +1,573 @@
+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);
+  }
+
+  // Effective skills for a loaded config, resolved exactly the way the
+  // plugin consumes them (RuntimeConfig.agents()).
+  function effectiveAgent(
+    loaded: PluginConfig,
+    name = 'oracle',
+  ): AgentOverrideConfig {
+    return runtimeFor(loaded).agents()[name] as AgentOverrideConfig;
+  }
+
+  // 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 keep default grants', () => {
+    expect(
+      resolveEffectiveSkills('oracle', undefined, ['x', 'y'], undefined),
+    ).toEqual([...getDefaultGrantedSkillNames('oracle'), 'x', 'y']);
+  });
+
+  test('resolveEffectiveSkills: additions without a base, orchestrator keeps allow-all', () => {
+    expect(
+      resolveEffectiveSkills('orchestrator', undefined, ['x'], undefined),
+    ).toEqual(['*', 'x']);
+  });
+
+  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('resolveEffectiveSkills: wildcard token list is preserved, exclusion tokens are not duplicated', () => {
+    // skills: ["*", "!legacy"], skills_add: ["project-skill"],
+    // skills_remove: ["!legacy"] -> the '!legacy' token is removed (the
+    // exclusion is lifted); '*' and the existing resolver are untouched.
+    expect(
+      resolveEffectiveSkills(
+        'oracle',
+        ['*', '!legacy'],
+        ['project-skill'],
+        ['!legacy'],
+      ),
+    ).toEqual(['*', 'project-skill']);
+  });
+
+  test('resolveEffectiveSkills: plain-name removal does not duplicate an existing exclusion', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*', '!foo'], undefined, ['foo']),
+    ).toEqual(['*', '!foo']);
+  });
+
+  test('resolveEffectiveSkills: removing an exclusion token lifts it under wildcard', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*', '!foo'], undefined, ['!foo']),
+    ).toEqual(['*']);
+  });
+
+  test('resolveEffectiveSkills: removing an absent exclusion token is a no-op', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*'], undefined, ['!foo']),
+    ).toEqual(['*']);
+  });
+
+  test('resolveEffectiveSkills: removing a plain name on a concrete list where only the exclusion exists is a no-op', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['a', 'b', '!c'], ['b'], ['c']),
+    ).toEqual(['a', 'b', '!c']);
+  });
+
+  test('resolveEffectiveSkills: additions do not override an inherited wildcard exclusion', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['*', '!foo'], ['foo'], undefined),
+    ).toEqual(['*', '!foo']);
+  });
+
+  test('resolveEffectiveSkills: additions skip excluded names on concrete lists too', () => {
+    expect(
+      resolveEffectiveSkills('oracle', ['a', '!b'], ['b', 'c'], undefined),
+    ).toEqual(['a', '!b', 'c']);
+  });
+
+  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 ---------------------------------------------------------------
+  // The loader keeps directives raw; the effective list is resolved by
+  // RuntimeConfig.agents(), so E2E assertions go through runtimeFor().
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual([
+      'codemap',
+      'deepwork',
+      'nexus-backend',
+      'nexus-frontend',
+    ]);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['codemap']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['a', 'c']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  test('loader: agent without existing skills gains skills via skills_add', () => {
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['x', 'y'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual([
+      ...getDefaultGrantedSkillNames('oracle'),
+      'x',
+      'y',
+    ]);
+    expectNoDirectiveKeys(effective);
+  });
+
+  test('loader: removal only, no base list', () => {
+    writeProjectConfig({
+      agents: {
+        orchestrator: { skills_remove: ['foo'] },
+        oracle: { skills_remove: ['codemap'] },
+      },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    const orchestrator = effectiveAgent(loaded, 'orchestrator');
+    expect(orchestrator.skills).toEqual(['*', '!foo']);
+    expectNoDirectiveKeys(orchestrator);
+    const oracle = effectiveAgent(loaded);
+    expect(oracle.skills).toEqual(
+      getDefaultGrantedSkillNames('oracle').filter((n) => n !== 'codemap'),
+    );
+    expectNoDirectiveKeys(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 });
+    const effective = effectiveAgent(loaded, 'my-agent');
+    expect(effective.skills).toEqual([
+      ...getDefaultGrantedSkillNames('my-agent'),
+      'proj-skill',
+    ]);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['a', 'b', 'c']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['a']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['x', 'c']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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 });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['x']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  test('loader: wildcard base + project removal', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['*'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_remove: ['foo'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['*', '!foo']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  test('loader: project skills_add does not re-grant an excluded skill', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['*', '!foo'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['foo', 'bar'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    const effective = effectiveAgent(loaded);
+    expect(effective.skills).toEqual(['*', '!foo', 'bar']);
+    expectNoDirectiveKeys(effective);
+  });
+
+  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);
+  });
+
+  test('loader: config keeps raw directives for runtime resolution', () => {
+    writeUserConfig({
+      agents: { oracle: { skills: ['a'] } },
+    });
+    writeProjectConfig({
+      agents: { oracle: { skills_add: ['b'] } },
+    });
+
+    const loaded = loadPluginConfig(projectDir, { silent: true });
+    expect(loaded.agents?.oracle).toEqual({
+      skills: ['a'],
+      skills_add: ['b'],
+    });
+    expect(effectiveAgent(loaded).skills).toEqual(['a', 'b']);
+  });
+
+  // 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);
+  });
+
+  test('runtime: higher runtime preset replaces startup-preset directive', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: {
+        p1: { oracle: { skills_add: ['b'] } },
+        p2: { oracle: { skills_add: ['c'] } },
+      },
+      agents: { oracle: { skills: ['a'] } },
+    });
+
+    const runtime = runtimeFor(loadPluginConfig(projectDir, { silent: true }));
+    expect(runtime.agents().oracle.skills).toEqual(['a', 'b']);
+
+    runtime.setRuntimePreset('p2');
+    const switched = runtime.agents().oracle;
+    expect(switched.skills).toEqual(['a', 'c']);
+    expectNoDirectiveKeys(switched);
+  });
+
+  test('runtime: empty skills_add in higher preset suppresses startup directive', () => {
+    writeUserConfig({
+      preset: 'p1',
+      presets: {
+        p1: { oracle: { skills_add: ['b'] } },
+        p2: { oracle: { skills_add: [] } },
+      },
+      agents: { oracle: { skills: ['a'] } },
+    });
+
+    const runtime = runtimeFor(loadPluginConfig(projectDir, { silent: true }));
+    expect(runtime.agents().oracle.skills).toEqual(['a', 'b']);
+
+    runtime.setRuntimePreset('p2');
+    expect(runtime.agents().oracle.skills).toEqual(['a']);
+  });
+
+  // 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');
+  });
+
+  test('createAgents: add-only directive keeps default grants', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { 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');
+  });
+
+  test('createAgents: added skill that the base list excludes stays denied', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { skills: ['*', '!foo'], skills_add: ['foo'] },
+      },
+    };
+    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?.foo).toBe('deny');
+  });
+});

+ 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;
+}

+ 67 - 0
src/hooks/filter-available-skills/skills-directives.integration.test.ts

@@ -0,0 +1,67 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../../config';
+import { RuntimeConfig } from '../../config/runtime';
+import { createFilterAvailableSkillsHook } from './index';
+
+const mockCtx = {} as PluginInput;
+const TEST_DIRECTORY = 'runtime-test-filter-skills-directives';
+
+function runtimeFor(config: PluginConfig) {
+  RuntimeConfig.reset(TEST_DIRECTORY);
+  RuntimeConfig.init(TEST_DIRECTORY, config);
+  return RuntimeConfig.get(TEST_DIRECTORY);
+}
+
+function skillBlock(name: string): string {
+  return `<skill>\n  <name>${name}</name>\n  <description>${name} description</description>\n  <location>file:///tmp/${name}</location>\n</skill>`;
+}
+
+function availableSkillsBlock(...names: string[]): string {
+  return `<available_skills>\n${names.map((name) => skillBlock(name)).join('\n')}\n</available_skills>`;
+}
+
+describe('available-skills integration with skill directives', () => {
+  test('lifted exclusion is re-added before the hook filters the prompt', async () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          skills: ['skill1', '!skill2'],
+          skills_add: ['skill2'],
+          skills_remove: ['!skill2'],
+        },
+      },
+    };
+
+    const runtime = runtimeFor(config);
+    expect(runtime.agents().oracle?.skills).toEqual(['skill1', 'skill2']);
+
+    const hook = createFilterAvailableSkillsHook(mockCtx, runtime);
+    const output = {
+      messages: [
+        {
+          info: { role: 'system' },
+          parts: [
+            {
+              type: 'text',
+              text: availableSkillsBlock('skill1', 'skill2', 'skill3'),
+            },
+          ],
+        },
+        {
+          info: { role: 'user', agent: 'oracle' },
+          parts: [{ type: 'text', text: 'check skills' }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output);
+
+    const resultText = output.messages[0].parts[0].text;
+    expect(resultText).toContain('<name>skill1</name>');
+    expect(resultText).toContain('<name>skill2</name>');
+    expect(resultText).not.toContain('<name>skill3</name>');
+
+    RuntimeConfig.reset(TEST_DIRECTORY);
+  });
+});