runwork 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,12 @@ vi.mock('os', () => ({
13
13
  }));
14
14
  vi.mock('child_process', () => ({
15
15
  execFileSync: vi.fn(),
16
+ // detection.ts (transitively imported via codex.ts) wraps execFile through
17
+ // util.promisify; provide a callback-shaped stub so module load succeeds.
18
+ execFile: vi.fn((..._args) => {
19
+ const cb = _args[_args.length - 1];
20
+ cb(new Error('ENOENT'));
21
+ }),
16
22
  }));
17
23
  vi.mock('../../utils/sqlite.js', () => ({
18
24
  querySqlite: vi.fn(() => ''),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Force a Windows environment for Windows-only detection methods. The
3
+ // `runAgentDetection` function bails out off Windows for `windows-appx` and
4
+ // `windows-start-app`, so we have to pretend.
5
+ vi.mock('os', async () => {
6
+ const actual = await vi.importActual('os');
7
+ return { ...actual, platform: vi.fn(() => 'win32') };
8
+ });
9
+ const execBehaviors = [];
10
+ let defaultBehavior = { kind: 'success' };
11
+ vi.mock('child_process', () => ({
12
+ // promisify(execFile) drives this: the last argument is always the
13
+ // (err, stdout, stderr) callback regardless of whether options are passed.
14
+ execFile: (...args) => {
15
+ const cb = args[args.length - 1];
16
+ const behavior = execBehaviors.shift() ?? defaultBehavior;
17
+ const finish = (b) => {
18
+ if (b.kind === 'success')
19
+ cb(null, '', '');
20
+ else if (b.kind === 'fail')
21
+ cb(new Error('exit 1'), '', '');
22
+ };
23
+ if (behavior.kind === 'delay') {
24
+ setTimeout(() => finish({ kind: behavior.result }), behavior.ms);
25
+ }
26
+ else {
27
+ finish(behavior);
28
+ }
29
+ },
30
+ }));
31
+ vi.mock('../../utils/which.js', () => ({
32
+ whichBinary: vi.fn((name) => (name === 'present-binary' ? `/usr/bin/${name}` : null)),
33
+ }));
34
+ import { runAgentDetection } from '../detection.js';
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ execBehaviors.length = 0;
38
+ defaultBehavior = { kind: 'success' };
39
+ });
40
+ describe('runAgentDetection', () => {
41
+ it('reports binary detection via whichBinary', async () => {
42
+ expect(await runAgentDetection({ method: 'binary', target: 'present-binary' })).toBe(true);
43
+ expect(await runAgentDetection({ method: 'binary', target: 'missing-binary' })).toBe(false);
44
+ });
45
+ it('reports false when a windows-appx probe exits non-zero', async () => {
46
+ defaultBehavior = { kind: 'fail' };
47
+ expect(await runAgentDetection({ method: 'windows-appx', target: 'Some.Package' })).toBe(false);
48
+ });
49
+ it('reports true when any windows-appx probe succeeds', async () => {
50
+ // First two probes fail, third succeeds — the function should still
51
+ // return true because we union with Promise.all + .some(Boolean).
52
+ execBehaviors.push({ kind: 'fail' }, { kind: 'fail' }, { kind: 'success' });
53
+ expect(await runAgentDetection({ method: 'windows-appx', target: ['A', 'B', 'C'] })).toBe(true);
54
+ });
55
+ it('runs windows-appx probes in parallel (wall time ≈ slowest probe, not sum)', async () => {
56
+ // Each probe takes 80ms. Six probes serialized = ~480ms; parallel = ~80ms.
57
+ // Allow slack for CI noise but enough to catch a regression to sync execution.
58
+ defaultBehavior = { kind: 'delay', ms: 80, result: 'fail' };
59
+ const start = Date.now();
60
+ const result = await runAgentDetection({
61
+ method: 'windows-appx',
62
+ target: ['A', 'B', 'C', 'D', 'E', 'F'],
63
+ });
64
+ const elapsed = Date.now() - start;
65
+ expect(result).toBe(false);
66
+ expect(elapsed).toBeLessThan(300);
67
+ });
68
+ it('any combinator returns true on first hit', async () => {
69
+ expect(await runAgentDetection({
70
+ method: 'any',
71
+ target: [
72
+ { method: 'binary', target: 'missing-binary' },
73
+ { method: 'binary', target: 'present-binary' },
74
+ ],
75
+ })).toBe(true);
76
+ });
77
+ it('any combinator returns false when every probe misses', async () => {
78
+ expect(await runAgentDetection({
79
+ method: 'any',
80
+ target: [
81
+ { method: 'binary', target: 'missing-binary' },
82
+ { method: 'binary', target: 'also-missing' },
83
+ ],
84
+ })).toBe(false);
85
+ });
86
+ });
@@ -13,6 +13,12 @@ vi.mock('os', () => ({
13
13
  }));
14
14
  vi.mock('child_process', () => ({
15
15
  execFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
16
+ // detection.ts wraps execFile via util.promisify; provide the callback
17
+ // shape so the import doesn't blow up even though no test here exercises it.
18
+ execFile: vi.fn((..._args) => {
19
+ const cb = _args[_args.length - 1];
20
+ cb(new Error('ENOENT'));
21
+ }),
16
22
  }));
17
23
  vi.mock('../../utils/sqlite.js', () => ({
18
24
  querySqlite: vi.fn(() => ''),
@@ -9,9 +9,10 @@
9
9
  */
10
10
  import type { AgentDetection } from './registry-data.js';
11
11
  /**
12
- * Synchronous detection — returns true when the agent is installed according
13
- * to its registry `detection` rules. Supports every method the desktop app
14
- * understands: `binary`, `path`, `windows-appx`, `windows-start-app`, and
15
- * nested `any` combinators. Windows-only methods return false off Windows.
12
+ * Detection — returns true when the agent is installed according to its
13
+ * registry `detection` rules. Supports `binary`, `path`, `windows-appx`,
14
+ * `windows-start-app`, and nested `any` combinators. PowerShell-backed
15
+ * Windows probes run in parallel so a multi-target `any { windows-appx }`
16
+ * pays one PowerShell startup, not N.
16
17
  */
17
- export declare function runAgentDetection(detection: AgentDetection): boolean;
18
+ export declare function runAgentDetection(detection: AgentDetection): Promise<boolean>;
@@ -7,12 +7,14 @@
7
7
  * Mirrors the desktop's agent-detection.ts logic but uses Node APIs instead
8
8
  * of Tauri plugins.
9
9
  */
10
- import { execFileSync } from 'child_process';
10
+ import { execFile } from 'child_process';
11
11
  import { existsSync } from 'fs';
12
12
  import { homedir, platform } from 'os';
13
13
  import { isAbsolute, join } from 'path';
14
+ import { promisify } from 'util';
14
15
  import { whichBinary } from '../utils/which.js';
15
16
  import { resolvePlatformString } from './registry.js';
17
+ const execFileAsync = promisify(execFile);
16
18
  function isWindows() {
17
19
  return platform() === 'win32';
18
20
  }
@@ -22,9 +24,9 @@ function powershellQuote(value) {
22
24
  function toList(value) {
23
25
  return Array.isArray(value) ? value : [value];
24
26
  }
25
- function runPowerShell(script) {
27
+ async function runPowerShell(script) {
26
28
  try {
27
- execFileSync('powershell', ['-NoProfile', '-Command', script], { stdio: 'pipe' });
29
+ await execFileAsync('powershell', ['-NoProfile', '-Command', script]);
28
30
  return true;
29
31
  }
30
32
  catch {
@@ -39,33 +41,35 @@ function checkPath(target) {
39
41
  return existsSync(resolved);
40
42
  return existsSync(join(homedir(), resolved));
41
43
  }
42
- function checkWindowsAppxPackage(target) {
44
+ async function checkWindowsAppxPackage(target) {
43
45
  if (!isWindows())
44
46
  return false;
45
- for (const pkg of toList(target)) {
47
+ // Probe all candidate package names in parallel; resolve true on first hit.
48
+ const probes = toList(target).map((pkg) => {
46
49
  const script = `$p = Get-AppxPackage -Name ${powershellQuote(pkg)} -ErrorAction SilentlyContinue; if ($null -ne $p) { exit 0 } exit 1`;
47
- if (runPowerShell(script))
48
- return true;
49
- }
50
- return false;
50
+ return runPowerShell(script);
51
+ });
52
+ const results = await Promise.all(probes);
53
+ return results.some(Boolean);
51
54
  }
52
- function checkWindowsStartApp(target) {
55
+ async function checkWindowsStartApp(target) {
53
56
  if (!isWindows())
54
57
  return false;
55
- for (const pattern of toList(target)) {
58
+ const probes = toList(target).map((pattern) => {
56
59
  const script = `$a = Get-StartApps -Name ${powershellQuote(pattern)} -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $a) { exit 0 } exit 1`;
57
- if (runPowerShell(script))
58
- return true;
59
- }
60
- return false;
60
+ return runPowerShell(script);
61
+ });
62
+ const results = await Promise.all(probes);
63
+ return results.some(Boolean);
61
64
  }
62
65
  /**
63
- * Synchronous detection — returns true when the agent is installed according
64
- * to its registry `detection` rules. Supports every method the desktop app
65
- * understands: `binary`, `path`, `windows-appx`, `windows-start-app`, and
66
- * nested `any` combinators. Windows-only methods return false off Windows.
66
+ * Detection — returns true when the agent is installed according to its
67
+ * registry `detection` rules. Supports `binary`, `path`, `windows-appx`,
68
+ * `windows-start-app`, and nested `any` combinators. PowerShell-backed
69
+ * Windows probes run in parallel so a multi-target `any { windows-appx }`
70
+ * pays one PowerShell startup, not N.
67
71
  */
68
- export function runAgentDetection(detection) {
72
+ export async function runAgentDetection(detection) {
69
73
  switch (detection.method) {
70
74
  case 'binary': {
71
75
  const target = resolvePlatformString(detection.target);
@@ -77,12 +81,10 @@ export function runAgentDetection(detection) {
77
81
  return checkWindowsAppxPackage(detection.target);
78
82
  case 'windows-start-app':
79
83
  return checkWindowsStartApp(detection.target);
80
- case 'any':
81
- for (const probe of detection.target) {
82
- if (runAgentDetection(probe))
83
- return true;
84
- }
85
- return false;
84
+ case 'any': {
85
+ const probes = await Promise.all(detection.target.map((p) => runAgentDetection(p)));
86
+ return probes.some(Boolean);
87
+ }
86
88
  default:
87
89
  return false;
88
90
  }
@@ -9,28 +9,10 @@
9
9
  * imports. Node-only path resolvers live next to this file in `./registry.ts`.
10
10
  */
11
11
  export type AgentCategory = 'cli' | 'ide' | 'desktop' | 'extension';
12
- /** Platform-specific string. Plain string = same on all platforms. */
13
- export type PlatformString = string | {
14
- default?: string;
15
- macos?: string;
16
- windows?: string;
17
- linux?: string;
18
- };
19
- /**
20
- * Detection strategy for an agent. Supports simple binary/path checks plus
21
- * richer Windows-specific methods and a nested `any` combinator used by the
22
- * desktop app to detect agents that ship as both GUI app and CLI binary.
23
- */
24
- export type AgentDetection = {
25
- method: 'binary' | 'path';
26
- target: PlatformString;
27
- } | {
28
- method: 'windows-appx' | 'windows-start-app';
29
- target: string | string[];
30
- } | {
31
- method: 'any';
32
- target: AgentDetection[];
33
- };
12
+ export type { PlatformString, Detection, InstallableTool } from '../tools/types.js';
13
+ import type { InstallableTool, Detection, PlatformString } from '../tools/types.js';
14
+ /** @deprecated use `Detection` from ../tools/types — kept as alias for back-compat. */
15
+ export type AgentDetection = Detection;
34
16
  export interface AgentLaunch {
35
17
  app?: {
36
18
  macos?: string;
@@ -70,19 +52,11 @@ export interface AgentManualSetup {
70
52
  showAfter?: ManualSetupSlot;
71
53
  downloadArtifact?: AgentManualSetupArtifact;
72
54
  }
73
- export interface AgentDefinition {
74
- slug: string;
75
- name: string;
55
+ export interface AgentDefinition extends InstallableTool {
76
56
  aliases?: string[];
77
- description: string;
78
57
  category: AgentCategory;
79
- detection: AgentDetection;
80
58
  /** Desktop launch hints (GUI app name, CLI command) */
81
59
  launch?: AgentLaunch;
82
- /** Logo identifier used by the desktop UI */
83
- logo?: string;
84
- /** Public download/install URL shown in the desktop onboarding */
85
- downloadUrl?: string;
86
60
  /** Whether the CLI knows how to auto-install this agent */
87
61
  autoInstallable?: boolean;
88
62
  /** Skill file directories relative to $HOME (global) or project root (project) */
@@ -316,7 +316,7 @@ const AGENT_REGISTRY = [
316
316
  mcpConfigKey: 'mcpServers',
317
317
  },
318
318
  // === Community agents (from skillshare targets.yaml) ===
319
- { slug: 'antigravity', name: 'Antigravity', aliases: ['Antigravity (Google)'], description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, logo: 'antigravity', skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
319
+ { slug: 'antigravity', name: 'Antigravity', aliases: ['Antigravity (Google)'], description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, logo: 'antigravity', launch: { cli: 'antigravity' }, skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
320
320
  { slug: 'amp', name: 'Amp', description: 'AI coding agent by Sourcegraph', category: 'cli', detection: { method: 'binary', target: 'amp' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
321
321
  { slug: 'adal', name: 'AdaL', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'adal' }, skillsPaths: { global: '.adal/skills', project: '.adal/skills' } },
322
322
  { slug: 'astrbot', name: 'AstrBot', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'astrbot' }, skillsPaths: { global: '.astrbot/data/skills', project: 'data/skills' } },
@@ -346,7 +346,7 @@ const AGENT_REGISTRY = [
346
346
  { slug: 'neovate', name: 'Neovate', description: 'AI coding agent for Neovim', category: 'extension', detection: { method: 'binary', target: 'neovate' }, skillsPaths: { global: '.neovate/skills', project: '.neovate/skills' } },
347
347
  { slug: 'omp', name: 'Oh My Pi', aliases: ['oh-my-pi'], description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'omp' }, skillsPaths: { global: '.omp/agent/skills', project: '.omp/skills' } },
348
348
  { slug: 'openclaw', name: 'OpenClaw', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openclaw' }, skillsPaths: { global: '.openclaw/skills', project: 'skills' } },
349
- { slug: 'opencode', name: 'OpenCode', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'opencode' }, skillsPaths: { global: '.config/opencode/skills', project: '.opencode/skills' } },
349
+ { slug: 'opencode', name: 'OpenCode', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'opencode' }, launch: { cli: 'opencode' }, skillsPaths: { global: '.config/opencode/skills', project: '.opencode/skills' } },
350
350
  { slug: 'openhands', name: 'OpenHands', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openhands' }, skillsPaths: { global: '.openhands/skills', project: '.openhands/skills' } },
351
351
  { slug: 'pi', name: 'Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pi' }, skillsPaths: { global: '.pi/agent/skills', project: '.pi/skills' } },
352
352
  { slug: 'pochi', name: 'Pochi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pochi' }, skillsPaths: { global: '.pochi/skills', project: '.pochi/skills' } },
@@ -358,7 +358,7 @@ const AGENT_REGISTRY = [
358
358
  { slug: 'trae-cn', name: 'Trae CN', description: 'ByteDance AI coding IDE (China)', category: 'ide', detection: { method: 'binary', target: 'trae-cn' }, skillsPaths: { global: '.trae-cn/skills', project: '.trae/skills' } },
359
359
  { slug: 'mistral-vibe', name: 'Mistral Vibe', aliases: ['vibe'], description: "Mistral's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'vibe' }, skillsPaths: { global: '.vibe/skills', project: '.vibe/skills' } },
360
360
  { slug: 'verdent', name: 'Verdent', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'verdent' }, skillsPaths: { global: '.verdent/skills', project: '.verdent/skills' } },
361
- { slug: 'warp', name: 'Warp AI', description: 'AI-powered terminal', category: 'cli', detection: { method: 'path', target: { macos: '/Applications/Warp.app', linux: '/usr/bin/warp-terminal' } }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
361
+ { slug: 'warp', name: 'Warp Terminal', aliases: ['Warp AI'], description: 'AI-powered terminal', category: 'cli', detection: { method: 'path', target: { macos: '/Applications/Warp.app', linux: '/usr/bin/warp-terminal' } }, launch: { app: { macos: 'Warp', windows: 'Warp' } }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
362
362
  { slug: 'witsy', name: 'Witsy', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'witsy' }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
363
363
  { slug: 'xcode-claude', name: 'Xcode Claude', description: 'Claude integration for Xcode', category: 'extension', detection: { method: 'path', target: { macos: 'Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig' } }, logo: 'claude', skillsPaths: { global: 'Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/skills', project: '.claude/skills' } },
364
364
  { slug: 'xcode-codex', name: 'Xcode Codex', description: 'Codex integration for Xcode', category: 'extension', detection: { method: 'path', target: { macos: 'Library/Developer/Xcode/CodingAssistant/codex' } }, logo: 'openai', skillsPaths: { global: 'Library/Developer/Xcode/CodingAssistant/codex/skills', project: '.codex/skills' } },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('../../agents/detect.js', () => ({
3
+ detectAgents: vi.fn(),
4
+ // getAdapterBySlug is imported by sync.ts at module load; stub it so the
5
+ // import graph resolves without dragging in real adapter side-effects.
6
+ getAdapterBySlug: vi.fn(() => undefined),
7
+ }));
8
+ import { detectAgents } from '../../agents/detect.js';
9
+ import { shouldRedetect, refreshConfiguredAgents, REDETECT_TTL_MS, } from '../sync.js';
10
+ const baseOpts = {
11
+ dryRun: false,
12
+ pullOnly: false,
13
+ yes: true,
14
+ };
15
+ function makeState(overrides = {}) {
16
+ return {
17
+ workspaceId: 'ws-1',
18
+ workspaceName: 'Test',
19
+ configuredAgents: ['claude-code'],
20
+ scope: 'user',
21
+ lastSyncAt: '',
22
+ mcpServers: [],
23
+ skills: [],
24
+ skillHashes: {},
25
+ lastDetectedAt: new Date().toISOString(),
26
+ ...overrides,
27
+ };
28
+ }
29
+ function fakeAdapter(slug) {
30
+ return {
31
+ name: slug,
32
+ slug,
33
+ detect: async () => true,
34
+ writeMcpServers: async () => { },
35
+ writeSkills: async () => { },
36
+ writeInstructionHint: async () => { },
37
+ supportsSkills: () => false,
38
+ supportsMcpScope: () => false,
39
+ };
40
+ }
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ });
44
+ describe('shouldRedetect', () => {
45
+ it('returns true when --redetect is passed even on fresh state', () => {
46
+ expect(shouldRedetect(makeState(), { ...baseOpts, redetect: true })).toBe(true);
47
+ });
48
+ it('returns true when configuredAgents is empty (recovery)', () => {
49
+ expect(shouldRedetect(makeState({ configuredAgents: [] }), baseOpts)).toBe(true);
50
+ });
51
+ it('returns true when lastDetectedAt is missing (first sync)', () => {
52
+ expect(shouldRedetect(makeState({ lastDetectedAt: undefined }), baseOpts)).toBe(true);
53
+ });
54
+ it('returns true when lastDetectedAt is unparseable (corrupt state)', () => {
55
+ expect(shouldRedetect(makeState({ lastDetectedAt: 'not-a-date' }), baseOpts)).toBe(true);
56
+ });
57
+ it('returns true when lastDetectedAt is older than the TTL', () => {
58
+ const stale = new Date(Date.now() - REDETECT_TTL_MS - 1000).toISOString();
59
+ expect(shouldRedetect(makeState({ lastDetectedAt: stale }), baseOpts)).toBe(true);
60
+ });
61
+ it('returns false when lastDetectedAt is fresh (within TTL)', () => {
62
+ const fresh = new Date(Date.now() - 60_000).toISOString();
63
+ expect(shouldRedetect(makeState({ lastDetectedAt: fresh }), baseOpts)).toBe(false);
64
+ });
65
+ });
66
+ describe('refreshConfiguredAgents', () => {
67
+ it('appends newly-detected agents and returns the diff', async () => {
68
+ vi.mocked(detectAgents).mockResolvedValue([
69
+ fakeAdapter('claude-code'),
70
+ fakeAdapter('codex'),
71
+ fakeAdapter('cursor'),
72
+ ]);
73
+ const state = makeState({ configuredAgents: ['claude-code'], lastDetectedAt: undefined });
74
+ const added = await refreshConfiguredAgents(state);
75
+ expect(added.sort()).toEqual(['codex', 'cursor']);
76
+ expect(state.configuredAgents.sort()).toEqual(['claude-code', 'codex', 'cursor']);
77
+ expect(state.lastDetectedAt).toBeTruthy();
78
+ });
79
+ it('returns empty diff and stamps lastDetectedAt when nothing new is found', async () => {
80
+ vi.mocked(detectAgents).mockResolvedValue([fakeAdapter('claude-code')]);
81
+ const state = makeState({ configuredAgents: ['claude-code'], lastDetectedAt: undefined });
82
+ const added = await refreshConfiguredAgents(state);
83
+ expect(added).toEqual([]);
84
+ expect(state.configuredAgents).toEqual(['claude-code']);
85
+ expect(state.lastDetectedAt).toBeTruthy();
86
+ });
87
+ it('never removes agents that are no longer detected', async () => {
88
+ // A transient PATH glitch or temporarily-unmounted disk shouldn't wipe
89
+ // an agent the user explicitly configured. Removal is opt-in via setup.
90
+ vi.mocked(detectAgents).mockResolvedValue([fakeAdapter('claude-code')]);
91
+ const state = makeState({ configuredAgents: ['claude-code', 'codex', 'cursor'] });
92
+ const added = await refreshConfiguredAgents(state);
93
+ expect(added).toEqual([]);
94
+ expect(state.configuredAgents.sort()).toEqual(['claude-code', 'codex', 'cursor']);
95
+ });
96
+ });
@@ -110,7 +110,9 @@ export const setupCommand = new Command('setup')
110
110
  const chosen = await promptSelect('Configure for:', scopeChoices);
111
111
  scope = chosen.value;
112
112
  }
113
- // 5. Save setup state (minimal, sync populates the rest)
113
+ // 5. Save setup state (minimal, sync populates the rest).
114
+ // `lastDetectedAt` is stamped now so the immediate post-setup sync
115
+ // doesn't waste time re-running detection that just ran.
114
116
  const state = {
115
117
  workspaceId,
116
118
  workspaceName: workspaceName || '',
@@ -121,6 +123,7 @@ export const setupCommand = new Command('setup')
121
123
  mcpServers: [],
122
124
  skills: [],
123
125
  skillHashes: {},
126
+ lastDetectedAt: new Date().toISOString(),
124
127
  };
125
128
  const scopes = scope === 'both' ? ['project', 'user'] : [scope];
126
129
  for (const s of scopes) {
@@ -6,6 +6,32 @@ export interface SyncOptions {
6
6
  prefer?: 'local' | 'remote';
7
7
  yes: boolean;
8
8
  verbose?: boolean;
9
+ /**
10
+ * Force a fresh agent-installation detection pass and union newly-found
11
+ * agents into `state.configuredAgents`. Without this, sync only re-detects
12
+ * when state is empty or `lastDetectedAt` is older than REDETECT_TTL_MS.
13
+ */
14
+ redetect?: boolean;
9
15
  }
16
+ /**
17
+ * How long a detection result stays trusted before sync re-runs detection
18
+ * on its own. Detection is cheap on macOS/Linux but pays a PowerShell
19
+ * waterfall on Windows, so we cache for a day. Users who just installed
20
+ * a new agent can shortcut the wait via `--redetect` (or the desktop's
21
+ * "Rescan" button, which forwards the flag).
22
+ */
23
+ export declare const REDETECT_TTL_MS: number;
24
+ /**
25
+ * Decide whether to re-run installation detection for this sync. We always
26
+ * re-detect on explicit user request, on first sync (no timestamp yet), when
27
+ * setup ended up with zero agents (recovery), or after the TTL has lapsed.
28
+ */
29
+ export declare function shouldRedetect(state: SetupState, opts: SyncOptions): boolean;
30
+ /**
31
+ * Detect installed agents and merge any new slugs into `state.configuredAgents`.
32
+ * Never removes agents — a transient PATH glitch shouldn't wipe the setup.
33
+ * Returns the slugs that were newly added (for logging).
34
+ */
35
+ export declare function refreshConfiguredAgents(state: SetupState): Promise<string[]>;
10
36
  export declare function syncFromState(state: SetupState, statePath: string, credentials: Credentials, opts: SyncOptions): Promise<void>;
11
37
  export declare const syncCommand: Command;
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { requireAuth } from '../auth/store.js';
6
6
  import { ApiClient } from '../api/client.js';
7
- import { getAdapterBySlug } from '../agents/detect.js';
7
+ import { getAdapterBySlug, detectAgents } from '../agents/detect.js';
8
8
  import { CodexAdapter } from '../agents/codex.js';
9
9
  import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
10
10
  import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
@@ -60,6 +60,49 @@ function readLocalSkills(state) {
60
60
  }
61
61
  return results;
62
62
  }
63
+ /**
64
+ * How long a detection result stays trusted before sync re-runs detection
65
+ * on its own. Detection is cheap on macOS/Linux but pays a PowerShell
66
+ * waterfall on Windows, so we cache for a day. Users who just installed
67
+ * a new agent can shortcut the wait via `--redetect` (or the desktop's
68
+ * "Rescan" button, which forwards the flag).
69
+ */
70
+ export const REDETECT_TTL_MS = 24 * 60 * 60 * 1000;
71
+ /**
72
+ * Decide whether to re-run installation detection for this sync. We always
73
+ * re-detect on explicit user request, on first sync (no timestamp yet), when
74
+ * setup ended up with zero agents (recovery), or after the TTL has lapsed.
75
+ */
76
+ export function shouldRedetect(state, opts) {
77
+ if (opts.redetect)
78
+ return true;
79
+ if (state.configuredAgents.length === 0)
80
+ return true;
81
+ if (!state.lastDetectedAt)
82
+ return true;
83
+ const last = Date.parse(state.lastDetectedAt);
84
+ if (Number.isNaN(last))
85
+ return true;
86
+ return Date.now() - last > REDETECT_TTL_MS;
87
+ }
88
+ /**
89
+ * Detect installed agents and merge any new slugs into `state.configuredAgents`.
90
+ * Never removes agents — a transient PATH glitch shouldn't wipe the setup.
91
+ * Returns the slugs that were newly added (for logging).
92
+ */
93
+ export async function refreshConfiguredAgents(state) {
94
+ const detected = await detectAgents();
95
+ const before = new Set(state.configuredAgents);
96
+ const added = [];
97
+ for (const adapter of detected) {
98
+ if (!before.has(adapter.slug)) {
99
+ state.configuredAgents.push(adapter.slug);
100
+ added.push(adapter.slug);
101
+ }
102
+ }
103
+ state.lastDetectedAt = new Date().toISOString();
104
+ return added;
105
+ }
63
106
  export async function syncFromState(state, statePath, credentials, opts) {
64
107
  const client = new ApiClient(credentials);
65
108
  // Refresh workspace name/slug if missing or stale (e.g. setup ran before name was set)
@@ -77,6 +120,16 @@ export async function syncFromState(state, statePath, credentials, opts) {
77
120
  }
78
121
  }
79
122
  console.log(`Syncing workspace: ${state.workspaceName || state.workspaceId}`);
123
+ // Refresh the configured-agents list when the cached detection is stale,
124
+ // empty, or the user explicitly asked. This is what catches "I just installed
125
+ // Claude Code on this machine" without needing the user to re-run setup.
126
+ if (shouldRedetect(state, opts)) {
127
+ console.log(' Detecting installed agents...');
128
+ const added = await refreshConfiguredAgents(state);
129
+ if (added.length > 0) {
130
+ console.log(` Detected new agent${added.length > 1 ? 's' : ''}: ${added.join(', ')}`);
131
+ }
132
+ }
80
133
  console.log(' Fetching workspace data...');
81
134
  // Fetch latest data (includeContent=true gets all skill content in one request)
82
135
  const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations] = await Promise.all([
@@ -436,6 +489,7 @@ export const syncCommand = new Command('sync')
436
489
  .option('--pull-only', 'Only pull remote changes, never push local edits')
437
490
  .option('--prefer <side>', 'Auto-resolve conflicts: "local" or "remote" (implies --yes)')
438
491
  .option('-y, --yes', 'Non-interactive mode (default: prefer remote for conflicts)')
492
+ .option('--redetect', 'Force agent-installation re-detection (picks up agents installed since last setup)')
439
493
  .action(async (opts) => {
440
494
  const credentials = requireAuth();
441
495
  const syncOpts = {
@@ -444,6 +498,7 @@ export const syncCommand = new Command('sync')
444
498
  yes: !!opts.yes || !!opts.prefer,
445
499
  prefer: opts.prefer,
446
500
  verbose: !!opts.verbose,
501
+ redetect: !!opts.redetect,
447
502
  };
448
503
  const projectStatePath = join(process.cwd(), '.runwork', 'setup.json');
449
504
  const userStatePath = join(homedir(), '.runwork', 'setup.json');
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Developer tool registry — non-AI prerequisites Runwork users need installed
3
+ * locally (git today, more later if needed).
4
+ *
5
+ * Extends the shared `InstallableTool` base so the desktop can detect and
6
+ * render devtools alongside AI agents using the same icon/detection plumbing.
7
+ *
8
+ * Browser-safe: no node-only imports.
9
+ */
10
+ import type { InstallableTool } from '../tools/types.js';
11
+ /**
12
+ * How the desktop should attempt to install the tool. Each platform either
13
+ * has an automated install command (run via the platform shell) or falls
14
+ * back to opening the tool's `downloadUrl` in the browser.
15
+ */
16
+ export type DevToolInstallStrategy = {
17
+ kind: 'shell';
18
+ shell: 'powershell' | 'zsh' | 'bash';
19
+ command: string;
20
+ postInstallNote?: string;
21
+ } | {
22
+ kind: 'open-url';
23
+ url: string;
24
+ };
25
+ export interface DevToolDefinition extends InstallableTool {
26
+ /** Beginner-friendly explanation shown in the install card. */
27
+ longDescription: string;
28
+ /**
29
+ * Per-platform install strategy. Missing platforms fall back to opening
30
+ * `downloadUrl` in the browser.
31
+ */
32
+ install: {
33
+ macos?: DevToolInstallStrategy;
34
+ windows?: DevToolInstallStrategy;
35
+ linux?: DevToolInstallStrategy;
36
+ };
37
+ }
38
+ export declare function getDevTools(): DevToolDefinition[];
39
+ export declare function getDevTool(slug: string): DevToolDefinition | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Developer tool registry — non-AI prerequisites Runwork users need installed
3
+ * locally (git today, more later if needed).
4
+ *
5
+ * Extends the shared `InstallableTool` base so the desktop can detect and
6
+ * render devtools alongside AI agents using the same icon/detection plumbing.
7
+ *
8
+ * Browser-safe: no node-only imports.
9
+ */
10
+ const DEV_TOOL_REGISTRY = [
11
+ {
12
+ slug: 'git',
13
+ name: 'Git',
14
+ description: 'Version control system',
15
+ longDescription: 'Git tracks changes to your code and is required for Runwork commands that sync your work with the platform (init, clone, dev, deploy).',
16
+ detection: { method: 'binary', target: 'git' },
17
+ downloadUrl: 'https://git-scm.com/downloads',
18
+ install: {
19
+ windows: {
20
+ kind: 'shell',
21
+ shell: 'powershell',
22
+ command: 'winget install --id Git.Git -e --source winget --accept-package-agreements --accept-source-agreements',
23
+ },
24
+ macos: {
25
+ kind: 'shell',
26
+ shell: 'zsh',
27
+ command: 'xcode-select --install',
28
+ postInstallNote: 'A macOS installer window has opened. Finish the install there, then click Re-detect.',
29
+ },
30
+ linux: { kind: 'open-url', url: 'https://git-scm.com/download/linux' },
31
+ },
32
+ },
33
+ ];
34
+ export function getDevTools() {
35
+ return DEV_TOOL_REGISTRY;
36
+ }
37
+ export function getDevTool(slug) {
38
+ return DEV_TOOL_REGISTRY.find((t) => t.slug === slug);
39
+ }
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.2";
1
+ export declare const VERSION = "0.9.3";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.9.2";
2
+ export const VERSION = "0.9.3";
@@ -6,11 +6,22 @@ import { getCredentials } from '../auth/store.js';
6
6
  */
7
7
  export async function configureGitCredentials(remoteUrl) {
8
8
  const origin = new URL(remoteUrl).origin;
9
- execFileSync('git', [
10
- 'config', '--global',
11
- `credential.${origin}.helper`,
12
- '!runwork git-credential-helper',
13
- ]);
9
+ try {
10
+ execFileSync('git', [
11
+ 'config', '--global',
12
+ `credential.${origin}.helper`,
13
+ '!runwork git-credential-helper',
14
+ ], { stdio: 'pipe' });
15
+ }
16
+ catch (err) {
17
+ const code = err?.code;
18
+ if (code === 'ENOENT') {
19
+ console.warn('Note: git is not installed. Skipping git credential helper setup.');
20
+ console.warn('Install git before running `runwork init`, `clone`, `dev`, or `deploy`.');
21
+ return;
22
+ }
23
+ throw err;
24
+ }
14
25
  }
15
26
  /**
16
27
  * Remove the git credential helper configuration for our remote.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared types for everything Runwork can detect and offer to install
3
+ * locally. Both AI agents and developer prerequisites (git, etc.) extend
4
+ * `InstallableTool` so the desktop can render and detect them uniformly.
5
+ *
6
+ * Browser-safe: no node-only imports.
7
+ */
8
+ /** Platform-specific string. Plain string = same on all platforms. */
9
+ export type PlatformString = string | {
10
+ default?: string;
11
+ macos?: string;
12
+ windows?: string;
13
+ linux?: string;
14
+ };
15
+ /**
16
+ * Detection strategy. Supports simple binary/path checks plus richer
17
+ * Windows-specific methods and a nested `any` combinator for tools that
18
+ * ship as both GUI app and CLI binary.
19
+ */
20
+ export type Detection = {
21
+ method: 'binary' | 'path';
22
+ target: PlatformString;
23
+ } | {
24
+ method: 'windows-appx' | 'windows-start-app';
25
+ target: string | string[];
26
+ } | {
27
+ method: 'any';
28
+ target: Detection[];
29
+ };
30
+ /**
31
+ * Common shape for anything that can be detected and installed locally.
32
+ * AI agents and developer tools both extend this.
33
+ */
34
+ export interface InstallableTool {
35
+ /** Stable identifier, kebab-case (e.g. "claude-code", "git"). */
36
+ slug: string;
37
+ /** Display name. */
38
+ name: string;
39
+ /** One-line description shown in lists. */
40
+ description: string;
41
+ /** How to determine whether this tool is installed locally. */
42
+ detection: Detection;
43
+ /** Public install/download URL — universal fallback when auto-install isn't available. */
44
+ downloadUrl?: string;
45
+ /** Logo identifier (matches an entry in the desktop's logo map). Optional. */
46
+ logo?: string;
47
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared types for everything Runwork can detect and offer to install
3
+ * locally. Both AI agents and developer prerequisites (git, etc.) extend
4
+ * `InstallableTool` so the desktop can render and detect them uniformly.
5
+ *
6
+ * Browser-safe: no node-only imports.
7
+ */
8
+ export {};
package/dist/types.d.ts CHANGED
@@ -141,6 +141,12 @@ export interface SetupState {
141
141
  }>;
142
142
  reportedAgentSlugs?: string[];
143
143
  lastHealthReportAt?: string;
144
+ /**
145
+ * ISO timestamp of the last agent-installation detection pass. Sync uses
146
+ * this with a TTL to skip the (Windows-expensive) PowerShell probe waterfall
147
+ * on every cycle while still catching newly-installed agents within a day.
148
+ */
149
+ lastDetectedAt?: string;
144
150
  }
145
151
  export interface WorkflowInfo {
146
152
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",