runwork 0.9.0 → 0.9.2

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.
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Shared agent registry data.
2
+ * CLI-side agent registry entry point.
3
3
  *
4
- * Source of truth: packages/runwork-desktop/src/lib/agent-registry.ts
5
- * When adding new agents, update both files.
6
- *
7
- * This is a data-only file -- no platform-specific helpers.
8
- * The CLI uses Node's process.platform for detection.
4
+ * The data and browser-safe types / lookups live in `./registry-data.ts` so
5
+ * that the desktop package can import them without pulling in Node-only
6
+ * modules. This file keeps the existing public surface for CLI callers by
7
+ * re-exporting everything from the data module and adding the Node-only path
8
+ * resolvers used by the generic adapter and `sync` command.
9
9
  */
10
10
  import { platform, homedir } from 'os';
11
11
  import { join } from 'path';
12
+ // Re-export types, data, and browser-safe lookups so every existing CLI
13
+ // caller keeps working unchanged (`import { getRegistryAgent, ... } from './registry.js'`).
14
+ export * from './registry-data.js';
12
15
  function getNodePlatform() {
13
16
  const p = platform();
14
17
  if (p === 'darwin')
@@ -31,189 +34,3 @@ export function resolveToAbsolute(ps, scope) {
31
34
  ? join(homedir(), resolved)
32
35
  : join(process.cwd(), resolved);
33
36
  }
34
- // ---------------------------------------------------------------------------
35
- // Registry
36
- // ---------------------------------------------------------------------------
37
- const AGENT_REGISTRY = [
38
- // === First-class agents (have custom adapters in CLI) ===
39
- {
40
- slug: 'claude-code',
41
- name: 'Claude Code',
42
- description: "Anthropic's AI coding agent for the terminal",
43
- category: 'cli',
44
- detection: { method: 'binary', target: 'claude' },
45
- skillsPaths: { global: '.claude/skills', project: '.claude/skills' },
46
- mcpConfigPath: '.claude/settings.json',
47
- instructionFile: { global: '.claude/CLAUDE.md', project: '.claude/CLAUDE.md' },
48
- mcpConfigKey: 'mcpServers',
49
- firstClass: true,
50
- },
51
- {
52
- slug: 'claude-desktop',
53
- name: 'Claude Desktop',
54
- aliases: ['Claude Desktop (Cowork)', 'Cowork'],
55
- description: 'Claude as a desktop application',
56
- category: 'desktop',
57
- detection: {
58
- method: 'path',
59
- target: {
60
- macos: 'Library/Application Support/Claude/claude_desktop_config.json',
61
- windows: 'AppData/Roaming/Claude/claude_desktop_config.json',
62
- linux: '.config/Claude/claude_desktop_config.json',
63
- },
64
- },
65
- skillsPaths: { global: '.claude/skills', project: '.claude/skills' },
66
- mcpConfigPath: {
67
- macos: 'Library/Application Support/Claude/claude_desktop_config.json',
68
- windows: 'AppData/Roaming/Claude/claude_desktop_config.json',
69
- linux: '.config/Claude/claude_desktop_config.json',
70
- },
71
- mcpConfigKey: 'mcpServers',
72
- firstClass: true,
73
- },
74
- {
75
- slug: 'cursor',
76
- name: 'Cursor',
77
- description: 'AI-powered code editor',
78
- category: 'ide',
79
- detection: { method: 'binary', target: 'cursor' },
80
- skillsPaths: { global: '.cursor/skills', project: '.cursor/skills' },
81
- mcpConfigPath: '.cursor/mcp.json',
82
- instructionFile: { project: '.cursor/rules/runwork.mdc' },
83
- mcpConfigKey: 'mcpServers',
84
- skillFormat: 'mdc',
85
- firstClass: true,
86
- },
87
- {
88
- slug: 'codex',
89
- name: 'Codex CLI',
90
- description: "OpenAI's AI coding agent for the terminal",
91
- category: 'cli',
92
- detection: { method: 'binary', target: 'codex' },
93
- skillsPaths: { global: '.codex/skills', project: '.agents/skills' },
94
- instructionFile: { global: '.codex/instructions.md', project: 'AGENTS.md' },
95
- firstClass: true,
96
- },
97
- {
98
- slug: 'windsurf',
99
- name: 'Windsurf',
100
- description: 'AI-powered code editor by Codeium',
101
- category: 'ide',
102
- detection: { method: 'binary', target: 'windsurf' },
103
- skillsPaths: { global: '.codeium/windsurf/skills', project: '.windsurf/skills' },
104
- mcpConfigPath: '.codeium/windsurf/mcp_config.json',
105
- instructionFile: { global: '.codeium/windsurf/rules/runwork.md', project: '.windsurf/rules/runwork.md' },
106
- mcpConfigKey: 'mcpServers',
107
- skillFormat: 'windsurf-md',
108
- firstClass: true,
109
- },
110
- {
111
- slug: 'gemini',
112
- name: 'Gemini CLI',
113
- description: "Google's AI coding agent for the terminal",
114
- category: 'cli',
115
- detection: { method: 'binary', target: 'gemini' },
116
- skillsPaths: { global: '.gemini/skills', project: '.gemini/skills' },
117
- mcpConfigPath: '.gemini/settings.json',
118
- instructionFile: { global: '.gemini/GEMINI.md', project: 'GEMINI.md' },
119
- mcpConfigKey: 'mcpServers',
120
- firstClass: true,
121
- },
122
- // === Additional agents with some custom handling ===
123
- {
124
- slug: 'copilot-vscode',
125
- name: 'GitHub Copilot (VS Code)',
126
- description: "GitHub's AI pair programmer in VS Code",
127
- category: 'extension',
128
- detection: { method: 'binary', target: 'code' },
129
- skillsPaths: { global: '.copilot/skills', project: '.github/skills' },
130
- mcpConfigPath: '.vscode/mcp.json',
131
- mcpConfigKey: 'servers',
132
- },
133
- {
134
- slug: 'cline',
135
- name: 'Cline',
136
- description: 'Autonomous AI coding agent for VS Code',
137
- category: 'extension',
138
- detection: { method: 'binary', target: 'cline' },
139
- skillsPaths: { global: '.agents/skills', project: '.agents/skills' },
140
- },
141
- {
142
- slug: 'copilot-cli',
143
- name: 'GitHub Copilot CLI',
144
- description: 'GitHub Copilot in the terminal',
145
- category: 'cli',
146
- detection: { method: 'binary', target: 'gh' },
147
- skillsPaths: { global: '.copilot/skills', project: '.github/skills' },
148
- mcpConfigPath: '.copilot/mcp-config.json',
149
- mcpConfigKey: 'mcpServers',
150
- },
151
- // === Community agents (from skillshare targets.yaml) ===
152
- { slug: 'antigravity', name: 'Antigravity', description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
153
- { 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' } },
154
- { slug: 'adal', name: 'AdaL', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'adal' }, skillsPaths: { global: '.adal/skills', project: '.adal/skills' } },
155
- { slug: 'astrbot', name: 'AstrBot', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'astrbot' }, skillsPaths: { global: '.astrbot/data/skills', project: 'data/skills' } },
156
- { slug: 'augment', name: 'Augment', description: 'AI coding assistant', category: 'ide', detection: { method: 'binary', target: 'augment' }, skillsPaths: { global: '.augment/skills', project: '.augment/skills' } },
157
- { slug: 'bob', name: 'Bob', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'bob' }, skillsPaths: { global: '.bob/skills', project: '.bob/skills' } },
158
- { slug: 'codebuddy', name: 'CodeBuddy', description: 'AI coding assistant', category: 'cli', detection: { method: 'binary', target: 'codebuddy' }, skillsPaths: { global: '.codebuddy/skills', project: '.codebuddy/skills' } },
159
- { slug: 'comate', name: 'Comate', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'comate' }, skillsPaths: { global: '.comate/skills', project: '.comate/skills' } },
160
- { slug: 'commandcode', name: 'Command Code', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'commandcode' }, skillsPaths: { global: '.commandcode/skills', project: '.commandcode/skills' } },
161
- { slug: 'continue', name: 'Continue', description: 'Open-source AI code assistant', category: 'extension', detection: { method: 'binary', target: 'continue' }, skillsPaths: { global: '.continue/skills', project: '.continue/skills' } },
162
- { slug: 'cortex', name: 'Cortex', description: "Snowflake's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'cortex' }, skillsPaths: { global: '.snowflake/cortex/skills', project: '.cortex/skills' } },
163
- { slug: 'crush', name: 'Crush', description: 'AI agent by Charm', category: 'cli', detection: { method: 'binary', target: 'crush' }, skillsPaths: { global: '.config/crush/skills', project: '.crush/skills' } },
164
- { slug: 'deepagents', name: 'Deep Agents', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'deepagents' }, skillsPaths: { global: '.deepagents/agent/skills', project: '.deepagents/skills' } },
165
- { slug: 'droid', name: 'Droid', description: "Factory AI's coding agent", category: 'cli', detection: { method: 'binary', target: 'droid' }, skillsPaths: { global: '.factory/skills', project: '.factory/skills' } },
166
- { slug: 'firebender', name: 'Firebender', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'firebender' }, skillsPaths: { global: '.firebender/skills', project: '.firebender/skills' } },
167
- { slug: 'goose', name: 'Goose', description: 'AI coding agent by Block', category: 'cli', detection: { method: 'binary', target: 'goose' }, skillsPaths: { global: '.config/goose/skills', project: '.goose/skills' } },
168
- { slug: 'hermes', name: 'Hermes', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'hermes' }, skillsPaths: { global: '.hermes/skills', project: '.hermes/skills' } },
169
- { slug: 'iflow', name: 'iFlow CLI', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'iflow' }, skillsPaths: { global: '.iflow/skills', project: '.iflow/skills' } },
170
- { slug: 'junie', name: 'Junie', description: 'JetBrains AI coding agent', category: 'ide', detection: { method: 'binary', target: 'junie' }, skillsPaths: { global: '.junie/skills', project: '.junie/skills' } },
171
- { slug: 'kilocode', name: 'Kilo Code', description: 'AI coding agent', category: 'extension', detection: { method: 'binary', target: 'kilocode' }, skillsPaths: { global: '.kilocode/skills', project: '.kilocode/skills' } },
172
- { slug: 'kimi', name: 'Kimi Code CLI', description: "Moonshot AI's coding agent", category: 'cli', detection: { method: 'binary', target: 'kimi' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
173
- { slug: 'kiro', name: 'Kiro', description: "AWS's AI coding agent", category: 'ide', detection: { method: 'binary', target: 'kiro' }, skillsPaths: { global: '.kiro/skills', project: '.kiro/skills' } },
174
- { slug: 'kode', name: 'Kode', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'kode' }, skillsPaths: { global: '.kode/skills', project: '.kode/skills' } },
175
- { slug: 'letta', name: 'Letta', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'letta' }, skillsPaths: { global: '.letta/skills', project: '.skills' } },
176
- { slug: 'lingma', name: 'Lingma', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'lingma' }, skillsPaths: { global: '.lingma/skills', project: '.lingma/skills' } },
177
- { slug: 'mcpjam', name: 'MCPJam', description: 'MCP-based AI agent', category: 'cli', detection: { method: 'binary', target: 'mcpjam' }, skillsPaths: { global: '.mcpjam/skills', project: '.mcpjam/skills' } },
178
- { slug: 'mux', name: 'Mux', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'mux' }, skillsPaths: { global: '.mux/skills', project: '.mux/skills' } },
179
- { slug: 'neovate', name: 'Neovate', description: 'AI coding agent for Neovim', category: 'extension', detection: { method: 'binary', target: 'neovate' }, skillsPaths: { global: '.neovate/skills', project: '.neovate/skills' } },
180
- { slug: 'omp', name: 'Oh My Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'omp' }, skillsPaths: { global: '.omp/agent/skills', project: '.omp/skills' } },
181
- { slug: 'openclaw', name: 'OpenClaw', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openclaw' }, skillsPaths: { global: '.openclaw/skills', project: 'skills' } },
182
- { 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' } },
183
- { slug: 'openhands', name: 'OpenHands', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openhands' }, skillsPaths: { global: '.openhands/skills', project: '.openhands/skills' } },
184
- { slug: 'pi', name: 'Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pi' }, skillsPaths: { global: '.pi/agent/skills', project: '.pi/skills' } },
185
- { slug: 'pochi', name: 'Pochi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pochi' }, skillsPaths: { global: '.pochi/skills', project: '.pochi/skills' } },
186
- { slug: 'purecode', name: 'PureCode AI', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'purecode' }, skillsPaths: { global: '.purecode/skills', project: '.agents/skills' } },
187
- { slug: 'qoder', name: 'Qoder', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'qoder' }, skillsPaths: { global: '.qoder/skills', project: '.qoder/skills' } },
188
- { slug: 'qwen', name: 'Qwen Code', description: "Alibaba's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'qwen' }, skillsPaths: { global: '.qwen/skills', project: '.qwen/skills' } },
189
- { slug: 'roo', name: 'Roo Code', description: 'AI coding agent', category: 'extension', detection: { method: 'binary', target: 'roo' }, skillsPaths: { global: '.roo/skills', project: '.roo/skills' } },
190
- { slug: 'trae', name: 'Trae', description: 'ByteDance AI coding IDE', category: 'ide', detection: { method: 'binary', target: 'trae' }, skillsPaths: { global: '.trae/skills', project: '.trae/skills' } },
191
- { slug: 'mistral-vibe', name: 'Mistral Vibe', description: "Mistral's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'vibe' }, skillsPaths: { global: '.vibe/skills', project: '.vibe/skills' } },
192
- { slug: 'verdent', name: 'Verdent', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'verdent' }, skillsPaths: { global: '.verdent/skills', project: '.verdent/skills' } },
193
- { 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' } },
194
- { slug: 'witsy', name: 'Witsy', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'witsy' }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
195
- { slug: 'zencoder', name: 'Zencoder', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'zencoder' }, skillsPaths: { global: '.zencoder/skills', project: '.zencoder/skills' } },
196
- { slug: 'replit', name: 'Replit', description: 'AI-powered coding platform', category: 'ide', detection: { method: 'binary', target: 'replit' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
197
- ];
198
- // ---------------------------------------------------------------------------
199
- // Index (built once)
200
- // ---------------------------------------------------------------------------
201
- const slugIndex = new Map();
202
- for (const agent of AGENT_REGISTRY) {
203
- slugIndex.set(agent.slug, agent);
204
- }
205
- // ---------------------------------------------------------------------------
206
- // Public API
207
- // ---------------------------------------------------------------------------
208
- export function getRegistryAgent(slug) {
209
- return slugIndex.get(slug);
210
- }
211
- export function getRegistryAgents() {
212
- return AGENT_REGISTRY;
213
- }
214
- export function getFirstClassSlugs() {
215
- return AGENT_REGISTRY.filter(a => a.firstClass).map(a => a.slug);
216
- }
217
- export function getCustomAdapterSlugs() {
218
- return new Set(['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'codex', 'cline', 'gemini']);
219
- }
@@ -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') {
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.0";
1
+ export declare const VERSION = "0.9.2";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.9.0";
2
+ export const VERSION = "0.9.2";
@@ -67,6 +67,47 @@ describe('generateManifest()', () => {
67
67
  expect(Object.keys(manifest.files)).toHaveLength(1);
68
68
  expect(manifest.files['a.txt']).toBeDefined();
69
69
  });
70
+ it('skips .bun-cache directory by default (regression: hung dev startup)', async () => {
71
+ // Bun's per-project cache used to be walked recursively here. When it
72
+ // grew to ~1 GB / 25k+ files, generateManifest spent minutes hashing
73
+ // every file and starved the rest of `runwork dev`. The default
74
+ // ignore-matcher now skips .bun-cache/ even without a .gitignore.
75
+ const dir = makeTempDir('bun-cache');
76
+ writeFileSync(join(dir, 'a.txt'), 'hello');
77
+ mkdirSync(join(dir, '.bun-cache'));
78
+ writeFileSync(join(dir, '.bun-cache', 'pkg.tgz'), 'tarball');
79
+ mkdirSync(join(dir, '.bun-cache', 'react@19.0.0'));
80
+ writeFileSync(join(dir, '.bun-cache', 'react@19.0.0', 'index.js'), 'r');
81
+ const manifest = await generateManifest(dir);
82
+ expect(Object.keys(manifest.files)).toEqual(['a.txt']);
83
+ });
84
+ it('honors simple .gitignore directory entries (e.g. dist/, .next/, coverage)', async () => {
85
+ const dir = makeTempDir('gitignored-dirs');
86
+ writeFileSync(join(dir, '.gitignore'), 'dist/\n.next/\ncoverage\n');
87
+ writeFileSync(join(dir, 'a.txt'), 'hello');
88
+ mkdirSync(join(dir, 'dist'));
89
+ writeFileSync(join(dir, 'dist', 'bundle.js'), 'compiled');
90
+ mkdirSync(join(dir, '.next'));
91
+ writeFileSync(join(dir, '.next', 'build.json'), '{}');
92
+ mkdirSync(join(dir, 'coverage'));
93
+ writeFileSync(join(dir, 'coverage', 'lcov.info'), 'data');
94
+ const manifest = await generateManifest(dir);
95
+ const keys = Object.keys(manifest.files).sort();
96
+ expect(keys).toEqual(['.gitignore', 'a.txt']);
97
+ });
98
+ it('falls through to defaults when .gitignore patterns are too complex', async () => {
99
+ // The walker only honors simple directory/basename entries. Globbed
100
+ // patterns are skipped, so files matching them still appear in the
101
+ // manifest. This is acceptable: the goal is to avoid the runaway
102
+ // cases (huge gitignored caches), not to be a full gitignore matcher.
103
+ const dir = makeTempDir('complex-gitignore');
104
+ writeFileSync(join(dir, '.gitignore'), '*.local\nsrc/generated/\n');
105
+ writeFileSync(join(dir, 'a.txt'), 'hello');
106
+ writeFileSync(join(dir, 'config.local'), 'should-still-appear');
107
+ const manifest = await generateManifest(dir);
108
+ expect(manifest.files['a.txt']).toBeDefined();
109
+ expect(manifest.files['config.local']).toBeDefined();
110
+ });
70
111
  it('handles files with spaces in names', async () => {
71
112
  const dir = makeTempDir('spaces');
72
113
  writeFileSync(join(dir, 'hello world.txt'), 'content');
@@ -374,4 +415,32 @@ describe('detectUserEdits() edge cases', () => {
374
415
  const edits = await detectUserEdits(dir, manifest);
375
416
  expect(edits).not.toContain('config.txt');
376
417
  });
418
+ it('does not flag tracked file whose committed content differs from template manifest', async () => {
419
+ // Reproduces the post-`runwork clone` scenario: the manifest was
420
+ // generated from the pristine template, then the AI-customized
421
+ // version was overlaid via `git fetch` + `git checkout .` and is now
422
+ // committed at HEAD with no local edits. Method 3's scan would
423
+ // false-flag every such file on every clone if it didn't skip
424
+ // tracked files.
425
+ const dir = makeTempDir('clone-overlay');
426
+ initGitRepo(dir);
427
+ // Simulate: AI-customized version is committed at HEAD.
428
+ const overlaidContent = 'AI-customized content';
429
+ writeFileSync(join(dir, 'worker/agents.ts'.replace('/', '-')), 'placeholder');
430
+ const aiFilePath = 'agents.ts';
431
+ writeFileSync(join(dir, aiFilePath), overlaidContent);
432
+ execFileSync('git', ['add', '.'], { cwd: dir });
433
+ execFileSync('git', ['commit', '-m', 'overlay'], { cwd: dir });
434
+ // Manifest captured the PRISTINE template hash (different from the
435
+ // overlaid HEAD content). detectUserEdits must not treat this as a
436
+ // user edit.
437
+ const manifest = {
438
+ version: 1,
439
+ files: {
440
+ [aiFilePath]: 'sha256:pristine_template_hash_unrelated_to_disk',
441
+ },
442
+ };
443
+ const edits = await detectUserEdits(dir, manifest);
444
+ expect(edits).not.toContain(aiFilePath);
445
+ });
377
446
  });
@@ -1,4 +1,9 @@
1
1
  import type { ApiClient } from '../api/client.js';
2
+ /**
3
+ * Basename-level ignore predicate using the static defaults only.
4
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
5
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
6
+ */
2
7
  export declare function isIgnored(filePath: string): boolean;
3
8
  export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string, callbacks?: {
4
9
  onFileChange?: (relPath: string, pendingCount: number) => void;
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { readFileSync } from 'fs';
3
3
  import { watch } from 'chokidar';
4
- import { basename, join, relative } from 'path';
4
+ import { join, relative } from 'path';
5
5
  import { dim, cyan, yellow } from '../ui/colors.js';
6
+ import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored } from '../utils/ignore-matcher.js';
6
7
  let watcher = null;
7
8
  let fastSyncTimer = null;
8
9
  let gitTimer = null;
@@ -13,9 +14,6 @@ let activeCallbacks;
13
14
  const pendingFastSync = new Map();
14
15
  // Track files the user actually touched during this session (for git)
15
16
  const changedFiles = new Set();
16
- const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
17
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
18
- const SKIP_EXTENSIONS = new Set(['.log']);
19
17
  // Binary extensions to skip in fast sync (git handles them fine)
20
18
  const BINARY_EXTENSIONS = new Set([
21
19
  '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.avif', '.svg',
@@ -24,21 +22,24 @@ const BINARY_EXTENSIONS = new Set([
24
22
  '.zip', '.tar', '.gz', '.br',
25
23
  '.pdf', '.wasm',
26
24
  ]);
25
+ const STATIC_IGNORE_SETS = defaultIgnoreSets();
26
+ /**
27
+ * Basename-level ignore predicate using the static defaults only.
28
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
29
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
30
+ */
27
31
  export function isIgnored(filePath) {
28
- const name = basename(filePath);
29
- if (SKIP_DIRS.has(name))
30
- return true;
31
- if (SKIP_FILES.has(name))
32
- return true;
33
- const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
34
- if (SKIP_EXTENSIONS.has(ext))
35
- return true;
36
- return false;
32
+ return isPathIgnored(filePath, STATIC_IGNORE_SETS);
37
33
  }
38
34
  export async function watchAndAutoCommit(directory, client, appId, callbacks) {
39
35
  activeCallbacks = callbacks;
36
+ // Build a project-scoped ignore matcher that includes simple patterns from
37
+ // the project's .gitignore in addition to the static defaults. Without
38
+ // this the watcher tries to descend into large gitignored caches such as
39
+ // `.bun-cache/` or `dist/` and bogs the CLI down on startup.
40
+ const ignoreSets = buildIgnoreSets(directory);
40
41
  watcher = watch(directory, {
41
- ignored: isIgnored,
42
+ ignored: (p) => isPathIgnored(p, ignoreSets),
42
43
  persistent: true,
43
44
  ignoreInitial: true,
44
45
  awaitWriteFinish: {
@@ -2,22 +2,26 @@ import { createHash } from 'crypto';
2
2
  import { execFileSync } from 'child_process';
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
4
4
  import { join, relative } from 'path';
5
- const SKIP_DIRS = new Set(['.git', 'node_modules', '.runwork']);
6
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
5
+ import { buildIgnoreSets } from '../utils/ignore-matcher.js';
7
6
  function sha256(data) {
8
7
  return 'sha256:' + createHash('sha256').update(data).digest('hex');
9
8
  }
10
- function walkDir(dir, base) {
9
+ function walkDir(dir, base, sets) {
11
10
  const results = [];
12
11
  const entries = readdirSync(dir, { withFileTypes: true });
13
12
  for (const entry of entries) {
14
- if (SKIP_DIRS.has(entry.name))
13
+ if (sets.dirs.has(entry.name))
15
14
  continue;
16
- if (SKIP_FILES.has(entry.name))
15
+ if (sets.files.has(entry.name))
17
16
  continue;
17
+ if (entry.isFile()) {
18
+ const dotIndex = entry.name.lastIndexOf('.');
19
+ if (dotIndex >= 0 && sets.extensions.has(entry.name.slice(dotIndex)))
20
+ continue;
21
+ }
18
22
  const fullPath = join(dir, entry.name);
19
23
  if (entry.isDirectory()) {
20
- results.push(...walkDir(fullPath, base));
24
+ results.push(...walkDir(fullPath, base, sets));
21
25
  }
22
26
  else if (entry.isFile()) {
23
27
  results.push(relative(base, fullPath));
@@ -27,7 +31,8 @@ function walkDir(dir, base) {
27
31
  }
28
32
  export async function generateManifest(dir) {
29
33
  const files = {};
30
- const allFiles = walkDir(dir, dir);
34
+ const sets = buildIgnoreSets(dir);
35
+ const allFiles = walkDir(dir, dir, sets);
31
36
  for (const relPath of allFiles) {
32
37
  const content = readFileSync(join(dir, relPath));
33
38
  files[relPath] = sha256(content);
@@ -104,9 +109,27 @@ export async function detectUserEdits(dir, manifest) {
104
109
  // 3. Scan manifest files on disk directly — catches gitignored files that
105
110
  // git ls-files --exclude-standard would miss. If a template file was added
106
111
  // to .gitignore by the user and then modified, only this check detects it.
112
+ //
113
+ // Tracked files are excluded here because their state is fully described
114
+ // by Method 1 (`git diff HEAD`). After `runwork clone`, AI-customized
115
+ // versions of template files are committed at HEAD and would otherwise be
116
+ // false-flagged on every fresh clone.
117
+ const trackedFiles = new Set();
118
+ try {
119
+ const tracked = execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files'], { cwd: dir, encoding: 'utf-8' }).trim();
120
+ for (const relPath of tracked.split('\n')) {
121
+ if (relPath)
122
+ trackedFiles.add(relPath);
123
+ }
124
+ }
125
+ catch {
126
+ // No git repo — every manifest file remains a candidate for Method 3.
127
+ }
107
128
  for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
108
129
  if (edits.includes(relPath))
109
130
  continue;
131
+ if (trackedFiles.has(relPath))
132
+ continue;
110
133
  const filePath = join(dir, relPath);
111
134
  try {
112
135
  const content = readFileSync(filePath);
@@ -0,0 +1 @@
1
+ export {};