diff --git a/README.md b/README.md index e58705f..4cd93a0 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 diff --git a/src/commands/install-skill.spec.ts b/src/commands/install-skill.spec.ts index 29320aa..9b9d11e 100644 --- a/src/commands/install-skill.spec.ts +++ b/src/commands/install-skill.spec.ts @@ -1,9 +1,26 @@ -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 +212,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 ca33401..749f71f 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 0000000..b28e09a --- /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 e1051ce..c023c99 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');