Skip to content

Commit ae5e5cd

Browse files
authored
fix: auto-install skills to coding agents after install (#94)
* feat: auto-install skills to coding agents after `workos install` After the installer completes successfully, silently install all bundled WorkOS skills to every detected coding agent (Claude Code, Codex, Cursor, Goose). Skills are overwritten on each run, keeping them up-to-date with the CLI version. All errors are swallowed so skill installation never disrupts the main install flow. * chore: formatting * docs: note skills auto-install in README
1 parent 0a5bfa8 commit ae5e5cd

5 files changed

Lines changed: 210 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Commands:
5555
doctor Diagnose WorkOS integration issues
5656
skills Manage WorkOS skills for coding agents (install, uninstall, list)
5757

58+
Skills are automatically installed to detected coding agents when you run `workos install`. Use `workos skills list` to check status.
59+
5860
Resource Management:
5961
organization (org) Manage organizations
6062
user Manage users

src/commands/install-skill.spec.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
1-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
33
import { join } from 'path';
44
import { mkdtempSync } from 'fs';
55
import { tmpdir } from 'os';
6-
import { createAgents, discoverSkills, detectAgents, installSkill, type AgentConfig } from './install-skill.js';
6+
import {
7+
createAgents,
8+
discoverSkills,
9+
detectAgents,
10+
installSkill,
11+
autoInstallSkills,
12+
type AgentConfig,
13+
} from './install-skill.js';
14+
15+
vi.mock('os', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('os')>();
17+
return { ...actual, homedir: vi.fn(actual.homedir) };
18+
});
19+
20+
vi.mock('@workos/skills', async (importOriginal) => {
21+
const actual = await importOriginal<typeof import('@workos/skills')>();
22+
return { ...actual, getSkillsDir: vi.fn(actual.getSkillsDir) };
23+
});
724

825
describe('install-skill', () => {
926
let testDir: string;
@@ -195,4 +212,92 @@ describe('install-skill', () => {
195212
expect(content).toContain('# Updated Skill');
196213
});
197214
});
215+
216+
describe('autoInstallSkills', () => {
217+
beforeEach(async () => {
218+
const { homedir } = await import('os');
219+
const { getSkillsDir } = await import('@workos/skills');
220+
vi.mocked(homedir).mockReturnValue(homeDir);
221+
vi.mocked(getSkillsDir).mockReturnValue(skillsDir);
222+
});
223+
224+
afterEach(() => {
225+
vi.restoreAllMocks();
226+
});
227+
228+
it('installs all skills to all detected agents', async () => {
229+
// Set up skills
230+
mkdirSync(join(skillsDir, 'skill-a'));
231+
writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A');
232+
mkdirSync(join(skillsDir, 'skill-b'));
233+
writeFileSync(join(skillsDir, 'skill-b', 'SKILL.md'), '# Skill B');
234+
235+
// Set up detected agents
236+
mkdirSync(join(homeDir, '.claude'));
237+
mkdirSync(join(homeDir, '.codex'));
238+
239+
await autoInstallSkills();
240+
241+
expect(existsSync(join(homeDir, '.claude/skills/skill-a/SKILL.md'))).toBe(true);
242+
expect(existsSync(join(homeDir, '.claude/skills/skill-b/SKILL.md'))).toBe(true);
243+
expect(existsSync(join(homeDir, '.codex/skills/skill-a/SKILL.md'))).toBe(true);
244+
expect(existsSync(join(homeDir, '.codex/skills/skill-b/SKILL.md'))).toBe(true);
245+
});
246+
247+
it('no-ops silently when no agents are detected', async () => {
248+
mkdirSync(join(skillsDir, 'skill-a'));
249+
writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A');
250+
251+
// No agent directories created — none detected
252+
await expect(autoInstallSkills()).resolves.toBeUndefined();
253+
});
254+
255+
it('no-ops silently when no skills are discovered', async () => {
256+
mkdirSync(join(homeDir, '.claude'));
257+
258+
// No skills in skillsDir
259+
await expect(autoInstallSkills()).resolves.toBeUndefined();
260+
});
261+
262+
it('swallows errors from discoverSkills', async () => {
263+
// Point to a nonexistent skills directory
264+
const { getSkillsDir } = await import('@workos/skills');
265+
vi.mocked(getSkillsDir).mockReturnValue('/nonexistent/path');
266+
267+
await expect(autoInstallSkills()).resolves.toBeUndefined();
268+
});
269+
270+
it('resolves silently when installSkill returns failure', async () => {
271+
// installSkill returns { success: false } on copy errors (doesn't throw).
272+
// Verify autoInstallSkills completes without throwing even when installs fail.
273+
// Simulate by creating a skill dir with SKILL.md for discovery, then making
274+
// the target agent dir read-only so copyFile fails.
275+
mkdirSync(join(skillsDir, 'test-skill'));
276+
writeFileSync(join(skillsDir, 'test-skill', 'SKILL.md'), '# Test');
277+
278+
mkdirSync(join(homeDir, '.claude'));
279+
// Create a file where the skills directory should be, so mkdir fails
280+
mkdirSync(join(homeDir, '.claude/skills'));
281+
writeFileSync(join(homeDir, '.claude/skills/test-skill'), 'not a directory');
282+
283+
await expect(autoInstallSkills()).resolves.toBeUndefined();
284+
});
285+
286+
it('does not produce any console output', async () => {
287+
const logSpy = vi.spyOn(console, 'log');
288+
const errorSpy = vi.spyOn(console, 'error');
289+
290+
mkdirSync(join(skillsDir, 'skill-a'));
291+
writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A');
292+
mkdirSync(join(homeDir, '.claude'));
293+
294+
await autoInstallSkills();
295+
296+
expect(logSpy).not.toHaveBeenCalled();
297+
expect(errorSpy).not.toHaveBeenCalled();
298+
299+
logSpy.mockRestore();
300+
errorSpy.mockRestore();
301+
});
302+
});
198303
});

