Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3473,6 +3473,11 @@ async function runCli(): Promise<void> {
try {
await parser.parseAsync(rawArgs);

// Offer to refresh stale CLI-installed skills. Self-gating (human TTY only,
// once per bundled version) and best-effort — never affects the outcome.
const { maybeOfferSkillsUpdate } = await import('./commands/setup.js');
await maybeOfferSkillsUpdate(commandName);

process.exitCode = 0;
commandOutcome = {
success: true,
Expand Down
193 changes: 190 additions & 3 deletions src/commands/setup.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// ── Mocks ─────────────────────────────────────────────────────────────────────
const CANCEL = Symbol('cancel');
Expand All @@ -7,7 +7,7 @@ vi.mock('../utils/ui.js', () => ({
default: {
heading: vi.fn(),
note: vi.fn(),
log: { info: vi.fn(), success: vi.fn(), error: vi.fn(), hint: vi.fn() },
log: { info: vi.fn(), success: vi.fn(), error: vi.fn(), hint: vi.fn(), warn: vi.fn() },
confirm: vi.fn(),
},
isCancel: (v: unknown) => v === CANCEL,
Expand Down Expand Up @@ -39,6 +39,10 @@ vi.mock('../lib/preferences.js', () => ({
recordSetupDeclined: vi.fn(),
recordSetupCompleted: vi.fn(),
clearSetupDecline: vi.fn(),
getSkillsUpdateOfferedVersion: vi.fn(() => undefined),
recordSkillsUpdateOffered: vi.fn(),
hasSkillsUpdateRetried: vi.fn(() => false),
recordSkillsUpdateRetry: vi.fn(),
}));

vi.mock('./install-skill.js', () => ({
Expand All @@ -50,6 +54,10 @@ vi.mock('./install-skill.js', () => ({
refreshWorkOSSkills: vi.fn(),
}));

vi.mock('../doctor/checks/skills.js', () => ({
checkSkills: vi.fn(),
}));

vi.mock('../lib/mcp-clients.js', () => ({
detectMcpClients: vi.fn(),
MCP_AGENT_KEYS: ['claude-code', 'codex', 'cursor'],
Expand All @@ -76,10 +84,11 @@ const { isJsonMode, outputSuccess } = await import('../utils/output.js');
const { isPromptAllowed } = await import('../utils/interaction-mode.js');
const prefs = await import('../lib/preferences.js');
const { detectAgents, refreshWorkOSSkills } = await import('./install-skill.js');
const { checkSkills } = await import('../doctor/checks/skills.js');
const { detectMcpClients } = await import('../lib/mcp-clients.js');
const { analytics } = await import('../utils/analytics.js');

const { runSetup, maybeRunSetupAfter } = await import('./setup.js');
const { runSetup, maybeRunSetupAfter, maybeOfferSkillsUpdate } = await import('./setup.js');

// ── Fixtures ──────────────────────────────────────────────────────────────────
const claudeAgent = { name: 'claude-code', displayName: 'Claude Code', globalSkillsDir: '/x', detect: () => true };
Expand Down Expand Up @@ -110,6 +119,9 @@ beforeEach(() => {
vi.mocked(isJsonMode).mockReturnValue(false);
vi.mocked(prefs.isSetupDeclined).mockReturnValue(false);
vi.mocked(prefs.isSetupCompleted).mockReturnValue(false);
vi.mocked(prefs.getSkillsUpdateOfferedVersion).mockReturnValue(undefined);
vi.mocked(prefs.hasSkillsUpdateRetried).mockReturnValue(false);
vi.mocked(checkSkills).mockResolvedValue(null);
vi.mocked(refreshWorkOSSkills).mockResolvedValue({
agents: [claudeAgent as any],
skills: ['workos', 'workos-widgets'],
Expand Down Expand Up @@ -431,3 +443,178 @@ describe('maybeRunSetupAfter', () => {
expect(confirmArgs.signal).toBeUndefined();
});
});

describe('maybeOfferSkillsUpdate', () => {
const realStdinIsTTY = process.stdin.isTTY;

beforeEach(() => {
// Interaction mode is mocked, but the stdin gate reads the real stream — and
// vitest runs with stdin redirected. Stub an answerable terminal.
process.stdin.isTTY = true;
});

afterEach(() => {
process.stdin.isTTY = realStdinIsTTY;
});

function staleClaude() {
vi.mocked(checkSkills).mockResolvedValue({
bundledVersion: '2.0.0',
agents: [
{ agent: 'Claude Code', installedVersion: '1.0.0', stale: true },
{ agent: 'Cursor', installedVersion: '2.0.0', stale: false },
],
});
}

it('is silent for exempt commands, non-human mode, JSON mode, and after a setup decline', async () => {
staleClaude();

await maybeOfferSkillsUpdate('skills.install');
await maybeOfferSkillsUpdate('root');
vi.mocked(isPromptAllowed).mockReturnValue(false);
await maybeOfferSkillsUpdate('organization.list');
vi.mocked(isPromptAllowed).mockReturnValue(true);
vi.mocked(isJsonMode).mockReturnValue(true);
await maybeOfferSkillsUpdate('organization.list');
vi.mocked(isJsonMode).mockReturnValue(false);
vi.mocked(prefs.isSetupDeclined).mockReturnValue(true);
await maybeOfferSkillsUpdate('organization.list');

expect(ui.confirm).not.toHaveBeenCalled();
});

it('does not prompt when nothing is stale or this bundled version was already offered', async () => {
await maybeOfferSkillsUpdate('organization.list');
staleClaude();
vi.mocked(prefs.getSkillsUpdateOfferedVersion).mockReturnValue('2.0.0');
await maybeOfferSkillsUpdate('organization.list');

expect(ui.confirm).not.toHaveBeenCalled();
});

it('refreshes only the stale agents on accept and remembers the bundled version', async () => {
staleClaude();
vi.mocked(ui.confirm).mockResolvedValue(true);

await maybeOfferSkillsUpdate('organization.list');

expect(ui.confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false }));
expect(refreshWorkOSSkills).toHaveBeenCalledWith({
agents: [expect.objectContaining({ name: 'claude-code' })],
});
expect(prefs.recordSkillsUpdateOffered).toHaveBeenCalledWith('2.0.0');
expect(ui.log.success).toHaveBeenCalled();
});

it('installs nothing on decline but remembers the version so it is not asked again', async () => {
staleClaude();
vi.mocked(ui.confirm).mockResolvedValue(false);

await maybeOfferSkillsUpdate('organization.list');

expect(refreshWorkOSSkills).not.toHaveBeenCalled();
expect(prefs.recordSkillsUpdateOffered).toHaveBeenCalledWith('2.0.0');
});

it('treats cancel (ctrl-c) as skip without remembering the version', async () => {
staleClaude();
vi.mocked(ui.confirm).mockResolvedValue(CANCEL);

await maybeOfferSkillsUpdate('organization.list');

expect(refreshWorkOSSkills).not.toHaveBeenCalled();
expect(prefs.recordSkillsUpdateOffered).not.toHaveBeenCalled();
});

it('stays silent when stdin is redirected, so it never asks a question nobody can answer', async () => {
staleClaude();
process.stdin.isTTY = undefined;

await maybeOfferSkillsUpdate('organization.list');

// The message must not print either: `ui.confirm` would throw on the
// redirected stdin, leaving the version unrecorded and the offer to repeat
// after every later command.
expect(ui.log.info).not.toHaveBeenCalled();
expect(ui.confirm).not.toHaveBeenCalled();
});

/** Both agents stale, but only Claude Code lands — a truthy result all the same. */
function partialRefresh() {
vi.mocked(checkSkills).mockResolvedValue({
bundledVersion: '2.0.0',
agents: [
{ agent: 'Claude Code', installedVersion: '1.0.0', stale: true },
{ agent: 'Cursor', installedVersion: '1.0.0', stale: true },
],
});
vi.mocked(ui.confirm).mockResolvedValue(true);
vi.mocked(refreshWorkOSSkills).mockResolvedValue({
agents: [claudeAgent as any],
skills: ['workos'],
version: '2.0.0',
perAgentBefore: {},
perAgentAfter: {},
});
}

it('reports a partial refresh as incomplete rather than a success', async () => {
partialRefresh();

await maybeOfferSkillsUpdate('organization.list');

expect(ui.log.success).not.toHaveBeenCalled();
const warning = vi.mocked(ui.log.warn).mock.calls[0][0];
expect(warning).toContain('Claude Code');
expect(warning).toContain('Cursor');
expect(warning).toContain('workos skills install');
});

it('leaves a partial refresh eligible for exactly one more offer', async () => {
partialRefresh();

await maybeOfferSkillsUpdate('organization.list');

// The version is recorded up front so a throwing refresh can't nag forever,
// then re-opened by the retry marker because some agents did land.
expect(prefs.recordSkillsUpdateOffered).toHaveBeenCalledWith('2.0.0');
expect(prefs.recordSkillsUpdateRetry).toHaveBeenCalledWith('2.0.0');
});

it('goes quiet after a second incomplete refresh for the same version', async () => {
partialRefresh();
vi.mocked(prefs.hasSkillsUpdateRetried).mockReturnValue(true);

await maybeOfferSkillsUpdate('organization.list');

expect(ui.log.warn).toHaveBeenCalled();
// Retry already spent: the recorded version stands, so only the explicit
// `workos skills install` remains.
expect(prefs.recordSkillsUpdateRetry).not.toHaveBeenCalled();
expect(prefs.recordSkillsUpdateOffered).toHaveBeenCalledWith('2.0.0');
});

it('points at the retry command when nothing could be refreshed', async () => {
staleClaude();
vi.mocked(ui.confirm).mockResolvedValue(true);
vi.mocked(refreshWorkOSSkills).mockResolvedValue(null);

await maybeOfferSkillsUpdate('organization.list');

expect(ui.log.success).not.toHaveBeenCalled();
expect(ui.log.error).toHaveBeenCalledWith(expect.stringContaining('workos skills install'));
// Nothing landed, so nothing suggests a retry would fare better: remembered,
// not re-offered.
expect(prefs.recordSkillsUpdateOffered).toHaveBeenCalledWith('2.0.0');
expect(prefs.recordSkillsUpdateRetry).not.toHaveBeenCalled();
});

it('never throws; failures are reported to telemetry', async () => {
vi.mocked(checkSkills).mockRejectedValue(new Error('boom'));

await expect(maybeOfferSkillsUpdate('organization.list')).resolves.toBeUndefined();

expect(analytics.captureException).toHaveBeenCalledWith(expect.any(Error), { 'setup.trigger': 'skills-update' });
});
});
90 changes: 90 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ import {
recordSetupDeclined,
recordSetupCompleted,
clearSetupDecline,
getSkillsUpdateOfferedVersion,
hasSkillsUpdateRetried,
recordSkillsUpdateOffered,
recordSkillsUpdateRetry,
} from '../lib/preferences.js';
import { createAgents, detectAgents, refreshWorkOSSkills, type AgentConfig } from './install-skill.js';
import { checkSkills } from '../doctor/checks/skills.js';
import {
detectMcpClients,
MCP_AGENT_KEYS,
Expand Down Expand Up @@ -334,3 +339,88 @@ export async function maybeRunSetupAfter(trigger: 'login' | 'install'): Promise<
});
}
}

/**
* Commands after which the stale-skills prompt is never shown: the bare root
* (`--help` / `--version`) and the commands that manage or report on skills.
*/
const SKILLS_UPDATE_EXEMPT_COMMANDS = new Set(['root', 'skills', 'setup', 'doctor']);

/**
* Best-effort offer to refresh CLI-installed skills that trail the version
* bundled with this binary. Runs after every successful command (see `runCli`),
* gated like the automatic setup offer: human TTY only, never after a setup
* decline. Asked at most once per bundled skills version — a "no" (or a refresh
* that landed nothing) is remembered until a newer CLI ships newer skills; a
* cancel (ctrl-c) is not, and a refresh that landed for some agents but not all
* gets exactly one more offer before going quiet. Never throws into the parent
* command.
*/
export async function maybeOfferSkillsUpdate(commandName: string): Promise<void> {
try {
if (SKILLS_UPDATE_EXEMPT_COMMANDS.has(commandName.split('.')[0])) return;
// Interaction mode is resolved from stdout/stderr, which says nothing about
// whether anyone can answer: with stdin redirected (`workos org list < /dev/null`)
// the mode is still `human`, so the offer prints, `ui.confirm` throws on the
// non-TTY stdin, and the catch below swallows it before the offered version is
// recorded — so every later command re-prints an offer nobody can answer.
// Check stdin before emitting any prompt-related output.
if (isJsonMode() || !isPromptAllowed() || !process.stdin.isTTY || isSetupDeclined()) return;

const info = await checkSkills();
const bundled = info?.bundledVersion;
const staleNames = new Set(info?.agents.filter((a) => a.stale).map((a) => a.agent));
if (!bundled || staleNames.size === 0 || getSkillsUpdateOfferedVersion() === bundled) return;

ui.log.info(
`WorkOS skills for ${[...staleNames].join(', ')} are older than the ones bundled with this CLI (${bundled}).`,
);
const answer = await ui.confirm({ message: 'Update them now?', initialValue: false });
if (isCancel(answer)) return;

if (!answer) {
recordSkillsUpdateOffered(bundled);
ui.log.hint(`Skipped. Run \`${formatWorkOSCommand('skills install')}\` to update later.`);
return;
}

// Recorded BEFORE the refresh on purpose: if the refresh throws (or the
// process dies mid-way) the version is already remembered, so a hard failure
// can never turn into an offer after every subsequent command. A recoverable
// partial miss downgrades this to retry-eligible below.
const alreadyRetried = hasSkillsUpdateRetried(bundled);
recordSkillsUpdateOffered(bundled);

const agents = Object.values(createAgents(homedir())).filter((a) => staleNames.has(a.displayName));
const result = await refreshWorkOSSkills({ agents });
// `refreshWorkOSSkills` is truthy as soon as ONE agent lands, so a truthy
// result is not the same as "all of them updated" — reporting it as a clean
// success would leave an agent silently stale.
const updated = result?.agents.map((a) => a.displayName) ?? [];
const failed = agents.map((a) => a.displayName).filter((name) => !updated.includes(name));

if (updated.length === 0) {
ui.log.error(`Couldn't update WorkOS skills. Run \`${formatWorkOSCommand('skills install')}\` to retry.`);
return;
}

if (failed.length === 0) {
ui.log.success(`Updated WorkOS skills for ${updated.join(', ')}.`);
return;
}

// Partial: some agents landed, so the refresh mechanism demonstrably works
// and the miss is worth one more automatic offer. Bounded to one — a
// permanently unwritable skills dir must not re-ask after every command —
// after which the recorded version above stands and only the explicit
// command remains.
if (!alreadyRetried) recordSkillsUpdateRetry(bundled);
ui.log.warn(
`Updated WorkOS skills for ${updated.join(', ')}, but ${failed.join(', ')} ${failed.length === 1 ? 'is' : 'are'} still on an older version. Run \`${formatWorkOSCommand('skills install')}\` to finish.`,
);
} catch (error) {
analytics.captureException(error instanceof Error ? error : new Error(String(error)), {
'setup.trigger': 'skills-update',
});
}
}
Loading