Skip to content

Commit ed7436a

Browse files
committed
fix: cli skills install
1 parent c246cd7 commit ed7436a

5 files changed

Lines changed: 128 additions & 35 deletions

File tree

apps/cli/src/__tests__/install-uninstall.test.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ type RunResult = {
1010
stderr: string;
1111
};
1212

13-
async function runCli(args: string[], cwd?: string): Promise<RunResult> {
13+
async function runCli(args: string[], cwd?: string, homeDir?: string): Promise<RunResult> {
1414
let stdout = '';
1515
let stderr = '';
1616
const originalCwd = process.cwd();
17+
const originalHome = process.env.HOME;
18+
const originalUserProfile = process.env.USERPROFILE;
1719

1820
if (cwd) process.chdir(cwd);
21+
if (homeDir) {
22+
process.env.HOME = homeDir;
23+
process.env.USERPROFILE = homeDir;
24+
}
25+
1926
try {
2027
const code = await run(args, {
2128
stdout(message: string) {
@@ -31,6 +38,8 @@ async function runCli(args: string[], cwd?: string): Promise<RunResult> {
3138

3239
return { code, stdout, stderr };
3340
} finally {
41+
process.env.HOME = originalHome;
42+
process.env.USERPROFILE = originalUserProfile;
3443
process.chdir(originalCwd);
3544
}
3645
}
@@ -49,89 +58,126 @@ describe('install and uninstall commands', () => {
4958
return testDir;
5059
}
5160

61+
function createTestHome(dir: string): string {
62+
const home = join(dir, 'home');
63+
mkdirSync(home, { recursive: true });
64+
return home;
65+
}
66+
5267
test('install --skills copies skill into .claude/', async () => {
5368
const dir = createTestDir();
69+
const home = createTestHome(dir);
5470
mkdirSync(join(dir, '.claude'));
5571

56-
const result = await runCli(['install', '--skills'], dir);
72+
const result = await runCli(['install', '--skills'], dir, home);
5773
expect(result.code).toBe(0);
5874
expect(result.stdout).toContain('.claude/skills/superdoc/');
5975
expect(existsSync(join(dir, '.claude', 'skills', 'superdoc', 'SKILL.md'))).toBe(true);
6076
});
6177

6278
test('install --skills copies skill into .agents/', async () => {
6379
const dir = createTestDir();
80+
const home = createTestHome(dir);
6481
mkdirSync(join(dir, '.agents'));
6582

66-
const result = await runCli(['install', '--skills'], dir);
83+
const result = await runCli(['install', '--skills'], dir, home);
6784
expect(result.code).toBe(0);
6885
expect(result.stdout).toContain('.agents/skills/superdoc/');
6986
expect(existsSync(join(dir, '.agents', 'skills', 'superdoc', 'SKILL.md'))).toBe(true);
7087
});
7188

7289
test('install --skills copies into both when both exist', async () => {
7390
const dir = createTestDir();
91+
const home = createTestHome(dir);
7492
mkdirSync(join(dir, '.claude'));
7593
mkdirSync(join(dir, '.agents'));
7694

77-
const result = await runCli(['install', '--skills'], dir);
95+
const result = await runCli(['install', '--skills'], dir, home);
7896
expect(result.code).toBe(0);
7997
expect(result.stdout).toContain('.claude/skills/superdoc/');
8098
expect(result.stdout).toContain('.agents/skills/superdoc/');
8199
});
82100

101+
test('install --skills falls back to home agent directories', async () => {
102+
const dir = createTestDir();
103+
const home = createTestHome(dir);
104+
mkdirSync(join(home, '.claude'));
105+
106+
const result = await runCli(['install', '--skills'], dir, home);
107+
expect(result.code).toBe(0);
108+
expect(result.stdout).toContain('~/.claude/skills/superdoc/');
109+
expect(existsSync(join(home, '.claude', 'skills', 'superdoc', 'SKILL.md'))).toBe(true);
110+
});
111+
83112
test('install --skills warns when no agent directories exist', async () => {
84113
const dir = createTestDir();
114+
const home = createTestHome(dir);
85115

86-
const result = await runCli(['install', '--skills'], dir);
116+
const result = await runCli(['install', '--skills'], dir, home);
87117
expect(result.code).toBe(1);
88118
expect(result.stderr).toContain('No agent directories found');
89119
});
90120

91121
test('install without --skills prints usage', async () => {
92122
const dir = createTestDir();
123+
const home = createTestHome(dir);
93124

94-
const result = await runCli(['install'], dir);
125+
const result = await runCli(['install'], dir, home);
95126
expect(result.code).toBe(1);
96127
expect(result.stderr).toContain('Usage: superdoc install --skills');
97128
});
98129

99130
test('install runtime failures return structured CLI errors', async () => {
100131
const dir = createTestDir();
132+
const home = createTestHome(dir);
101133
mkdirSync(join(dir, '.claude'));
102134
writeFileSync(join(dir, '.claude', 'skills'), 'blocked');
103135

104-
const result = await runCli(['install', '--skills'], dir);
136+
const result = await runCli(['install', '--skills'], dir, home);
105137
expect(result.code).toBe(1);
106138
expect(result.stderr).toContain('"ok":false');
107139
expect(result.stderr).toContain('"code":"COMMAND_FAILED"');
108140
});
109141

110142
test('uninstall --skills removes installed skill directories', async () => {
111143
const dir = createTestDir();
144+
const home = createTestHome(dir);
112145
mkdirSync(join(dir, '.claude', 'skills', 'superdoc'), { recursive: true });
113146
mkdirSync(join(dir, '.agents', 'skills', 'superdoc'), { recursive: true });
114147

115-
const result = await runCli(['uninstall', '--skills'], dir);
148+
const result = await runCli(['uninstall', '--skills'], dir, home);
116149
expect(result.code).toBe(0);
117150
expect(result.stdout).toContain('.claude/skills/superdoc/');
118151
expect(result.stdout).toContain('.agents/skills/superdoc/');
119152
expect(existsSync(join(dir, '.claude', 'skills', 'superdoc'))).toBe(false);
120153
expect(existsSync(join(dir, '.agents', 'skills', 'superdoc'))).toBe(false);
121154
});
122155

156+
test('uninstall --skills removes skills from home agent directories', async () => {
157+
const dir = createTestDir();
158+
const home = createTestHome(dir);
159+
mkdirSync(join(home, '.agents', 'skills', 'superdoc'), { recursive: true });
160+
161+
const result = await runCli(['uninstall', '--skills'], dir, home);
162+
expect(result.code).toBe(0);
163+
expect(result.stdout).toContain('~/.agents/skills/superdoc/');
164+
expect(existsSync(join(home, '.agents', 'skills', 'superdoc'))).toBe(false);
165+
});
166+
123167
test('uninstall --skills is no-op when nothing is installed', async () => {
124168
const dir = createTestDir();
169+
const home = createTestHome(dir);
125170

126-
const result = await runCli(['uninstall', '--skills'], dir);
171+
const result = await runCli(['uninstall', '--skills'], dir, home);
127172
expect(result.code).toBe(0);
128173
expect(result.stdout).toContain('No installed skills found');
129174
});
130175

131176
test('uninstall without --skills prints usage', async () => {
132177
const dir = createTestDir();
178+
const home = createTestHome(dir);
133179

134-
const result = await runCli(['uninstall'], dir);
180+
const result = await runCli(['uninstall'], dir, home);
135181
expect(result.code).toBe(1);
136182
expect(result.stderr).toContain('Usage: superdoc uninstall --skills');
137183
});

apps/cli/src/__tests__/lib/manual-command-allowlist.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe('manual command allowlist', () => {
4848
'session-list.ts',
4949
'session-save.ts',
5050
'session-set-default.ts',
51+
'skill-targets.ts',
5152
'uninstall.ts',
5253
]);
5354
});

apps/cli/src/commands/install.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ import { existsSync, cpSync, mkdirSync } from 'node:fs';
22
import { join, dirname } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import type { CliIO } from '../lib/types';
5+
import { resolveSkillTargets } from './skill-targets';
56

67
const __filename = fileURLToPath(import.meta.url);
78
const __dirname = dirname(__filename);
89

9-
const AGENT_TARGETS = [
10-
{ name: 'Claude Code', dir: '.claude' },
11-
{ name: 'Codex', dir: '.agents' },
12-
] as const;
13-
1410
function resolveSkillSource(): string {
1511
// In compiled dist: __dirname is dist/, skill/ is at dist/../skill/
1612
// In dev (bun run src/index.ts): __dirname is src/commands/, skill/ is at src/commands/../../skill/
@@ -31,21 +27,20 @@ export async function runInstall(tokens: string[], io: CliIO): Promise<number> {
3127

3228
const cwd = process.cwd();
3329
const skillSource = resolveSkillSource();
30+
const targets = resolveSkillTargets(cwd);
3431
let installed = 0;
3532

36-
for (const target of AGENT_TARGETS) {
37-
const agentDir = join(cwd, target.dir);
38-
if (!existsSync(agentDir)) continue;
39-
40-
const dest = join(agentDir, 'skills', 'superdoc');
41-
mkdirSync(dest, { recursive: true });
42-
cpSync(skillSource, dest, { recursive: true });
43-
io.stdout(`Installed skill to ${target.dir}/skills/superdoc/\n`);
33+
for (const target of targets) {
34+
mkdirSync(target.skillDir, { recursive: true });
35+
cpSync(skillSource, target.skillDir, { recursive: true });
36+
io.stdout(`Installed skill to ${target.displaySkillDir}\n`);
4437
installed += 1;
4538
}
4639

4740
if (installed === 0) {
48-
io.stderr('No agent directories found. Create .claude/ (Claude Code) or .agents/ (Codex) first, then re-run.\n');
41+
io.stderr(
42+
'No agent directories found in current or home directory. Create .claude/ (Claude Code) or .agents/ (Codex) first, then re-run.\n',
43+
);
4944
return 1;
5045
}
5146

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { existsSync, realpathSync } from 'node:fs';
2+
import { homedir } from 'node:os';
3+
import { join } from 'node:path';
4+
5+
const AGENT_DIRS = ['.claude', '.agents'] as const;
6+
7+
type SkillTargetRoot = {
8+
baseDir: string;
9+
displayPrefix: string;
10+
};
11+
12+
export type SkillTarget = {
13+
skillDir: string;
14+
displaySkillDir: string;
15+
};
16+
17+
function realPathOrSelf(path: string): string {
18+
try {
19+
return realpathSync(path);
20+
} catch {
21+
return path;
22+
}
23+
}
24+
25+
function resolveTargetRoots(cwd: string): SkillTargetRoot[] {
26+
const roots: SkillTargetRoot[] = [{ baseDir: cwd, displayPrefix: '' }];
27+
const homeDir = process.env.HOME || process.env.USERPROFILE || homedir();
28+
if (homeDir && homeDir !== cwd) {
29+
roots.push({ baseDir: homeDir, displayPrefix: '~/' });
30+
}
31+
return roots;
32+
}
33+
34+
export function resolveSkillTargets(cwd: string): SkillTarget[] {
35+
const targets: SkillTarget[] = [];
36+
const seen = new Set<string>();
37+
38+
for (const root of resolveTargetRoots(cwd)) {
39+
for (const agentDirName of AGENT_DIRS) {
40+
const agentDir = join(root.baseDir, agentDirName);
41+
if (!existsSync(agentDir)) continue;
42+
43+
const dedupeKey = realPathOrSelf(agentDir);
44+
if (seen.has(dedupeKey)) continue;
45+
seen.add(dedupeKey);
46+
47+
const displaySkillDir = `${root.displayPrefix}${agentDirName}/skills/superdoc/`;
48+
targets.push({
49+
skillDir: join(agentDir, 'skills', 'superdoc'),
50+
displaySkillDir,
51+
});
52+
}
53+
}
54+
55+
return targets;
56+
}

apps/cli/src/commands/uninstall.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import { existsSync, rmSync } from 'node:fs';
2-
import { join } from 'node:path';
32
import type { CliIO } from '../lib/types';
4-
5-
const SKILL_PATHS = [
6-
{ name: 'Claude Code', path: '.claude/skills/superdoc' },
7-
{ name: 'Codex', path: '.agents/skills/superdoc' },
8-
] as const;
3+
import { resolveSkillTargets } from './skill-targets';
94

105
export async function runUninstall(tokens: string[], io: CliIO): Promise<number> {
116
if (!tokens.includes('--skills')) {
@@ -14,14 +9,14 @@ export async function runUninstall(tokens: string[], io: CliIO): Promise<number>
149
}
1510

1611
const cwd = process.cwd();
12+
const targets = resolveSkillTargets(cwd);
1713
let removed = 0;
1814

19-
for (const target of SKILL_PATHS) {
20-
const fullPath = join(cwd, target.path);
21-
if (!existsSync(fullPath)) continue;
15+
for (const target of targets) {
16+
if (!existsSync(target.skillDir)) continue;
2217

23-
rmSync(fullPath, { recursive: true });
24-
io.stdout(`Removed ${target.path}/\n`);
18+
rmSync(target.skillDir, { recursive: true });
19+
io.stdout(`Removed ${target.displaySkillDir}\n`);
2520
removed += 1;
2621
}
2722

0 commit comments

Comments
 (0)