diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index a61af26c..c4dedcdf 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -89,8 +89,10 @@ path (measured here from a 77-character one). `skill get ` refuses, `skill get --all` leaves the skill out and says so on stderr, `skill path ` and `skill show ` refuse, and `skill list --json` reports `blockedBy: "recall"` with `path: null`. With no - team config to consult — or one it cannot load — it fails open: a refusal the - member cannot act on is worse than serving the workflow. The Stop-hook share + config on the machine at all it fails open: a refusal a fresh install cannot act + on is worse than serving the workflow. A config that exists but cannot be loaded + blocks instead (`blockedBy: "config"`), since recall and the source are then + unknown and the workflow would fail at `teamai contribute`. The Stop-hook share reminder is gated the same way (`contributeHintAllowed`, `src/hook-handlers.ts`), because it points at this command. The gate lives in one place: `resolveServableSkill` (`src/skill-content.ts`) is the only way to obtain a @@ -176,7 +178,7 @@ member's own skill that uses a legacy name, a root TeamAI never managed because directory. Checking the path alone would have deleted those. What is removed is still copied first to `~/.teamai/removed-skills//////`, so no -removal is a one-way door. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the +removal is a one-way door. The legacy trees go only after the stub deployed for that agent: pruning first and then failing to write the stub (a link, a read-only directory) would leave nothing to discover. Codex reads both `.codex/skills` and the shared `.agents/skills`, and the stub goes to the shared one when a copy already lives there; the copy an earlier release left in the other root is retired by the same rule (`retireOtherCodexCopy`), so Codex never sees a stale `teamai` beside the current diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 8ce6f620..c32fb584 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -158,7 +158,6 @@ Generated: do not edit by hand. Regenerate with - `--token ` — API token for the HTTP endpoint (stored 0600, never committed) - `--force` — Overwrite an existing HTTP source config - `teamai source remove-http` — Remove the HTTP source and clean up its resources - - `teamai source reconcile-plugins` — Run plugin reconcile worker (called internally by session_start hook) - `teamai source list` — List all configured sources - `teamai source browse ` — Browse public skills from a source @@ -207,18 +206,6 @@ Generated: do not edit by hand. Regenerate with - `teamai webhook test` — Send test event to webhook endpoints - `--url ` — Test specific endpoint URL -## track - -- `teamai track [toolName] [toolInput]` — Track a tool usage event (called by PostToolUse hook) - - `--stdin` — Read hook data from STDIN (Claude Code hook format) - - `--tool ` — Tool identifier for usage attribution (e.g. claude, claude-internal) - -## track-slash - -- `teamai track-slash` — Track a slash command usage (called by UserPromptSubmit hook) - - `--stdin` — Read hook data from STDIN - - `--tool ` — Tool identifier for usage attribution (e.g. claude, claude-internal) - ## stats - `teamai stats` — Show local skill usage statistics @@ -244,12 +231,6 @@ Generated: do not edit by hand. Regenerate with - `teamai dashboard` — Start the AI coding session dashboard (Web UI) - `-p, --port ` — Port number -## dashboard-report - -- `teamai dashboard-report` — Report session state to dashboard (called by hooks) - - `--stdin` — Read hook data from STDIN - - `--tool ` — Tool identifier (e.g. claude, claude-internal) - ## hook-dispatch - `teamai hook-dispatch ` — Unified hook dispatcher — handles all teamai hooks for a given event in one process @@ -265,12 +246,6 @@ Generated: do not edit by hand. Regenerate with - `--project-id ` — Project ID from /projects/mine - `--skip` — Mark current workspace as skipped (never prompt again) -## contribute-check - -- `teamai contribute-check` — Check if session qualifies for contribution (called by PostToolUse hook) - - `--stdin` — Read hook data from STDIN - - `--tool ` — Tool identifier (e.g. claude, claude-internal) - ## contribute - `teamai contribute` — Contribute session knowledge to team repo @@ -301,12 +276,6 @@ Generated: do not edit by hand. Regenerate with - `--category ` — Target category: skills | rules | docs - `--dry-run` — Show what would be done without making changes -## todowrite-hint - -- `teamai todowrite-hint` — Remind the agent to invoke teamai-recall when TodoWrite is used (PostToolUse hook) - - `--stdin` — Read hook data from STDIN - - `--tool ` — Source AI tool (claude / codebuddy / cursor) - ## import - `teamai import` — Import knowledge from local directories, remote repos, organizations, MRs, or iWiki @@ -338,12 +307,6 @@ Generated: do not edit by hand. Regenerate with - `--max-bytes ` (hidden) — Override capacity cap for --cache-gc - `--stale-days ` (hidden) — Threshold for stale-eviction in days (default 30) -## mr-hint - -- `teamai mr-hint` — Hint AI about recently merged but un-imported MRs (SessionStart hook) - - `--stdin` — Read hook data from STDIN - - `--tool ` — Source AI tool (claude / codebuddy / cursor) - ## codebase - `teamai codebase` — Inspect and maintain team-codebase outputs @@ -382,10 +345,3 @@ Generated: do not edit by hand. Regenerate with - `--write-mode ` — Write strategy: direct | pending-review - `--output ` — Write artifacts to directory - `--individual-comments` — Post each suggestion as separate comment with reaction/resolve support - -## deep-enrich - -- `teamai deep-enrich` — Run deep AI knowledge generation for an imported repo - - `--project ` — Project slug (directory name in evidence/code/) - - `--wiki-root ` — Teamwiki root path - - `--max-modules ` — Max modules to process (cost control) diff --git a/skill-data/wiki/references/templates/project-overview.md b/skill-data/wiki/references/templates/project-overview.md index f6d54ead..653c1dbd 100644 --- a/skill-data/wiki/references/templates/project-overview.md +++ b/skill-data/wiki/references/templates/project-overview.md @@ -78,7 +78,7 @@ ### Knowledge base update notes -- **Incremental update**: `code-to-knowledge --update` updates only the documents of changed files +- **Incremental update**: `teamai codebase --extract --project --incremental` re-extracts only the changed files - **Full rebuild**: recommended after large-scale code refactoring - **Last updated**: `` @@ -138,7 +138,7 @@ ## Code baseline version -> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `code-to-knowledge --update` for an incremental update. +> ⚠️ This knowledge base was generated from the code version below. After the code evolves, run `teamai codebase --extract --project --incremental` for an incremental update. - **Commit**: `` - **Tag**: `` diff --git a/src/__tests__/skill-recall-gate.test.ts b/src/__tests__/skill-recall-gate.test.ts index 32618ba5..29864bd6 100644 --- a/src/__tests__/skill-recall-gate.test.ts +++ b/src/__tests__/skill-recall-gate.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; const autoDetectInit = vi.fn(); -vi.mock('../config.js', () => ({ autoDetectInit })); +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + autoDetectInit, +})); import { resolveServableSkill, skillCatalog, skillGet, skillPath } from '../skill-content.js'; @@ -150,10 +153,24 @@ describe('recall gate on served skills', () => { }); it('fails open when there is no team config to consult', async () => { - autoDetectInit.mockRejectedValue(new Error('not initialized')); + const { NotInitializedError } = await import('../config.js'); + autoDetectInit.mockRejectedValue(new NotInitializedError('teamai is not initialized. Run `teamai init` first.')); // A fresh machine reading the docs gets the content, not a refusal it // cannot act on. expect((await resolveServableSkill('share')).kind).toBe('found'); }); + + it('blocks share when a config exists but cannot be loaded, since recall and the source are then unknown', async () => { + autoDetectInit.mockRejectedValue(new Error('Team config (teamai.yaml) not found. Check your repo path.')); + + expect(await resolveServableSkill('share')).toEqual({ kind: 'blocked', name: 'share', reason: 'config' }); + await skillGet(['share']); + expect(process.exitCode).toBe(1); + expect(stdout).toBe(''); + expect(stderr).toContain('config on this machine could not be loaded'); + expect((await skillCatalog()).find((entry) => entry.name === 'share')).toMatchObject({ blockedBy: 'config', path: null }); + // Only share depends on the config; the rest is still served. + expect((await resolveServableSkill('core')).kind).toBe('found'); + }); }); diff --git a/src/__tests__/skill-show.test.ts b/src/__tests__/skill-show.test.ts index 3d1b14c3..616c6cfd 100644 --- a/src/__tests__/skill-show.test.ts +++ b/src/__tests__/skill-show.test.ts @@ -87,7 +87,8 @@ function captureLogs() { } async function runSkillShow(name: string, fx: Fixture): Promise { - vi.doMock('../config.js', () => ({ + vi.doMock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), autoDetectInit: async () => ({ localConfig: fx.localConfig, teamConfig: fx.teamConfig }), })); const { skillShow } = await import('../skill-cmd.js'); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index 5511c245..c794d4f1 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -976,6 +976,50 @@ describe('deployBuiltinSkills — skip uninstalled tools', () => { expect(bodies.sort()).toEqual([wikiSkillTagged('project'), wikiSkillTagged('user')]); }); + it('keeps the legacy skills when the stub could not be deployed, so the agent keeps one to discover', async () => { + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + + // The stub destination is a link, so the stub is refused; pruning first + // would leave the agent with neither the old skills nor the new one. + const outside = path.join(tmpDir, 'outside/teamai'); + await fse.ensureDir(outside); + await fse.ensureDir(path.join(homeDir, '.claude/skills/team-wiki-codebase')); + await fse.writeFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), WIKI_SKILL); + await fse.symlink(outside, path.join(homeDir, '.claude/skills/teamai'), 'dir'); + + const deployed = await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + + expect(deployed).toBe(0); + expect(await fse.readFile(path.join(homeDir, '.claude/skills/team-wiki-codebase/SKILL.md'), 'utf8')).toBe(WIKI_SKILL); + }); + + it('reports a legacy directory it emptied but could not remove, instead of calling it removed', async () => { + if (process.getuid?.() === 0) return; // root ignores directory permissions + const { deployBuiltinSkills } = await import('../builtin-skills.js'); + const { log } = await import('../utils/logger.js'); + + // A locked skills root: the files inside the legacy directory can go, the + // directory itself cannot. The stub directory already exists, so the stub + // still deploys and the prune runs. + const skills = path.join(homeDir, '.claude/skills'); + await fse.ensureDir(path.join(skills, 'team-wiki-codebase')); + await fse.writeFile(path.join(skills, 'team-wiki-codebase/SKILL.md'), WIKI_SKILL); + await fse.ensureDir(path.join(skills, 'teamai')); + await fse.writeFile(path.join(skills, 'teamai/SKILL.md'), shipped('teamai', 'SKILL.md')); + (log.warn as ReturnType).mockClear(); + await fse.chmod(skills, 0o555); + try { + await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir)); + } finally { + await fse.chmod(skills, 0o755); + } + + const warnings = (log.warn as ReturnType).mock.calls.map((c) => String(c[0])); + expect(warnings.filter((w) => w.includes('team-wiki-codebase') && w.includes('could not be deleted'))).toHaveLength(1); + // The stub's own directory is not empty, so it is not reported. + expect(warnings.filter((w) => w.includes(path.join(skills, 'teamai')))).toEqual([]); + }); + it('archives nothing when there is nothing retired to archive', async () => { const { deployBuiltinSkills } = await import('../builtin-skills.js'); diff --git a/src/builtin-skills.ts b/src/builtin-skills.ts index 8b80d81e..6b2ef26d 100644 --- a/src/builtin-skills.ts +++ b/src/builtin-skills.ts @@ -160,20 +160,36 @@ async function walkFiles(dir: string, prefix = ''): Promise { return found; } -/** Remove `dir` and every directory under it that holds nothing. */ -async function removeEmptyDirs(dir: string): Promise { +/** + * Remove `dir` and every directory under it that holds nothing. A directory + * that still has something in it stays, which is the point: that something is + * the member's. Any other failure (permissions, a busy mount) is returned, so + * the prune does not report a directory gone that is still there. + */ +async function removeEmptyDirs(dir: string): Promise<{ file: string; error: string }[]> { + const failures: { file: string; error: string }[] = []; let entries; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { - return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') failures.push({ file: dir, error: (e as Error).message }); + return failures; } for (const entry of entries) { - if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name)); + if (entry.isDirectory()) failures.push(...await removeEmptyDirs(path.join(dir, entry.name))); } - // Fails when something is left, which is the point: that something is the - // member's, and their directory stays. - try { await fs.promises.rmdir(dir); } catch { /* not empty */ } + try { + await fs.promises.rmdir(dir); + } catch (e) { + // Not empty is the expected outcome for a directory holding the member's + // files — and some platforms say EACCES for that under a read-only parent, + // so the directory's contents, not the error code, decide. + const code = (e as NodeJS.ErrnoException).code; + const stillHolds = code === 'ENOTEMPTY' || code === 'EEXIST' + || (await fs.promises.readdir(dir).catch(() => [])).length > 0; + if (!stillHolds) failures.push({ file: dir, error: (e as Error).message }); + } + return failures; } /** What `removeOwnedFiles` did and did not do, for the caller to report. */ @@ -279,7 +295,7 @@ export async function removeOwnedFiles( result.notRemoved.push({ file, error: (e as Error).message }); } } - await removeEmptyDirs(dir); + result.notRemoved.push(...await removeEmptyDirs(dir)); return result; } @@ -525,8 +541,7 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? // legacy directories are left alone too. if (localConfig && isAgentExcluded(localConfig, tool)) continue; - await pruneLegacyBuiltinSkills(tool, target); - + let deployedHere = 0; for (const skillName of skillNames) { const srcDir = path.join(builtinDir, skillName); const destDir = localConfig @@ -566,10 +581,20 @@ export async function deployBuiltinSkills(teamConfig: TeamaiConfig, localConfig? if (tool === CODEX_TOOL) await retireOtherCodexCopy(tool, skillName, destDir, target); deployed++; + deployedHere++; } catch (e) { log.error(`Failed to deploy built-in skill ${skillName} to ${toolPath.skills}: ${(e as Error).message}`); } } + + // The legacy trees go only once their replacement is in place: pruning first + // and then failing to write the stub (a link, a read-only directory) would + // leave the agent with no discoverable TeamAI skill at all. + if (deployedHere === skillNames.length) { + await pruneLegacyBuiltinSkills(tool, target); + } else { + log.warn(`Kept the pre-stub skills for ${tool}: the new stub was not deployed there, so removing them would leave nothing to discover.`); + } } return deployed; diff --git a/src/commands-reference.ts b/src/commands-reference.ts index c6810aeb..fc4c451e 100644 --- a/src/commands-reference.ts +++ b/src/commands-reference.ts @@ -37,6 +37,15 @@ function visibleOptions(command: Command): Option[] { return command.options.filter((option) => option.long !== '--help'); } +/** + * Subcommands `--help` lists. Hidden ones (`track`, `contribute-check`, …) are + * hook plumbing the CLI calls itself; listing them would advertise them to the + * agent as supported commands. The implicit `help` entry says nothing. + */ +function visibleSubcommands(command: Command): Command[] { + return command.createHelp().visibleCommands(command).filter((sub) => sub.name() !== 'help'); +} + function renderCommand(command: Command, parents: string[]): string[] { const path = [...parents, command.name()]; const args = command.registeredArguments.map((a) => { @@ -51,7 +60,7 @@ function renderCommand(command: Command, parents: string[]): string[] { for (const option of visibleOptions(command)) { lines.push(renderOption(option)); } - for (const sub of command.commands) { + for (const sub of visibleSubcommands(command)) { lines.push(...renderCommand(sub, path).map((line) => ` ${line}`)); } return lines; @@ -66,7 +75,7 @@ export function renderCommandsReference(program: Command): string { sections.push(['## Global options', '', ...globalOptions.map(renderOption).map((l) => l.slice(2))].join('\n')); } - for (const command of program.commands) { + for (const command of visibleSubcommands(program)) { sections.push([`## ${command.name()}`, '', ...renderCommand(command, [])].join('\n')); } diff --git a/src/skill-cmd.ts b/src/skill-cmd.ts index c4b8d3ef..fe6786dd 100644 --- a/src/skill-cmd.ts +++ b/src/skill-cmd.ts @@ -175,7 +175,8 @@ export async function skillList(options: GlobalOptions & { json?: boolean }): Pr } else { for (const entry of catalog) { const note = entry.blockedBy === 'recall' ? ' (needs recall — teamai recall enable)' - : entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' : ''; + : entry.blockedBy === 'read-only' ? ' (not available on a read-only HTTP source)' + : entry.blockedBy === 'config' ? ' (not available: the teamai config could not be loaded)' : ''; console.log(` ${entry.name}${note}`); console.log(` ${truncate(entry.description, DESCRIPTION_MAX) || '(no description)'}`); console.log(` teamai skill get ${entry.name}`); diff --git a/src/skill-content.ts b/src/skill-content.ts index dbae692b..971a6b99 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -63,16 +63,19 @@ const SKILL_ALIASES: Readonly> = { const RECALL_DEPENDENT_SKILLS = new Set(['share']); /** Why a served skill is withheld right now. */ -export type SkillBlockReason = 'recall' | 'read-only'; +export type SkillBlockReason = 'recall' | 'read-only' | 'config'; /** * What makes this skill unusable right now, or null. * - * Fails open: a machine with no team config (a fresh install reading the docs) - * gets the content rather than a refusal it cannot act on. + * Fails open only where there is no config at all: a fresh install reading + * the docs gets the content rather than a refusal it cannot act on. A config + * that exists but cannot be loaded blocks: whether recall is on, or the source + * writable, is then unknown, and the workflow would fail at `teamai contribute`. */ async function blockReason(name: string): Promise { if (!RECALL_DEPENDENT_SKILLS.has(name)) return null; + const { NotInitializedError } = await import('./config.js'); try { const [{ autoDetectInit }, { isRecallEnabled }] = await Promise.all([ import('./config.js'), @@ -93,8 +96,8 @@ async function blockReason(name: string): Promise { // workflow would fail at its last step after the agent did all the work. if (localConfig.repo?.kind === 'http') return 'read-only'; return isRecallEnabled(localConfig, teamConfig) ? null : 'recall'; - } catch { - return null; + } catch (e) { + return e instanceof NotInitializedError ? null : 'config'; } } @@ -224,6 +227,11 @@ export function blockMessage(name: string, reason: SkillBlockReason): { headline headline: `${name} is not available: this team uses a read-only HTTP source, so nothing can be contributed from here.`, hint: 'Ask a team admin to add the learning to the team repo.', }; + case 'config': + return { + headline: `${name} is not available: the teamai config on this machine could not be loaded, so whether it can contribute is unknown.`, + hint: 'Run `teamai doctor` to see what is wrong with it, then try again.', + }; default: { const exhaustive: never = reason; throw new Error(`Unhandled block reason ${String(exhaustive)}`);