runwork 0.9.1 → 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.
- package/dist/agents/__tests__/codex-stats.test.js +6 -0
- package/dist/agents/__tests__/detection.test.d.ts +1 -0
- package/dist/agents/__tests__/detection.test.js +86 -0
- package/dist/agents/__tests__/graceful-degradation.test.js +6 -0
- package/dist/agents/detection.d.ts +6 -5
- package/dist/agents/detection.js +28 -26
- package/dist/agents/registry-data.d.ts +5 -31
- package/dist/agents/registry-data.js +3 -3
- package/dist/commands/__tests__/sync-redetect.test.d.ts +1 -0
- package/dist/commands/__tests__/sync-redetect.test.js +96 -0
- package/dist/commands/dev.js +78 -0
- package/dist/commands/setup.js +4 -1
- package/dist/commands/sync.d.ts +26 -0
- package/dist/commands/sync.js +56 -1
- package/dist/devtools/registry-data.d.ts +39 -0
- package/dist/devtools/registry-data.js +39 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/manifest.test.js +69 -0
- package/dist/git/auto-commit.d.ts +5 -0
- package/dist/git/auto-commit.js +15 -14
- package/dist/git/credentials.js +16 -5
- package/dist/template/manifest.js +30 -7
- package/dist/tools/types.d.ts +47 -0
- package/dist/tools/types.js +8 -0
- package/dist/types.d.ts +6 -0
- package/dist/utils/__tests__/ignore-matcher.test.d.ts +1 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +188 -0
- package/dist/utils/ignore-matcher.d.ts +38 -0
- package/dist/utils/ignore-matcher.js +116 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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>;
|
package/dist/agents/detection.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
60
|
+
return runPowerShell(script);
|
|
61
|
+
});
|
|
62
|
+
const results = await Promise.all(probes);
|
|
63
|
+
return results.some(Boolean);
|
|
61
64
|
}
|
|
62
65
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
});
|
package/dist/commands/dev.js
CHANGED
|
@@ -35,6 +35,65 @@ function readConfig() {
|
|
|
35
35
|
}
|
|
36
36
|
return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
37
37
|
}
|
|
38
|
+
// Files the CLI cannot function without and which the user did not author.
|
|
39
|
+
// `.runwork.json` is the local app identity; `blueprint.json` is the app's
|
|
40
|
+
// canonical feature definition; `.gitignore` keeps caches out of git. If a
|
|
41
|
+
// sync from the remote silently removes any of these (which has happened
|
|
42
|
+
// when a server-side agent commits with a stale index — see
|
|
43
|
+
// worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
|
|
44
|
+
// project usable and unblocks `runwork deploy`.
|
|
45
|
+
const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
|
|
46
|
+
function snapshotCriticalFiles(cwd) {
|
|
47
|
+
const snapshots = [];
|
|
48
|
+
for (const rel of CRITICAL_FILES) {
|
|
49
|
+
const abs = join(cwd, rel);
|
|
50
|
+
if (!existsSync(abs))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Best-effort: skip unreadable files.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return snapshots;
|
|
60
|
+
}
|
|
61
|
+
function restoreMissingCriticalFiles(cwd, snapshots) {
|
|
62
|
+
const restored = [];
|
|
63
|
+
for (const snap of snapshots) {
|
|
64
|
+
const abs = join(cwd, snap.path);
|
|
65
|
+
if (existsSync(abs))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(abs, snap.contents, 'utf-8');
|
|
69
|
+
restored.push(snap.path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Best-effort: skip files we cannot write back.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return restored;
|
|
76
|
+
}
|
|
77
|
+
function commitAndPushRestoredFiles(cwd, files) {
|
|
78
|
+
if (files.length === 0)
|
|
79
|
+
return false;
|
|
80
|
+
try {
|
|
81
|
+
execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
|
|
82
|
+
execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Push failures are non-fatal: the local copy is restored, and the
|
|
93
|
+
// restoration commit will be pushed with the next auto-sync cycle.
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
38
97
|
export async function execDev(options) {
|
|
39
98
|
const useJson = options?.json ?? false;
|
|
40
99
|
const config = readConfig();
|
|
@@ -108,7 +167,26 @@ export async function execDev(options) {
|
|
|
108
167
|
// Sync
|
|
109
168
|
if (!useJson)
|
|
110
169
|
console.log(dim('Syncing...'));
|
|
170
|
+
const criticalSnapshot = snapshotCriticalFiles(cwd);
|
|
111
171
|
const syncResult = syncWithRemote(cwd);
|
|
172
|
+
const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
|
|
173
|
+
if (restoredCriticalFiles.length > 0) {
|
|
174
|
+
const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
|
|
175
|
+
if (useJson) {
|
|
176
|
+
jsonLine({
|
|
177
|
+
event: 'sync_restored_critical_files',
|
|
178
|
+
files: restoredCriticalFiles,
|
|
179
|
+
pushed,
|
|
180
|
+
timestamp: ts(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
|
|
185
|
+
if (!pushed) {
|
|
186
|
+
console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
112
190
|
if (useJson) {
|
|
113
191
|
jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
|
|
114
192
|
if (syncResult.status === 'sync-failed') {
|
package/dist/commands/setup.js
CHANGED
|
@@ -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) {
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -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;
|