From 54a5cd86ed3d9f86b6367925c36be907883d5cb9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 16 Mar 2026 09:28:46 -0500 Subject: [PATCH 1/3] 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. --- src/commands/install-skill.spec.ts | 102 ++++++++++++++++++++++++++++- src/commands/install-skill.ts | 24 +++++++ src/commands/install.spec.ts | 75 +++++++++++++++++++++ src/commands/install.ts | 2 + 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 src/commands/install.spec.ts diff --git a/src/commands/install-skill.spec.ts b/src/commands/install-skill.spec.ts index 29320aa4..e0453a5a 100644 --- a/src/commands/install-skill.spec.ts +++ b/src/commands/install-skill.spec.ts @@ -1,9 +1,19 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; -import { createAgents, discoverSkills, detectAgents, installSkill, type AgentConfig } from './install-skill.js'; +import { createAgents, discoverSkills, detectAgents, installSkill, autoInstallSkills, type AgentConfig } from './install-skill.js'; + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: vi.fn(actual.homedir) }; +}); + +vi.mock('@workos/skills', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getSkillsDir: vi.fn(actual.getSkillsDir) }; +}); describe('install-skill', () => { let testDir: string; @@ -195,4 +205,92 @@ describe('install-skill', () => { expect(content).toContain('# Updated Skill'); }); }); + + describe('autoInstallSkills', () => { + beforeEach(async () => { + const { homedir } = await import('os'); + const { getSkillsDir } = await import('@workos/skills'); + vi.mocked(homedir).mockReturnValue(homeDir); + vi.mocked(getSkillsDir).mockReturnValue(skillsDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('installs all skills to all detected agents', async () => { + // Set up skills + mkdirSync(join(skillsDir, 'skill-a')); + writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A'); + mkdirSync(join(skillsDir, 'skill-b')); + writeFileSync(join(skillsDir, 'skill-b', 'SKILL.md'), '# Skill B'); + + // Set up detected agents + mkdirSync(join(homeDir, '.claude')); + mkdirSync(join(homeDir, '.codex')); + + await autoInstallSkills(); + + expect(existsSync(join(homeDir, '.claude/skills/skill-a/SKILL.md'))).toBe(true); + expect(existsSync(join(homeDir, '.claude/skills/skill-b/SKILL.md'))).toBe(true); + expect(existsSync(join(homeDir, '.codex/skills/skill-a/SKILL.md'))).toBe(true); + expect(existsSync(join(homeDir, '.codex/skills/skill-b/SKILL.md'))).toBe(true); + }); + + it('no-ops silently when no agents are detected', async () => { + mkdirSync(join(skillsDir, 'skill-a')); + writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A'); + + // No agent directories created — none detected + await expect(autoInstallSkills()).resolves.toBeUndefined(); + }); + + it('no-ops silently when no skills are discovered', async () => { + mkdirSync(join(homeDir, '.claude')); + + // No skills in skillsDir + await expect(autoInstallSkills()).resolves.toBeUndefined(); + }); + + it('swallows errors from discoverSkills', async () => { + // Point to a nonexistent skills directory + const { getSkillsDir } = await import('@workos/skills'); + vi.mocked(getSkillsDir).mockReturnValue('/nonexistent/path'); + + await expect(autoInstallSkills()).resolves.toBeUndefined(); + }); + + it('resolves silently when installSkill returns failure', async () => { + // installSkill returns { success: false } on copy errors (doesn't throw). + // Verify autoInstallSkills completes without throwing even when installs fail. + // Simulate by creating a skill dir with SKILL.md for discovery, then making + // the target agent dir read-only so copyFile fails. + mkdirSync(join(skillsDir, 'test-skill')); + writeFileSync(join(skillsDir, 'test-skill', 'SKILL.md'), '# Test'); + + mkdirSync(join(homeDir, '.claude')); + // Create a file where the skills directory should be, so mkdir fails + mkdirSync(join(homeDir, '.claude/skills')); + writeFileSync(join(homeDir, '.claude/skills/test-skill'), 'not a directory'); + + await expect(autoInstallSkills()).resolves.toBeUndefined(); + }); + + it('does not produce any console output', async () => { + const logSpy = vi.spyOn(console, 'log'); + const errorSpy = vi.spyOn(console, 'error'); + + mkdirSync(join(skillsDir, 'skill-a')); + writeFileSync(join(skillsDir, 'skill-a', 'SKILL.md'), '# Skill A'); + mkdirSync(join(homeDir, '.claude')); + + await autoInstallSkills(); + + expect(logSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + }); }); diff --git a/src/commands/install-skill.ts b/src/commands/install-skill.ts index ca334012..749f71f8 100644 --- a/src/commands/install-skill.ts +++ b/src/commands/install-skill.ts @@ -156,3 +156,27 @@ export async function runInstallSkill(options: InstallSkillOptions): Promise { + try { + const home = homedir(); + const agents = createAgents(home); + const skillsDir = getSkillsDir(); + const skills = await discoverSkills(skillsDir); + const targetAgents = detectAgents(agents); + + if (skills.length === 0 || targetAgents.length === 0) return; + + for (const skill of skills) { + for (const agent of targetAgents) { + await installSkill(skillsDir, skill, agent); + } + } + } catch { + // Intentionally swallowed — skill install is best-effort + } +} diff --git a/src/commands/install.spec.ts b/src/commands/install.spec.ts new file mode 100644 index 00000000..b28e09aa --- /dev/null +++ b/src/commands/install.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../run.js', () => ({ + runInstaller: vi.fn(), +})); + +vi.mock('./install-skill.js', () => ({ + autoInstallSkills: vi.fn(), +})); + +vi.mock('../utils/clack.js', () => ({ + default: { + log: { info: vi.fn(), error: vi.fn() }, + }, +})); + +vi.mock('../utils/output.js', () => ({ + exitWithError: vi.fn(), + isJsonMode: vi.fn(() => false), +})); + +vi.mock('../utils/debug.js', () => ({ + getLogFilePath: vi.fn(() => null), +})); + +const { runInstaller } = await import('../run.js'); +const { autoInstallSkills } = await import('./install-skill.js'); + +vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); +}) as any); + +const { handleInstall } = await import('./install.js'); + +describe('handleInstall', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls autoInstallSkills after successful install', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockResolvedValue(undefined); + + await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called'); + + expect(runInstaller).toHaveBeenCalledOnce(); + expect(autoInstallSkills).toHaveBeenCalledOnce(); + + // Verify order: autoInstallSkills called after runInstaller + const runInstallerOrder = vi.mocked(runInstaller).mock.invocationCallOrder[0]; + const autoInstallOrder = vi.mocked(autoInstallSkills).mock.invocationCallOrder[0]; + expect(autoInstallOrder).toBeGreaterThan(runInstallerOrder); + }); + + it('does not call autoInstallSkills when runInstaller throws', async () => { + vi.mocked(runInstaller).mockRejectedValue(new Error('install failed')); + + await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called'); + + expect(runInstaller).toHaveBeenCalledOnce(); + expect(autoInstallSkills).not.toHaveBeenCalled(); + }); + + it('still exits 0 even if autoInstallSkills throws', async () => { + vi.mocked(runInstaller).mockResolvedValue(undefined as any); + vi.mocked(autoInstallSkills).mockRejectedValue(new Error('skill install exploded')); + + // autoInstallSkills throwing will trigger the outer catch, which calls process.exit(1) + // But autoInstallSkills has its own internal catch in production — this tests defense in depth + await expect(handleInstall({ _: ['install'], $0: 'workos' } as any)).rejects.toThrow('process.exit called'); + + expect(runInstaller).toHaveBeenCalledOnce(); + expect(autoInstallSkills).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/commands/install.ts b/src/commands/install.ts index e1051ce1..c023c995 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -3,6 +3,7 @@ import type { InstallerArgs } from '../run.js'; import clack from '../utils/clack.js'; import { exitWithError, isJsonMode } from '../utils/output.js'; import type { ArgumentsCamelCase } from 'yargs'; +import { autoInstallSkills } from './install-skill.js'; /** * Handle install command execution. @@ -28,6 +29,7 @@ export async function handleInstall(argv: ArgumentsCamelCase): Pr try { await runInstaller(options); + await autoInstallSkills(); process.exit(0); } catch (err) { const { getLogFilePath } = await import('../utils/debug.js'); From 94a27ad825eea88b7b290025ed7ee2cf874ffc71 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 16 Mar 2026 09:30:53 -0500 Subject: [PATCH 2/3] chore: formatting --- src/commands/install-skill.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commands/install-skill.spec.ts b/src/commands/install-skill.spec.ts index e0453a5a..9b9d11e2 100644 --- a/src/commands/install-skill.spec.ts +++ b/src/commands/install-skill.spec.ts @@ -3,7 +3,14 @@ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; -import { createAgents, discoverSkills, detectAgents, installSkill, autoInstallSkills, type AgentConfig } from './install-skill.js'; +import { + createAgents, + discoverSkills, + detectAgents, + installSkill, + autoInstallSkills, + type AgentConfig, +} from './install-skill.js'; vi.mock('os', async (importOriginal) => { const actual = await importOriginal(); From a3748fbb656a03bc4f837ac91ec5c119eb5fc1b5 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 16 Mar 2026 09:40:49 -0500 Subject: [PATCH 3/3] docs: note skills auto-install in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e58705f2..4cd93a0d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Commands: doctor Diagnose WorkOS integration issues skills Manage WorkOS skills for coding agents (install, uninstall, list) +Skills are automatically installed to detected coding agents when you run `workos install`. Use `workos skills list` to check status. + Resource Management: organization (org) Manage organizations user Manage users