From 9a0c9bf9f9a87ad2f238411a7fede38110aee939 Mon Sep 17 00:00:00 2001 From: rudyll Date: Wed, 10 Jun 2026 13:13:29 +0800 Subject: [PATCH] feat(workspaces): auto-discover user skills under data/skills/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the persona override model (data/brain/persona.md wins over default/persona.default.md) for skills: - Auto-discover user skills under `data/skills//` (any dir with a SKILL.md) and merge into the template's bundledSkills. Lets users add custom skills without editing the shipped template.json — survives app upgrades since data/ is in USER_DATA_HOME. - A user-shipped skill of the same name as a default one wins, same precedence as persona. Useful for tweaking a built-in (e.g. personal sector-rotation screen) without forking. - Empty / SKILL.md-less directories under data/skills/ are silently skipped, so WIP folders don't accidentally inject. context-injector.ts: extract `discoverUserSkills()` + `resolveSkillSource()` helpers; merge template skills with discovered user skills (de-duplicated, deterministic order); resolve each skill's source through the user-first helper before copying to the three CLI discovery paths. Tests: 4 new cases covering auto-discovery, override precedence, and WIP-dir guard. All 15 specs pass. --- src/workspaces/context-injector.spec.ts | 52 ++++++++++++++++++++++++- src/workspaces/context-injector.ts | 50 ++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/workspaces/context-injector.spec.ts b/src/workspaces/context-injector.spec.ts index 5b654e29..44eea491 100644 --- a/src/workspaces/context-injector.spec.ts +++ b/src/workspaces/context-injector.spec.ts @@ -7,7 +7,7 @@ */ import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -168,3 +168,53 @@ describe('injectWorkspaceContext — skills', () => { expect(await read('.pi/skills/scan-value-chain/SKILL.md')).toBe(expected); // Pi }); }); + +// User-skill discovery + override: skills dropped under `data/skills//` +// are auto-bundled (no template edit needed), and a user-shipped skill of the +// same name as a default one wins — same precedence model as persona. +describe('injectWorkspaceContext — user skills (data/skills/)', () => { + const USER_SKILL_NAME = '__test-user-skill__'; + const OVERRIDE_NAME = 'scan-value-chain'; // shipped default; user copy should win + const userSkillDir = dataPath('skills', USER_SKILL_NAME); + const overrideDir = dataPath('skills', OVERRIDE_NAME); + const userSkillBody = '---\nname: __test-user-skill__\ndescription: user-installed test skill\n---\nUSER-BODY\n'; + const overrideBody = '---\nname: scan-value-chain\ndescription: user override\n---\nOVERRIDE-BODY\n'; + + beforeEach(async () => { + await mkdir(userSkillDir, { recursive: true }); + await writeFile(join(userSkillDir, 'SKILL.md'), userSkillBody); + await mkdir(overrideDir, { recursive: true }); + await writeFile(join(overrideDir, 'SKILL.md'), overrideBody); + }); + afterEach(async () => { + await rm(userSkillDir, { recursive: true, force: true }); + await rm(overrideDir, { recursive: true, force: true }); + }); + + it('auto-discovers user skills from data/skills/* even when template lists none', async () => { + await injectWorkspaceContext({ template: makeTemplate({ bundledSkills: [] }), wsId: 'ws-abc', dir }); + expect(await read(`.claude/skills/${USER_SKILL_NAME}/SKILL.md`)).toBe(userSkillBody); + expect(await read(`.agents/skills/${USER_SKILL_NAME}/SKILL.md`)).toBe(userSkillBody); + expect(await read(`.pi/skills/${USER_SKILL_NAME}/SKILL.md`)).toBe(userSkillBody); + }); + + it('a user-shipped skill of the same name wins over the default (persona-style precedence)', async () => { + await injectWorkspaceContext({ + template: makeTemplate({ bundledSkills: [OVERRIDE_NAME] }), + wsId: 'ws-abc', + dir, + }); + expect(await read(`.claude/skills/${OVERRIDE_NAME}/SKILL.md`)).toBe(overrideBody); + }); + + it('skips a data/skills// entry that has no SKILL.md (WIP guard)', async () => { + const wipDir = dataPath('skills', '__wip-no-manifest__'); + await mkdir(wipDir, { recursive: true }); + try { + await injectWorkspaceContext({ template: makeTemplate({ bundledSkills: [] }), wsId: 'ws-abc', dir }); + expect(existsSync(join(dir, '.claude/skills/__wip-no-manifest__'))).toBe(false); + } finally { + await rm(wipDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/workspaces/context-injector.ts b/src/workspaces/context-injector.ts index 61fb0248..90d2222b 100644 --- a/src/workspaces/context-injector.ts +++ b/src/workspaces/context-injector.ts @@ -12,7 +12,7 @@ */ import { existsSync } from 'node:fs'; -import { cp, mkdir, readFile } from 'node:fs/promises'; +import { cp, mkdir, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { dataPath, defaultPath } from '@/core/paths.js'; @@ -116,7 +116,15 @@ export async function injectWorkspaceContext(opts: { await writeWorkspaceFile(dir, 'AGENTS.md', composed); } - if (template.bundledSkills.length > 0) { + // Merge template-declared skills with auto-discovered user skills from + // `data/skills//` (USER_DATA_HOME survives upgrades, mirroring the same + // override model `data/brain/persona.md` uses for persona). A user skill with + // the same name as a default one wins — `resolveSkillSource` below picks the + // user copy first. Order: template first, then user-added (de-duplicated). + const userSkills = await discoverUserSkills(); + const allSkills = [...new Set([...template.bundledSkills, ...userSkills])]; + + if (allSkills.length > 0) { // Each agent CLI discovers skills from its own dir: Claude Code reads // `.claude/skills`, Codex reads `.agents/skills`, Pi reads `.pi/skills`. // (opencode reads `.claude/skills` + `.agents/skills` by default via its @@ -125,8 +133,8 @@ export async function injectWorkspaceContext(opts: { await mkdir(join(dir, '.claude/skills'), { recursive: true }); await mkdir(join(dir, '.agents/skills'), { recursive: true }); await mkdir(join(dir, '.pi/skills'), { recursive: true }); - for (const name of template.bundledSkills) { - const src = defaultPath('skills', name); + for (const name of allSkills) { + const src = resolveSkillSource(name); await cp(src, join(dir, '.claude/skills', name), { recursive: true }); await cp(src, join(dir, '.agents/skills', name), { recursive: true }); await cp(src, join(dir, '.pi/skills', name), { recursive: true }); @@ -134,6 +142,40 @@ export async function injectWorkspaceContext(opts: { } } +/** + * Auto-discover user-installed skills under `data/skills//`. A directory + * with a `SKILL.md` inside counts as a skill; anything else (stray files, dirs + * without a manifest) is silently ignored so users can stash WIP without + * accidentally injecting it into every new workspace. + * + * Returns sorted names for deterministic ordering across runs. + */ +async function discoverUserSkills(): Promise { + const dir = dataPath('skills'); + if (!existsSync(dir)) return []; + try { + const entries = await readdir(dir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && existsSync(join(dir, e.name, 'SKILL.md'))) + .map((e) => e.name) + .sort(); + } catch { + return []; + } +} + +/** + * A user-shipped skill (`data/skills//`) wins over the default + * (`default/skills//`) — same precedence as persona. Lets users override + * a shipped skill (e.g. tweak `sector-rotation` for a personal screen) without + * forking the app, and lets them add new skills without editing + * `template.json`. + */ +function resolveSkillSource(name: string): string { + const userSrc = dataPath('skills', name); + return existsSync(userSrc) ? userSrc : defaultPath('skills', name); +} + /** * Live persona override (`data/brain/persona.md`) wins; else the shipped * default (`default/persona.default.md`); else none. Same precedence the