Skip to content

Commit c008b72

Browse files
authored
feat!: add workos skills subcommand group (install, uninstall, list) (#86)
* feat: add uninstall-skill command to remove installed skills from coding agents Mirrors install-skill with --list, --skill, and --agent flags. Detects installed skills by scanning agent directories for known WorkOS skill names, ensuring user-created skills are never removed. * fix: add process.exit(0) to install-skill and uninstall-skill commands The auth system keeps the event loop alive after command completion. Other commands (install, doctor) already call process.exit(0) explicitly. * fix: add JSON mode, logging, and error handling to uninstall-skill command - Add structured JSON output for --list, results, and errors (isJsonMode/outputJson/exitWithError) - Add project logging (logError/logInfo/logWarn) for Sentry and session logs - Wrap discoverSkills in try-catch with actionable error message - Warn on unrecognized --skill names instead of silently ignoring - Replace redundant existsSync pre-check with findInstalledSkills - Remove process.exit(0) from install-skill and uninstall-skill handlers in bin.ts - Add orchestrator tests for --skill filter and JSON mode tests - Consolidate duplicate fs import and add globalSkillsDir nonexistent test * feat!: refactor skills commands into `workos skills` subcommand group BREAKING CHANGE: `workos install-skill` and `workos uninstall-skill` are replaced by `workos skills install`, `workos skills uninstall`, and `workos skills list`. - Restructure as `skills` subcommand group using registerSubcommand - Extract `--list` flags into dedicated `workos skills list` command that shows both available and installed skills per agent - Remove withAuth from skills commands (no API calls needed) - Add list-skills.ts with JSON mode support - Add list-skills.spec.ts with human and JSON output tests - Update help-json.ts command registry to nested structure * chore: formatting
1 parent 95d17f1 commit c008b72

8 files changed

Lines changed: 735 additions & 59 deletions

File tree

src/bin.ts

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -226,38 +226,79 @@ yargs(rawArgs)
226226
);
227227
return yargs.demandCommand(1, 'Please specify an auth subcommand').strict();
228228
})
229-
.command(
230-
'install-skill',
231-
'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)',
232-
(yargs) => {
233-
return yargs
234-
.option('list', {
235-
alias: 'l',
236-
type: 'boolean',
237-
description: 'List available skills without installing',
238-
})
239-
.option('skill', {
240-
alias: 's',
241-
type: 'array',
242-
string: true,
243-
description: 'Install specific skill(s)',
244-
})
245-
.option('agent', {
229+
.command('skills', 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => {
230+
registerSubcommand(
231+
yargs,
232+
'install',
233+
'Install bundled AuthKit skills to coding agents',
234+
(y) =>
235+
y
236+
.option('skill', {
237+
alias: 's',
238+
type: 'array',
239+
string: true,
240+
description: 'Install specific skill(s) by name',
241+
})
242+
.option('agent', {
243+
alias: 'a',
244+
type: 'array',
245+
string: true,
246+
description: 'Target specific agent(s): claude-code, codex, cursor, goose',
247+
}),
248+
async (argv) => {
249+
const { runInstallSkill } = await import('./commands/install-skill.js');
250+
await runInstallSkill({
251+
skill: argv.skill as string[] | undefined,
252+
agent: argv.agent as string[] | undefined,
253+
});
254+
},
255+
);
256+
registerSubcommand(
257+
yargs,
258+
'uninstall',
259+
'Remove installed WorkOS skills from coding agents',
260+
(y) =>
261+
y
262+
.option('skill', {
263+
alias: 's',
264+
type: 'array',
265+
string: true,
266+
description: 'Remove specific skill(s) by name',
267+
})
268+
.option('agent', {
269+
alias: 'a',
270+
type: 'array',
271+
string: true,
272+
description: 'Target specific agent(s): claude-code, codex, cursor, goose',
273+
}),
274+
async (argv) => {
275+
const { runUninstallSkill } = await import('./commands/uninstall-skill.js');
276+
await runUninstallSkill({
277+
skill: argv.skill as string[] | undefined,
278+
agent: argv.agent as string[] | undefined,
279+
});
280+
},
281+
);
282+
registerSubcommand(
283+
yargs,
284+
'list',
285+
'List available and installed skills',
286+
(y) =>
287+
y.option('agent', {
246288
alias: 'a',
247289
type: 'array',
248290
string: true,
249291
description: 'Target specific agent(s): claude-code, codex, cursor, goose',
292+
}),
293+
async (argv) => {
294+
const { runListSkills } = await import('./commands/list-skills.js');
295+
await runListSkills({
296+
agent: argv.agent as string[] | undefined,
250297
});
251-
},
252-
withAuth(async (argv) => {
253-
const { runInstallSkill } = await import('./commands/install-skill.js');
254-
await runInstallSkill({
255-
list: argv.list as boolean | undefined,
256-
skill: argv.skill as string[] | undefined,
257-
agent: argv.agent as string[] | undefined,
258-
});
259-
}),
260-
)
298+
},
299+
);
300+
return yargs.demandCommand(1, 'Please specify a skills subcommand').strict();
301+
})
261302
.command(
262303
'doctor',
263304
'Diagnose WorkOS AuthKit integration issues in the current project',

src/commands/install-skill.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export function createAgents(home: string): Record<string, AgentConfig> {
4242
}
4343

4444
export interface InstallSkillOptions {
45-
list?: boolean;
4645
skill?: string[];
4746
agent?: string[];
4847
}
@@ -97,15 +96,6 @@ export async function runInstallSkill(options: InstallSkillOptions): Promise<voi
9796
const skillsDir = getSkillsDir();
9897
const skills = await discoverSkills(skillsDir);
9998

100-
if (options.list) {
101-
console.log(chalk.bold('\nAvailable Skills:\n'));
102-
for (const skill of skills) {
103-
console.log(` ${chalk.cyan(skill)}`);
104-
}
105-
console.log();
106-
return;
107-
}
108-
10999
const targetSkills = options.skill ? skills.filter((s) => options.skill!.includes(s)) : skills;
110100

111101
if (targetSkills.length === 0) {

src/commands/list-skills.spec.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
3+
import { join } from 'path';
4+
import { tmpdir } from 'os';
5+
import type { AgentConfig } from './install-skill.js';
6+
7+
describe('runListSkills', () => {
8+
let testDir: string;
9+
let homeDir: string;
10+
let skillsDir: string;
11+
let agentSkillsDir: string;
12+
13+
beforeEach(() => {
14+
testDir = mkdtempSync(join(tmpdir(), 'list-skills-test-'));
15+
homeDir = join(testDir, 'home');
16+
skillsDir = join(testDir, 'skills');
17+
agentSkillsDir = join(homeDir, '.test/skills');
18+
mkdirSync(homeDir);
19+
mkdirSync(skillsDir);
20+
});
21+
22+
afterEach(() => {
23+
vi.restoreAllMocks();
24+
vi.resetModules();
25+
rmSync(testDir, { recursive: true, force: true });
26+
});
27+
28+
function makeTestAgent(): AgentConfig {
29+
return { name: 'test', displayName: 'Test', globalSkillsDir: agentSkillsDir, detect: () => true };
30+
}
31+
32+
async function importMocked() {
33+
vi.resetModules();
34+
vi.doMock('./install-skill.js', async (importOriginal) => {
35+
const mod = await importOriginal<typeof import('./install-skill.js')>();
36+
return {
37+
...mod,
38+
getSkillsDir: () => skillsDir,
39+
createAgents: () => ({ test: makeTestAgent() }),
40+
detectAgents: () => [makeTestAgent()],
41+
};
42+
});
43+
const { runListSkills } = await import('./list-skills.js');
44+
return { runListSkills };
45+
}
46+
47+
async function importMockedWithJsonMode() {
48+
vi.resetModules();
49+
vi.doMock('./install-skill.js', async (importOriginal) => {
50+
const mod = await importOriginal<typeof import('./install-skill.js')>();
51+
return {
52+
...mod,
53+
getSkillsDir: () => skillsDir,
54+
createAgents: () => ({ test: makeTestAgent() }),
55+
detectAgents: () => [makeTestAgent()],
56+
};
57+
});
58+
const output = await import('../utils/output.js');
59+
output.setOutputMode('json');
60+
const { runListSkills } = await import('./list-skills.js');
61+
return { runListSkills, resetMode: () => output.setOutputMode('human') };
62+
}
63+
64+
it('lists available and installed skills', async () => {
65+
mkdirSync(join(skillsDir, 'skill-a'), { recursive: true });
66+
writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# A');
67+
mkdirSync(join(skillsDir, 'skill-b'), { recursive: true });
68+
writeFileSync(join(skillsDir, 'skill-b', 'SKILL.md'), '# B');
69+
70+
mkdirSync(join(agentSkillsDir, 'skill-a'), { recursive: true });
71+
writeFileSync(join(agentSkillsDir, 'skill-a', 'SKILL.md'), '# A');
72+
73+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
74+
const { runListSkills } = await importMocked();
75+
await runListSkills({});
76+
77+
const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n');
78+
expect(output).toContain('skill-a');
79+
expect(output).toContain('skill-b');
80+
consoleSpy.mockRestore();
81+
});
82+
83+
it('outputs structured JSON in JSON mode', async () => {
84+
mkdirSync(join(skillsDir, 'skill-a'), { recursive: true });
85+
writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# A');
86+
87+
mkdirSync(join(agentSkillsDir, 'skill-a'), { recursive: true });
88+
writeFileSync(join(agentSkillsDir, 'skill-a', 'SKILL.md'), '# A');
89+
90+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
91+
const { runListSkills, resetMode } = await importMockedWithJsonMode();
92+
await runListSkills({});
93+
94+
const jsonOutput = consoleSpy.mock.calls.find((call) => {
95+
try {
96+
JSON.parse(call[0] as string);
97+
return true;
98+
} catch {
99+
return false;
100+
}
101+
});
102+
expect(jsonOutput).toBeDefined();
103+
const parsed = JSON.parse(jsonOutput![0] as string);
104+
expect(parsed).toEqual([{ agent: 'Test', available: ['skill-a'], installed: ['skill-a'] }]);
105+
106+
consoleSpy.mockRestore();
107+
resetMode();
108+
});
109+
});

src/commands/list-skills.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { homedir } from 'os';
2+
import chalk from 'chalk';
3+
import { logError } from '../utils/debug.js';
4+
import { exitWithError, isJsonMode, outputJson } from '../utils/output.js';
5+
import { createAgents, detectAgents, discoverSkills, getSkillsDir } from './install-skill.js';
6+
import { findInstalledSkills } from './uninstall-skill.js';
7+
8+
export interface ListSkillsOptions {
9+
agent?: string[];
10+
}
11+
12+
export async function runListSkills(options: ListSkillsOptions): Promise<void> {
13+
const home = homedir();
14+
const agents = createAgents(home);
15+
const skillsDir = getSkillsDir();
16+
17+
let knownSkills: string[];
18+
try {
19+
knownSkills = await discoverSkills(skillsDir);
20+
} catch (error) {
21+
logError('Failed to read skills directory:', error);
22+
exitWithError({
23+
code: 'SKILLS_DIR_READ_FAILED',
24+
message: `Could not read skills directory at ${skillsDir}. Your WorkOS CLI installation may be corrupted. Try reinstalling with \`npm install -g @workos-inc/cli\`.`,
25+
});
26+
}
27+
28+
const targetAgents = detectAgents(agents, options.agent);
29+
30+
const listData: Array<{ agent: string; available: string[]; installed: string[] }> = [];
31+
for (const agent of targetAgents) {
32+
const installed = findInstalledSkills(knownSkills, agent);
33+
listData.push({ agent: agent.displayName, available: knownSkills, installed });
34+
}
35+
36+
if (isJsonMode()) {
37+
outputJson(listData);
38+
return;
39+
}
40+
41+
console.log(chalk.bold('\nWorkOS Skills:\n'));
42+
console.log(` ${chalk.bold('Available:')} ${knownSkills.map((s) => chalk.cyan(s)).join(', ')}\n`);
43+
44+
if (targetAgents.length === 0) {
45+
console.log(chalk.dim(' No coding agents detected.\n'));
46+
return;
47+
}
48+
49+
console.log(chalk.bold(' Installed per agent:\n'));
50+
for (const entry of listData) {
51+
console.log(` ${chalk.bold(entry.agent)}:`);
52+
if (entry.installed.length === 0) {
53+
console.log(` ${chalk.dim('(none)')}`);
54+
} else {
55+
for (const skill of entry.installed) {
56+
console.log(` ${chalk.cyan(skill)}`);
57+
}
58+
}
59+
}
60+
console.log();
61+
}

0 commit comments

Comments
 (0)