src/commands/install-skill.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,27 @@ export async function runInstallSkill(options: InstallSkillOptions): Promise<voi
156156

157157
console.log(chalk.green('\nDone!'));
158158
}
159+
160+
/**
161+
* Silently install all bundled skills to all detected coding agents.
162+
* Errors are swallowed — this must never disrupt the calling flow.
163+
*/
164+
export async function autoInstallSkills(): Promise<void> {
165+
try {
166+
const home = homedir();
167+
const agents = createAgents(home);
168+
const skillsDir = getSkillsDir();
169+
const skills = await discoverSkills(skillsDir);
170+
const targetAgents = detectAgents(agents);
171+
172+
if (skills.length === 0 || targetAgents.length === 0) return;
173+
174+
for (const skill of skills) {
175+
for (const agent of targetAgents) {
176+
await installSkill(skillsDir, skill, agent);
177+
}
178+
}
179+
} catch {
180+
// Intentionally swallowed — skill install is best-effort
181+
}
182+
}

src/commands/install.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
3+
vi.mock('../run.js', () => ({
4+
runInstaller: vi.fn(),
5+
}));
6+
7+
vi.mock('./install-skill.js', () => ({
8+
autoInstallSkills: vi.fn(),
9+
}));
10+
11+
vi.mock('../utils/clack.js', () => ({
12+
default: {
13+
log: { info: vi.fn(), error: vi.fn() },
14+
},
15+
}));
16+
17+
vi.mock('../utils/output.js', () => ({
18+
exitWithError: vi.fn(),
19+
isJsonMode: vi.fn(() => false),
20+
}));
21+
22+
vi.mock('../utils/debug.js', () => ({
23+
getLogFilePath: vi.fn(() => null),
24+
}));
25+
26+
const { runInstaller } = await import('../run.js');
27+
const { autoInstallSkills } = await import('./install-skill.js');
28+
29+
vi.spyOn(process, 'exit').mockImplementation((() => {
30+
throw new Error('process.exit called');
31+
}) as any);
32+
33+
const { handleInstall } = await import('./install.js');
34+
35+
describe('handleInstall', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
});
39+
40+
it('calls autoInstallSkills after successful install', async () => {
41+
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
42+
vi.mocked(autoInstallSkills).mockResolvedValue(undefined);
43+
44+
await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called');
45+
46+
expect(runInstaller).toHaveBeenCalledOnce();
47+
expect(autoInstallSkills).toHaveBeenCalledOnce();
48+
49+
// Verify order: autoInstallSkills called after runInstaller
50+
const runInstallerOrder = vi.mocked(runInstaller).mock.invocationCallOrder[0];
51+
const autoInstallOrder = vi.mocked(autoInstallSkills).mock.invocationCallOrder[0];
52+
expect(autoInstallOrder).toBeGreaterThan(runInstallerOrder);
53+
});
54+
55+
it('does not call autoInstallSkills when runInstaller throws', async () => {
56+
vi.mocked(runInstaller).mockRejectedValue(new Error('install failed'));
57+
58+
await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called');
59+
60+
expect(runInstaller).toHaveBeenCalledOnce();
61+
expect(autoInstallSkills).not.toHaveBeenCalled();
62+
});
63+
64+
it('still exits 0 even if autoInstallSkills throws', async () => {
65+
vi.mocked(runInstaller).mockResolvedValue(undefined as any);
66+
vi.mocked(autoInstallSkills).mockRejectedValue(new Error('skill install exploded'));
67+
68+
// autoInstallSkills throwing will trigger the outer catch, which calls process.exit(1)
69+
// But autoInstallSkills has its own internal catch in production — this tests defense in depth
70+
await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called');
71+
72+
expect(runInstaller).toHaveBeenCalledOnce();
73+
expect(autoInstallSkills).toHaveBeenCalledOnce();
74+
});
75+
});

src/commands/install.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { InstallerArgs } from '../run.js';
33
import clack from '../utils/clack.js';
44
import { exitWithError, isJsonMode } from '../utils/output.js';
55
import type { ArgumentsCamelCase } from 'yargs';
6+
import { autoInstallSkills } from './install-skill.js';
67

78
/**
89
* Handle install command execution.
@@ -28,6 +29,7 @@ export async function handleInstall(argv: ArgumentsCamelCase<InstallerArgs>): Pr
2829

2930
try {
3031
await runInstaller(options);
32+
await autoInstallSkills();
3133
process.exit(0);
3234
} catch (err) {
3335
const { getLogFilePath } = await import('../utils/debug.js');

0 commit comments

Comments
 (0)