From 22b335255d4e0a3fc4cdd337b84565a1865ec3e5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 7 Mar 2026 10:33:45 -0600 Subject: [PATCH 1/5] 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. --- src/bin.ts | 32 +++++++ src/commands/uninstall-skill.spec.ts | 137 +++++++++++++++++++++++++++ src/commands/uninstall-skill.ts | 128 +++++++++++++++++++++++++ src/utils/help-json.ts | 30 ++++++ 4 files changed, 327 insertions(+) create mode 100644 src/commands/uninstall-skill.spec.ts create mode 100644 src/commands/uninstall-skill.ts diff --git a/src/bin.ts b/src/bin.ts index 5891d0f..fc97ed1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -258,6 +258,38 @@ yargs(rawArgs) }); }), ) + .command( + 'uninstall-skill', + 'Remove installed WorkOS skills from coding agents', + (yargs) => { + return yargs + .option('list', { + alias: 'l', + type: 'boolean', + description: 'List installed skills without removing', + }) + .option('skill', { + alias: 's', + type: 'array', + string: true, + description: 'Remove specific skill(s)', + }) + .option('agent', { + alias: 'a', + type: 'array', + string: true, + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + }); + }, + withAuth(async (argv) => { + const { runUninstallSkill } = await import('./commands/uninstall-skill.js'); + await runUninstallSkill({ + list: argv.list as boolean | undefined, + skill: argv.skill as string[] | undefined, + agent: argv.agent as string[] | undefined, + }); + }), + ) .command( 'doctor', 'Diagnose WorkOS AuthKit integration issues in the current project', diff --git a/src/commands/uninstall-skill.spec.ts b/src/commands/uninstall-skill.spec.ts new file mode 100644 index 0000000..4365918 --- /dev/null +++ b/src/commands/uninstall-skill.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { createAgents, type AgentConfig } from './install-skill.js'; +import { findInstalledSkills, uninstallSkill } from './uninstall-skill.js'; + +describe('uninstall-skill', () => { + let testDir: string; + let homeDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'uninstall-skill-test-')); + homeDir = join(testDir, 'home'); + mkdirSync(homeDir); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('findInstalledSkills', () => { + let agent: AgentConfig; + + beforeEach(() => { + agent = { + name: 'test-agent', + displayName: 'Test Agent', + globalSkillsDir: join(homeDir, '.test-agent/skills'), + detect: () => true, + }; + }); + + it('returns empty array when no skills are installed', () => { + mkdirSync(agent.globalSkillsDir, { recursive: true }); + const result = findInstalledSkills(['skill-one', 'skill-two'], agent); + expect(result).toEqual([]); + }); + + it('returns only skills that exist in agent directory', () => { + const skillDir = join(agent.globalSkillsDir, 'skill-one'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '# Skill One'); + + const result = findInstalledSkills(['skill-one', 'skill-two'], agent); + expect(result).toEqual(['skill-one']); + }); + + it('returns all matching skills when multiple are installed', () => { + for (const name of ['skill-one', 'skill-two']) { + const skillDir = join(agent.globalSkillsDir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}`); + } + + const result = findInstalledSkills(['skill-one', 'skill-two', 'skill-three'], agent); + expect(result).toEqual(['skill-one', 'skill-two']); + }); + + it('ignores directories without SKILL.md', () => { + const skillDir = join(agent.globalSkillsDir, 'skill-one'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'README.md'), '# Not a skill'); + + const result = findInstalledSkills(['skill-one'], agent); + expect(result).toEqual([]); + }); + + it('does not detect skills not in the known list', () => { + const skillDir = join(agent.globalSkillsDir, 'custom-skill'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '# Custom'); + + const result = findInstalledSkills(['workos-skill'], agent); + expect(result).toEqual([]); + }); + }); + + describe('uninstallSkill', () => { + let agent: AgentConfig; + + beforeEach(() => { + agent = { + name: 'test-agent', + displayName: 'Test Agent', + globalSkillsDir: join(homeDir, '.test-agent/skills'), + detect: () => true, + }; + }); + + it('removes skill directory', async () => { + const skillDir = join(agent.globalSkillsDir, 'test-skill'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '# Test'); + + const result = await uninstallSkill('test-skill', agent); + + expect(result.success).toBe(true); + expect(existsSync(skillDir)).toBe(false); + }); + + it('succeeds when directory does not exist', async () => { + const result = await uninstallSkill('nonexistent-skill', agent); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('removes directory and all contents', async () => { + const skillDir = join(agent.globalSkillsDir, 'test-skill'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '# Test'); + writeFileSync(join(skillDir, 'extra-file.txt'), 'extra'); + + const result = await uninstallSkill('test-skill', agent); + + expect(result.success).toBe(true); + expect(existsSync(skillDir)).toBe(false); + }); + }); + + describe('createAgents integration', () => { + it('uses correct skill paths for uninstall detection', () => { + mkdirSync(join(homeDir, '.claude'), { recursive: true }); + const agents = createAgents(homeDir); + const claudeAgent = agents['claude-code']; + + const skillDir = join(claudeAgent.globalSkillsDir, 'workos-test'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '# Test'); + + const installed = findInstalledSkills(['workos-test', 'workos-other'], claudeAgent); + expect(installed).toEqual(['workos-test']); + }); + }); +}); diff --git a/src/commands/uninstall-skill.ts b/src/commands/uninstall-skill.ts new file mode 100644 index 0000000..e5829d0 --- /dev/null +++ b/src/commands/uninstall-skill.ts @@ -0,0 +1,128 @@ +import { existsSync } from 'fs'; +import { rm } from 'fs/promises'; +import { join } from 'path'; +import { homedir } from 'os'; +import chalk from 'chalk'; +import { createAgents, detectAgents, discoverSkills, getSkillsDir, type AgentConfig } from './install-skill.js'; + +export interface UninstallSkillOptions { + list?: boolean; + skill?: string[]; + agent?: string[]; +} + +export function findInstalledSkills(knownSkills: string[], agent: AgentConfig): string[] { + return knownSkills.filter((name) => existsSync(join(agent.globalSkillsDir, name, 'SKILL.md'))); +} + +export async function uninstallSkill( + skillName: string, + agent: AgentConfig, +): Promise<{ success: boolean; error?: string }> { + const targetDir = join(agent.globalSkillsDir, skillName); + try { + await rm(targetDir, { recursive: true, force: true }); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +export async function runUninstallSkill(options: UninstallSkillOptions): Promise { + const home = homedir(); + const agents = createAgents(home); + const skillsDir = getSkillsDir(); + const knownSkills = await discoverSkills(skillsDir); + + const targetAgents = detectAgents(agents, options.agent); + + if (targetAgents.length === 0) { + if (options.agent) { + console.error(chalk.red('Specified agents not found.')); + } else { + console.error(chalk.red('No coding agents detected.')); + } + console.log('Supported agents:', Object.keys(agents).join(', ')); + process.exit(1); + } + + if (options.list) { + console.log(chalk.bold('\nInstalled WorkOS Skills:\n')); + for (const agent of targetAgents) { + const installed = findInstalledSkills(knownSkills, agent); + console.log(` ${chalk.bold(agent.displayName)}:`); + if (installed.length === 0) { + console.log(` ${chalk.dim('(none)')}`); + } else { + for (const skill of installed) { + console.log(` ${chalk.cyan(skill)}`); + } + } + } + console.log(); + return; + } + + const targetSkillNames = options.skill ? knownSkills.filter((s) => options.skill!.includes(s)) : knownSkills; + + if (options.skill && targetSkillNames.length === 0) { + console.error(chalk.red('No matching skills found.')); + console.log('Known skills:', knownSkills.join(', ')); + process.exit(1); + } + + console.log(chalk.bold('\nUninstalling skills...\n')); + + const results: Array<{ + skill: string; + agent: string; + success: boolean; + skipped: boolean; + error?: string; + }> = []; + + for (const skill of targetSkillNames) { + for (const agent of targetAgents) { + const isInstalled = existsSync(join(agent.globalSkillsDir, skill, 'SKILL.md')); + if (!isInstalled) { + results.push({ skill, agent: agent.displayName, success: true, skipped: true }); + continue; + } + const result = await uninstallSkill(skill, agent); + results.push({ + skill, + agent: agent.displayName, + skipped: false, + ...result, + }); + } + } + + const removed = results.filter((r) => r.success && !r.skipped); + const skipped = results.filter((r) => r.skipped); + const failed = results.filter((r) => !r.success); + + if (removed.length > 0) { + console.log(chalk.green(`✓ Removed ${removed.length} skill(s):\n`)); + for (const r of removed) { + console.log(` ${chalk.cyan(r.skill)} ← ${chalk.dim(r.agent)}`); + } + } + + if (skipped.length > 0 && removed.length === 0 && failed.length === 0) { + console.log(chalk.dim('No WorkOS skills were installed.')); + } + + if (failed.length > 0) { + console.log(chalk.red(`\n✗ Failed to remove ${failed.length}:\n`)); + for (const r of failed) { + console.log(` ${r.skill} ← ${r.agent}: ${chalk.dim(r.error)}`); + } + process.exit(1); + } + + console.log(chalk.green('\nDone!')); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index f8a44cb..66c3661 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -140,6 +140,36 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'uninstall-skill', + description: 'Remove installed WorkOS skills from coding agents', + options: [ + { + name: 'list', + type: 'boolean', + description: 'List installed skills without removing', + required: false, + alias: 'l', + hidden: false, + }, + { + name: 'skill', + type: 'array', + description: 'Remove specific skill(s) by name', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, + ], + }, { name: 'doctor', description: 'Diagnose WorkOS AuthKit integration issues in the current project', From cd20810f3d84b1070ddb5a51731202363a0980b1 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 7 Mar 2026 10:36:26 -0600 Subject: [PATCH 2/5] 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. --- src/bin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bin.ts b/src/bin.ts index fc97ed1..5f36622 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -256,6 +256,7 @@ yargs(rawArgs) skill: argv.skill as string[] | undefined, agent: argv.agent as string[] | undefined, }); + process.exit(0); }), ) .command( @@ -288,6 +289,7 @@ yargs(rawArgs) skill: argv.skill as string[] | undefined, agent: argv.agent as string[] | undefined, }); + process.exit(0); }), ) .command( From f2c1b58164fa971618adf3765fdbcea36448fa5d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 7 Mar 2026 10:54:14 -0600 Subject: [PATCH 3/5] 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 --- src/bin.ts | 2 - src/commands/uninstall-skill.spec.ts | 158 ++++++++++++++++++++++++++- src/commands/uninstall-skill.ts | 99 ++++++++++++----- 3 files changed, 227 insertions(+), 32 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 5f36622..fc97ed1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -256,7 +256,6 @@ yargs(rawArgs) skill: argv.skill as string[] | undefined, agent: argv.agent as string[] | undefined, }); - process.exit(0); }), ) .command( @@ -289,7 +288,6 @@ yargs(rawArgs) skill: argv.skill as string[] | undefined, agent: argv.agent as string[] | undefined, }); - process.exit(0); }), ) .command( diff --git a/src/commands/uninstall-skill.spec.ts b/src/commands/uninstall-skill.spec.ts index 4365918..db2d062 100644 --- a/src/commands/uninstall-skill.spec.ts +++ b/src/commands/uninstall-skill.spec.ts @@ -1,7 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { createAgents, type AgentConfig } from './install-skill.js'; import { findInstalledSkills, uninstallSkill } from './uninstall-skill.js'; @@ -38,6 +37,11 @@ describe('uninstall-skill', () => { expect(result).toEqual([]); }); + it('returns empty array when globalSkillsDir does not exist', () => { + const result = findInstalledSkills(['skill-one', 'skill-two'], agent); + expect(result).toEqual([]); + }); + it('returns only skills that exist in agent directory', () => { const skillDir = join(agent.globalSkillsDir, 'skill-one'); mkdirSync(skillDir, { recursive: true }); @@ -135,3 +139,151 @@ describe('uninstall-skill', () => { }); }); }); + +describe('runUninstallSkill', () => { + let testDir: string; + let homeDir: string; + let skillsDir: string; + let agentSkillsDir: string; + let mockExit: ReturnType; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'uninstall-run-test-')); + homeDir = join(testDir, 'home'); + skillsDir = join(testDir, 'skills'); + agentSkillsDir = join(homeDir, '.test/skills'); + mkdirSync(homeDir); + mkdirSync(skillsDir); + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + mockExit.mockRestore(); + vi.restoreAllMocks(); + vi.resetModules(); + rmSync(testDir, { recursive: true, force: true }); + }); + + function makeTestAgent(): AgentConfig { + return { name: 'test', displayName: 'Test', globalSkillsDir: agentSkillsDir, detect: () => true }; + } + + async function importMocked() { + vi.resetModules(); + vi.doMock('./install-skill.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getSkillsDir: () => skillsDir, + createAgents: () => ({ test: makeTestAgent() }), + detectAgents: () => [makeTestAgent()], + }; + }); + const { runUninstallSkill } = await import('./uninstall-skill.js'); + return { runUninstallSkill }; + } + + it('exits with error when --skill contains only unknown names', async () => { + mkdirSync(join(skillsDir, 'authkit-setup'), { recursive: true }); + writeFileSync(join(skillsDir, 'authkit-setup', 'SKILL.md'), '# AuthKit'); + + const { runUninstallSkill } = await importMocked(); + await runUninstallSkill({ skill: ['nonexistent-skill'] }); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('does not uninstall all skills when --skill filter partially matches', async () => { + // Set up known skills + for (const name of ['skill-a', 'skill-b']) { + mkdirSync(join(skillsDir, name), { recursive: true }); + writeFileSync(join(skillsDir, name, 'SKILL.md'), `# ${name}`); + } + + // Set up installed skills for the agent + for (const name of ['skill-a', 'skill-b']) { + mkdirSync(join(agentSkillsDir, name), { recursive: true }); + writeFileSync(join(agentSkillsDir, name, 'SKILL.md'), `# ${name}`); + } + + const { runUninstallSkill } = await importMocked(); + await runUninstallSkill({ skill: ['skill-a', 'typo-skill'] }); + + // skill-a should be removed, skill-b should be untouched + expect(existsSync(join(agentSkillsDir, 'skill-a', 'SKILL.md'))).toBe(false); + expect(existsSync(join(agentSkillsDir, 'skill-b', 'SKILL.md'))).toBe(true); + }); + + describe('JSON mode', () => { + async function importMockedWithJsonMode() { + vi.resetModules(); + vi.doMock('./install-skill.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getSkillsDir: () => skillsDir, + createAgents: () => ({ test: makeTestAgent() }), + detectAgents: () => [makeTestAgent()], + }; + }); + // Import output.js from the fresh module graph and set JSON mode + const output = await import('../utils/output.js'); + output.setOutputMode('json'); + const { runUninstallSkill } = await import('./uninstall-skill.js'); + return { runUninstallSkill, resetMode: () => output.setOutputMode('human') }; + } + + it('outputs structured JSON for --list in JSON mode', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Set up a known skill + mkdirSync(join(skillsDir, 'test-skill'), { recursive: true }); + writeFileSync(join(skillsDir, 'test-skill', 'SKILL.md'), '# Test'); + + // Set up installed skill for the agent + mkdirSync(join(agentSkillsDir, 'test-skill'), { recursive: true }); + writeFileSync(join(agentSkillsDir, 'test-skill', 'SKILL.md'), '# Test'); + + const { runUninstallSkill, resetMode } = await importMockedWithJsonMode(); + await runUninstallSkill({ list: true }); + + const jsonOutput = consoleSpy.mock.calls.find((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + const parsed = JSON.parse(jsonOutput![0] as string); + expect(parsed).toEqual([{ agent: 'Test', skills: ['test-skill'] }]); + + consoleSpy.mockRestore(); + resetMode(); + }); + + it('outputs structured JSON error for unknown skills', async () => { + const stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mkdirSync(join(skillsDir, 'authkit-setup'), { recursive: true }); + writeFileSync(join(skillsDir, 'authkit-setup', 'SKILL.md'), '# AuthKit'); + + const { runUninstallSkill, resetMode } = await importMockedWithJsonMode(); + await runUninstallSkill({ skill: ['nonexistent'] }); + + const jsonError = stderrSpy.mock.calls.find((call) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.error?.code === 'SKILL_NOT_FOUND'; + } catch { + return false; + } + }); + expect(jsonError).toBeDefined(); + + stderrSpy.mockRestore(); + resetMode(); + }); + }); +}); diff --git a/src/commands/uninstall-skill.ts b/src/commands/uninstall-skill.ts index e5829d0..248eb57 100644 --- a/src/commands/uninstall-skill.ts +++ b/src/commands/uninstall-skill.ts @@ -1,8 +1,10 @@ import { existsSync } from 'fs'; import { rm } from 'fs/promises'; -import { join } from 'path'; import { homedir } from 'os'; +import { join } from 'path'; import chalk from 'chalk'; +import { logError, logInfo, logWarn } from '../utils/debug.js'; +import { exitWithError, isJsonMode, outputJson } from '../utils/output.js'; import { createAgents, detectAgents, discoverSkills, getSkillsDir, type AgentConfig } from './install-skill.js'; export interface UninstallSkillOptions { @@ -24,10 +26,9 @@ export async function uninstallSkill( await rm(targetDir, { recursive: true, force: true }); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; + const message = error instanceof Error ? error.message : 'Unknown error'; + logError(`Failed to remove skill "${skillName}" for ${agent.displayName} at ${targetDir}:`, message); + return { success: false, error: message }; } } @@ -35,46 +36,80 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise const home = homedir(); const agents = createAgents(home); const skillsDir = getSkillsDir(); - const knownSkills = await discoverSkills(skillsDir); + + let knownSkills: string[]; + try { + knownSkills = await discoverSkills(skillsDir); + } catch (error) { + logError('Failed to read skills directory:', error); + exitWithError({ + code: 'SKILLS_DIR_READ_FAILED', + message: `Could not read skills directory at ${skillsDir}. Your WorkOS CLI installation may be corrupted. Try reinstalling with \`npm install -g @workos-inc/cli\`.`, + }); + } const targetAgents = detectAgents(agents, options.agent); if (targetAgents.length === 0) { - if (options.agent) { - console.error(chalk.red('Specified agents not found.')); - } else { - console.error(chalk.red('No coding agents detected.')); - } - console.log('Supported agents:', Object.keys(agents).join(', ')); - process.exit(1); + const message = options.agent ? 'Specified agents not found.' : 'No coding agents detected.'; + logWarn(message, 'Supported agents:', Object.keys(agents).join(', ')); + exitWithError({ + code: 'NO_AGENTS_FOUND', + message: `${message} Supported agents: ${Object.keys(agents).join(', ')}`, + }); } if (options.list) { - console.log(chalk.bold('\nInstalled WorkOS Skills:\n')); + const listData: Array<{ agent: string; skills: string[] }> = []; for (const agent of targetAgents) { const installed = findInstalledSkills(knownSkills, agent); - console.log(` ${chalk.bold(agent.displayName)}:`); - if (installed.length === 0) { - console.log(` ${chalk.dim('(none)')}`); - } else { - for (const skill of installed) { - console.log(` ${chalk.cyan(skill)}`); + listData.push({ agent: agent.displayName, skills: installed }); + } + + if (isJsonMode()) { + outputJson(listData); + } else { + console.log(chalk.bold('\nInstalled WorkOS Skills:\n')); + for (const entry of listData) { + console.log(` ${chalk.bold(entry.agent)}:`); + if (entry.skills.length === 0) { + console.log(` ${chalk.dim('(none)')}`); + } else { + for (const skill of entry.skills) { + console.log(` ${chalk.cyan(skill)}`); + } } } + console.log(); } - console.log(); return; } const targetSkillNames = options.skill ? knownSkills.filter((s) => options.skill!.includes(s)) : knownSkills; + if (options.skill) { + const unrecognized = options.skill.filter((s) => !knownSkills.includes(s)); + if (unrecognized.length > 0) { + logWarn('Unrecognized skill names requested for uninstall:', unrecognized); + if (!isJsonMode()) { + console.warn(chalk.yellow(`Unknown skills (ignored): ${unrecognized.join(', ')}`)); + } + } + } + if (options.skill && targetSkillNames.length === 0) { - console.error(chalk.red('No matching skills found.')); - console.log('Known skills:', knownSkills.join(', ')); - process.exit(1); + logError('No matching skills found. Known skills:', knownSkills.join(', ')); + exitWithError({ + code: 'SKILL_NOT_FOUND', + message: `No matching skills found. Known skills: ${knownSkills.join(', ')}`, + }); } - console.log(chalk.bold('\nUninstalling skills...\n')); + logInfo('Uninstalling skills:', targetSkillNames.join(', '), 'for agents:', targetAgents.map((a) => a.displayName).join(', ')); + + if (!isJsonMode()) { + console.log(chalk.bold('\nUninstalling skills...\n')); + } const results: Array<{ skill: string; @@ -86,8 +121,8 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise for (const skill of targetSkillNames) { for (const agent of targetAgents) { - const isInstalled = existsSync(join(agent.globalSkillsDir, skill, 'SKILL.md')); - if (!isInstalled) { + const installed = findInstalledSkills([skill], agent); + if (installed.length === 0) { results.push({ skill, agent: agent.displayName, success: true, skipped: true }); continue; } @@ -105,7 +140,16 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise const skipped = results.filter((r) => r.skipped); const failed = results.filter((r) => !r.success); + if (isJsonMode()) { + outputJson({ removed, skipped, failed }); + if (failed.length > 0) { + process.exit(1); + } + return; + } + if (removed.length > 0) { + logInfo(`Removed ${removed.length} skill(s)`); console.log(chalk.green(`✓ Removed ${removed.length} skill(s):\n`)); for (const r of removed) { console.log(` ${chalk.cyan(r.skill)} ← ${chalk.dim(r.agent)}`); @@ -117,6 +161,7 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise } if (failed.length > 0) { + logError(`Failed to remove ${failed.length} skill(s)`); console.log(chalk.red(`\n✗ Failed to remove ${failed.length}:\n`)); for (const r of failed) { console.log(` ${r.skill} ← ${r.agent}: ${chalk.dim(r.error)}`); From d7b05a80eb95018b8c162a37ff4e6498268e5a36 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 7 Mar 2026 11:58:51 -0600 Subject: [PATCH 4/5] 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 --- src/bin.ts | 125 ++++++++++++++------------- src/commands/install-skill.ts | 10 --- src/commands/list-skills.spec.ts | 111 ++++++++++++++++++++++++ src/commands/list-skills.ts | 61 +++++++++++++ src/commands/uninstall-skill.spec.ts | 11 +-- src/commands/uninstall-skill.ts | 27 ------ src/utils/help-json.spec.ts | 2 +- src/utils/help-json.ts | 104 +++++++++++----------- 8 files changed, 300 insertions(+), 151 deletions(-) create mode 100644 src/commands/list-skills.spec.ts create mode 100644 src/commands/list-skills.ts diff --git a/src/bin.ts b/src/bin.ts index fc97ed1..a3ae927 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -226,70 +226,79 @@ yargs(rawArgs) ); return yargs.demandCommand(1, 'Please specify an auth subcommand').strict(); }) - .command( - 'install-skill', - 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', - (yargs) => { - return yargs - .option('list', { - alias: 'l', - type: 'boolean', - description: 'List available skills without installing', - }) - .option('skill', { - alias: 's', - type: 'array', - string: true, - description: 'Install specific skill(s)', - }) - .option('agent', { - alias: 'a', - type: 'array', - string: true, - description: 'Target specific agent(s): claude-code, codex, cursor, goose', + .command('skills', 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', (yargs) => { + registerSubcommand( + yargs, + 'install', + 'Install bundled AuthKit skills to coding agents', + (y) => + y + .option('skill', { + alias: 's', + type: 'array', + string: true, + description: 'Install specific skill(s) by name', + }) + .option('agent', { + alias: 'a', + type: 'array', + string: true, + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + }), + async (argv) => { + const { runInstallSkill } = await import('./commands/install-skill.js'); + await runInstallSkill({ + skill: argv.skill as string[] | undefined, + agent: argv.agent as string[] | undefined, }); - }, - withAuth(async (argv) => { - const { runInstallSkill } = await import('./commands/install-skill.js'); - await runInstallSkill({ - list: argv.list as boolean | undefined, - skill: argv.skill as string[] | undefined, - agent: argv.agent as string[] | undefined, - }); - }), - ) - .command( - 'uninstall-skill', - 'Remove installed WorkOS skills from coding agents', - (yargs) => { - return yargs - .option('list', { - alias: 'l', - type: 'boolean', - description: 'List installed skills without removing', - }) - .option('skill', { - alias: 's', - type: 'array', - string: true, - description: 'Remove specific skill(s)', - }) - .option('agent', { + }, + ); + registerSubcommand( + yargs, + 'uninstall', + 'Remove installed WorkOS skills from coding agents', + (y) => + y + .option('skill', { + alias: 's', + type: 'array', + string: true, + description: 'Remove specific skill(s) by name', + }) + .option('agent', { + alias: 'a', + type: 'array', + string: true, + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + }), + async (argv) => { + const { runUninstallSkill } = await import('./commands/uninstall-skill.js'); + await runUninstallSkill({ + skill: argv.skill as string[] | undefined, + agent: argv.agent as string[] | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'list', + 'List available and installed skills', + (y) => + y.option('agent', { alias: 'a', type: 'array', string: true, description: 'Target specific agent(s): claude-code, codex, cursor, goose', + }), + async (argv) => { + const { runListSkills } = await import('./commands/list-skills.js'); + await runListSkills({ + agent: argv.agent as string[] | undefined, }); - }, - withAuth(async (argv) => { - const { runUninstallSkill } = await import('./commands/uninstall-skill.js'); - await runUninstallSkill({ - list: argv.list as boolean | undefined, - skill: argv.skill as string[] | undefined, - agent: argv.agent as string[] | undefined, - }); - }), - ) + }, + ); + return yargs.demandCommand(1, 'Please specify a skills subcommand').strict(); + }) .command( 'doctor', 'Diagnose WorkOS AuthKit integration issues in the current project', diff --git a/src/commands/install-skill.ts b/src/commands/install-skill.ts index 02f4ac3..f475778 100644 --- a/src/commands/install-skill.ts +++ b/src/commands/install-skill.ts @@ -42,7 +42,6 @@ export function createAgents(home: string): Record { } export interface InstallSkillOptions { - list?: boolean; skill?: string[]; agent?: string[]; } @@ -97,15 +96,6 @@ export async function runInstallSkill(options: InstallSkillOptions): Promise options.skill!.includes(s)) : skills; if (targetSkills.length === 0) { diff --git a/src/commands/list-skills.spec.ts b/src/commands/list-skills.spec.ts new file mode 100644 index 0000000..1228a32 --- /dev/null +++ b/src/commands/list-skills.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import type { AgentConfig } from './install-skill.js'; + +describe('runListSkills', () => { + let testDir: string; + let homeDir: string; + let skillsDir: string; + let agentSkillsDir: string; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'list-skills-test-')); + homeDir = join(testDir, 'home'); + skillsDir = join(testDir, 'skills'); + agentSkillsDir = join(homeDir, '.test/skills'); + mkdirSync(homeDir); + mkdirSync(skillsDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + rmSync(testDir, { recursive: true, force: true }); + }); + + function makeTestAgent(): AgentConfig { + return { name: 'test', displayName: 'Test', globalSkillsDir: agentSkillsDir, detect: () => true }; + } + + async function importMocked() { + vi.resetModules(); + vi.doMock('./install-skill.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getSkillsDir: () => skillsDir, + createAgents: () => ({ test: makeTestAgent() }), + detectAgents: () => [makeTestAgent()], + }; + }); + const { runListSkills } = await import('./list-skills.js'); + return { runListSkills }; + } + + async function importMockedWithJsonMode() { + vi.resetModules(); + vi.doMock('./install-skill.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getSkillsDir: () => skillsDir, + createAgents: () => ({ test: makeTestAgent() }), + detectAgents: () => [makeTestAgent()], + }; + }); + const output = await import('../utils/output.js'); + output.setOutputMode('json'); + const { runListSkills } = await import('./list-skills.js'); + return { runListSkills, resetMode: () => output.setOutputMode('human') }; + } + + it('lists available and installed skills', async () => { + mkdirSync(join(skillsDir, 'skill-a'), { recursive: true }); + writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# A'); + mkdirSync(join(skillsDir, 'skill-b'), { recursive: true }); + writeFileSync(join(skillsDir, 'skill-b', 'SKILL.md'), '# B'); + + mkdirSync(join(agentSkillsDir, 'skill-a'), { recursive: true }); + writeFileSync(join(agentSkillsDir, 'skill-a', 'SKILL.md'), '# A'); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { runListSkills } = await importMocked(); + await runListSkills({}); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('skill-a'); + expect(output).toContain('skill-b'); + consoleSpy.mockRestore(); + }); + + it('outputs structured JSON in JSON mode', async () => { + mkdirSync(join(skillsDir, 'skill-a'), { recursive: true }); + writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# A'); + + mkdirSync(join(agentSkillsDir, 'skill-a'), { recursive: true }); + writeFileSync(join(agentSkillsDir, 'skill-a', 'SKILL.md'), '# A'); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { runListSkills, resetMode } = await importMockedWithJsonMode(); + await runListSkills({}); + + const jsonOutput = consoleSpy.mock.calls.find((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonOutput).toBeDefined(); + const parsed = JSON.parse(jsonOutput![0] as string); + expect(parsed).toEqual([ + { agent: 'Test', available: ['skill-a'], installed: ['skill-a'] }, + ]); + + consoleSpy.mockRestore(); + resetMode(); + }); +}); diff --git a/src/commands/list-skills.ts b/src/commands/list-skills.ts new file mode 100644 index 0000000..952a499 --- /dev/null +++ b/src/commands/list-skills.ts @@ -0,0 +1,61 @@ +import { homedir } from 'os'; +import chalk from 'chalk'; +import { logError } from '../utils/debug.js'; +import { exitWithError, isJsonMode, outputJson } from '../utils/output.js'; +import { createAgents, detectAgents, discoverSkills, getSkillsDir } from './install-skill.js'; +import { findInstalledSkills } from './uninstall-skill.js'; + +export interface ListSkillsOptions { + agent?: string[]; +} + +export async function runListSkills(options: ListSkillsOptions): Promise { + const home = homedir(); + const agents = createAgents(home); + const skillsDir = getSkillsDir(); + + let knownSkills: string[]; + try { + knownSkills = await discoverSkills(skillsDir); + } catch (error) { + logError('Failed to read skills directory:', error); + exitWithError({ + code: 'SKILLS_DIR_READ_FAILED', + message: `Could not read skills directory at ${skillsDir}. Your WorkOS CLI installation may be corrupted. Try reinstalling with \`npm install -g @workos-inc/cli\`.`, + }); + } + + const targetAgents = detectAgents(agents, options.agent); + + const listData: Array<{ agent: string; available: string[]; installed: string[] }> = []; + for (const agent of targetAgents) { + const installed = findInstalledSkills(knownSkills, agent); + listData.push({ agent: agent.displayName, available: knownSkills, installed }); + } + + if (isJsonMode()) { + outputJson(listData); + return; + } + + console.log(chalk.bold('\nWorkOS Skills:\n')); + console.log(` ${chalk.bold('Available:')} ${knownSkills.map((s) => chalk.cyan(s)).join(', ')}\n`); + + if (targetAgents.length === 0) { + console.log(chalk.dim(' No coding agents detected.\n')); + return; + } + + console.log(chalk.bold(' Installed per agent:\n')); + for (const entry of listData) { + console.log(` ${chalk.bold(entry.agent)}:`); + if (entry.installed.length === 0) { + console.log(` ${chalk.dim('(none)')}`); + } else { + for (const skill of entry.installed) { + console.log(` ${chalk.cyan(skill)}`); + } + } + } + console.log(); +} diff --git a/src/commands/uninstall-skill.spec.ts b/src/commands/uninstall-skill.spec.ts index db2d062..36cf116 100644 --- a/src/commands/uninstall-skill.spec.ts +++ b/src/commands/uninstall-skill.spec.ts @@ -233,7 +233,7 @@ describe('runUninstallSkill', () => { return { runUninstallSkill, resetMode: () => output.setOutputMode('human') }; } - it('outputs structured JSON for --list in JSON mode', async () => { + it('outputs structured JSON results for uninstall', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // Set up a known skill @@ -245,19 +245,20 @@ describe('runUninstallSkill', () => { writeFileSync(join(agentSkillsDir, 'test-skill', 'SKILL.md'), '# Test'); const { runUninstallSkill, resetMode } = await importMockedWithJsonMode(); - await runUninstallSkill({ list: true }); + await runUninstallSkill({}); const jsonOutput = consoleSpy.mock.calls.find((call) => { try { - JSON.parse(call[0] as string); - return true; + const parsed = JSON.parse(call[0] as string); + return parsed.removed !== undefined; } catch { return false; } }); expect(jsonOutput).toBeDefined(); const parsed = JSON.parse(jsonOutput![0] as string); - expect(parsed).toEqual([{ agent: 'Test', skills: ['test-skill'] }]); + expect(parsed.removed).toHaveLength(1); + expect(parsed.removed[0].skill).toBe('test-skill'); consoleSpy.mockRestore(); resetMode(); diff --git a/src/commands/uninstall-skill.ts b/src/commands/uninstall-skill.ts index 248eb57..88c6bcd 100644 --- a/src/commands/uninstall-skill.ts +++ b/src/commands/uninstall-skill.ts @@ -8,7 +8,6 @@ import { exitWithError, isJsonMode, outputJson } from '../utils/output.js'; import { createAgents, detectAgents, discoverSkills, getSkillsDir, type AgentConfig } from './install-skill.js'; export interface UninstallSkillOptions { - list?: boolean; skill?: string[]; agent?: string[]; } @@ -59,32 +58,6 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise }); } - if (options.list) { - const listData: Array<{ agent: string; skills: string[] }> = []; - for (const agent of targetAgents) { - const installed = findInstalledSkills(knownSkills, agent); - listData.push({ agent: agent.displayName, skills: installed }); - } - - if (isJsonMode()) { - outputJson(listData); - } else { - console.log(chalk.bold('\nInstalled WorkOS Skills:\n')); - for (const entry of listData) { - console.log(` ${chalk.bold(entry.agent)}:`); - if (entry.skills.length === 0) { - console.log(` ${chalk.dim('(none)')}`); - } else { - for (const skill of entry.skills) { - console.log(` ${chalk.cyan(skill)}`); - } - } - } - console.log(); - } - return; - } - const targetSkillNames = options.skill ? knownSkills.filter((s) => options.skill!.includes(s)) : knownSkills; if (options.skill) { diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 4744301..e548ec2 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -32,7 +32,7 @@ describe('help-json', () => { 'auth login', 'auth logout', 'auth status', - 'install-skill', + 'skills', 'doctor', 'env', 'organization', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 66c3661..c1f6837 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -111,62 +111,66 @@ const commands: CommandSchema[] = [ options: [insecureStorageOpt], }, { - name: 'install-skill', - description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)', - options: [ - { - name: 'list', - type: 'boolean', - description: 'List available skills without installing', - required: false, - alias: 'l', - hidden: false, - }, + name: 'skills', + description: 'Manage WorkOS skills for coding agents (Claude Code, Codex, Cursor, Goose)', + commands: [ { - name: 'skill', - type: 'array', - description: 'Install specific skill(s) by name', - required: false, - alias: 's', - hidden: false, + name: 'install', + description: 'Install bundled AuthKit skills to coding agents', + options: [ + { + name: 'skill', + type: 'array', + description: 'Install specific skill(s) by name', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, + ], }, { - name: 'agent', - type: 'array', - description: 'Target specific agent(s): claude-code, codex, cursor, goose', - required: false, - alias: 'a', - hidden: false, + name: 'uninstall', + description: 'Remove installed WorkOS skills from coding agents', + options: [ + { + name: 'skill', + type: 'array', + description: 'Remove specific skill(s) by name', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, + ], }, - ], - }, - { - name: 'uninstall-skill', - description: 'Remove installed WorkOS skills from coding agents', - options: [ { name: 'list', - type: 'boolean', - description: 'List installed skills without removing', - required: false, - alias: 'l', - hidden: false, - }, - { - name: 'skill', - type: 'array', - description: 'Remove specific skill(s) by name', - required: false, - alias: 's', - hidden: false, - }, - { - name: 'agent', - type: 'array', - description: 'Target specific agent(s): claude-code, codex, cursor, goose', - required: false, - alias: 'a', - hidden: false, + description: 'List available and installed skills', + options: [ + { + name: 'agent', + type: 'array', + description: 'Target specific agent(s): claude-code, codex, cursor, goose', + required: false, + alias: 'a', + hidden: false, + }, + ], }, ], }, From 28eb68b1d06544ac45c1a1aa91ce9b0b08f85c7d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sat, 7 Mar 2026 12:00:28 -0600 Subject: [PATCH 5/5] chore: formatting --- src/commands/list-skills.spec.ts | 4 +--- src/commands/uninstall-skill.ts | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/list-skills.spec.ts b/src/commands/list-skills.spec.ts index 1228a32..1a9027b 100644 --- a/src/commands/list-skills.spec.ts +++ b/src/commands/list-skills.spec.ts @@ -101,9 +101,7 @@ describe('runListSkills', () => { }); expect(jsonOutput).toBeDefined(); const parsed = JSON.parse(jsonOutput![0] as string); - expect(parsed).toEqual([ - { agent: 'Test', available: ['skill-a'], installed: ['skill-a'] }, - ]); + expect(parsed).toEqual([{ agent: 'Test', available: ['skill-a'], installed: ['skill-a'] }]); consoleSpy.mockRestore(); resetMode(); diff --git a/src/commands/uninstall-skill.ts b/src/commands/uninstall-skill.ts index 88c6bcd..64e4f1f 100644 --- a/src/commands/uninstall-skill.ts +++ b/src/commands/uninstall-skill.ts @@ -78,7 +78,12 @@ export async function runUninstallSkill(options: UninstallSkillOptions): Promise }); } - logInfo('Uninstalling skills:', targetSkillNames.join(', '), 'for agents:', targetAgents.map((a) => a.displayName).join(', ')); + logInfo( + 'Uninstalling skills:', + targetSkillNames.join(', '), + 'for agents:', + targetAgents.map((a) => a.displayName).join(', '), + ); if (!isJsonMode()) { console.log(chalk.bold('\nUninstalling skills...\n'));