fix(skills): deploy before pruning, block share on an unloadable config, drop hidden commands from the reference

- Legacy trees were pruned before the stub was written, so a refused or
  failed stub (a link, a read-only directory) left the agent with nothing
  to discover. They go only once the stub deployed for that agent.
- The share gate failed open on any config error. Only a machine with no
  config (NotInitializedError) is served; a config that exists but cannot be
  loaded blocks with its own reason, `blockedBy: "config"`.
- The KB template told agents to run `code-to-knowledge --update`, which
  does not exist; it names `teamai codebase --extract … --incremental`.
- The generated command reference listed hidden hook plumbing (`track`,
  `contribute-check`, `todowrite-hint`, …). It renders what `--help` lists.
- removeEmptyDirs swallowed every rmdir error, so a directory that stayed
  could be reported removed. Only "still holds something" is expected; any
  other failure is reported.
This commit is contained in:
Saul Moro
2026-09-23 07:33:10 +02:00
parent 96d0f7ce08
commit 327f9cd72c
10 changed files with 134 additions and 71 deletions
+5 -3
View File
@@ -89,8 +89,10 @@ path (measured here from a 77-character one).
`skill get <name>` refuses, `skill get --all` leaves the skill out and says so
on stderr, `skill path <name>` and `skill show <name>` 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/<run>/<base>/<tool>/<skill-root>/<skill>/`, 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
-44
View File
@@ -158,7 +158,6 @@ Generated: do not edit by hand. Regenerate with
- `--token <key>` — 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 <name>` — 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 <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 <name>` — 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 <name>` — 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>` — Port number
## dashboard-report
- `teamai dashboard-report` — Report session state to dashboard (called by hooks)
- `--stdin` — Read hook data from STDIN
- `--tool <name>` — Tool identifier (e.g. claude, claude-internal)
## hook-dispatch
- `teamai hook-dispatch <event>` — 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 <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 <name>` — 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 <cat>` — 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 <name>` — 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 <n>` (hidden) — Override capacity cap for --cache-gc
- `--stale-days <n>` (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 <name>` — 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 <mode>` — Write strategy: direct | pending-review
- `--output <dir>` — 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 <slug>` — Project slug (directory name in evidence/code/)
- `--wiki-root <path>` — Teamwiki root path
- `--max-modules <n>` — Max modules to process (cost control)
@@ -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 <repo> --project <slug> --incremental` re-extracts only the changed files
- **Full rebuild**: recommended after large-scale code refactoring
- **Last updated**: `<ISO8601>`
@@ -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 <repo> --project <slug> --incremental` for an incremental update.
- **Commit**: `<git commit SHA>`
- **Tag**: `<tag or "no tag">`
+19 -2
View File
@@ -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<typeof import('../config.js')>()),
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');
});
});
+2 -1
View File
@@ -87,7 +87,8 @@ function captureLogs() {
}
async function runSkillShow(name: string, fx: Fixture): Promise<string[]> {
vi.doMock('../config.js', () => ({
vi.doMock('../config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../config.js')>()),
autoDetectInit: async () => ({ localConfig: fx.localConfig, teamConfig: fx.teamConfig }),
}));
const { skillShow } = await import('../skill-cmd.js');
@@ -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<typeof vi.fn>).mockClear();
await fse.chmod(skills, 0o555);
try {
await deployBuiltinSkills(legacyPruneTeamConfig(), legacyPruneLocalConfig(tmpDir));
} finally {
await fse.chmod(skills, 0o755);
}
const warnings = (log.warn as ReturnType<typeof vi.fn>).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');
+36 -11
View File
@@ -160,20 +160,36 @@ async function walkFiles(dir: string, prefix = ''): Promise<string[]> {
return found;
}
/** Remove `dir` and every directory under it that holds nothing. */
async function removeEmptyDirs(dir: string): Promise<void> {
/**
* 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;
+11 -2
View File
@@ -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'));
}
+2 -1
View File
@@ -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}`);
+13 -5
View File
@@ -63,16 +63,19 @@ const SKILL_ALIASES: Readonly<Record<string, string>> = {
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<SkillBlockReason | null> {
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<SkillBlockReason | null> {
// 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)}`);