Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 107 additions & 2 deletions src/commands/install-skill.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('os')>();
return { ...actual, homedir: vi.fn(actual.homedir) };
});

vi.mock('@workos/skills', async (importOriginal) => {
const actual = await importOriginal<typeof import('@workos/skills')>();
return { ...actual, getSkillsDir: vi.fn(actual.getSkillsDir) };
});

describe('install-skill', () => {
let testDir: string;
Expand Down Expand Up @@ -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();
});
});
});
24 changes: 24 additions & 0 deletions src/commands/install-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,27 @@ export async function runInstallSkill(options: InstallSkillOptions): Promise<voi

console.log(chalk.green('\nDone!'));
}

/**
* Silently install all bundled skills to all detected coding agents.
* Errors are swallowed — this must never disrupt the calling flow.
*/
export async function autoInstallSkills(): Promise<void> {
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
}
}
75 changes: 75 additions & 0 deletions src/commands/install.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 2 additions & 0 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,6 +29,7 @@ export async function handleInstall(argv: ArgumentsCamelCase<InstallerArgs>): Pr

try {
await runInstaller(options);
await autoInstallSkills();
process.exit(0);
} catch (err) {
const { getLogFilePath } = await import('../utils/debug.js');
Expand Down
Loading