runwork 0.14.0 → 0.15.1

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.
Files changed (90) hide show
  1. package/dist/__tests__/install-scripts.test.js +33 -0
  2. package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
  3. package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
  4. package/dist/agents/__tests__/claude-code-managed-block.test.js +52 -44
  5. package/dist/agents/__tests__/detection.test.js +3 -0
  6. package/dist/agents/claude-code.d.ts +11 -5
  7. package/dist/agents/claude-code.js +50 -51
  8. package/dist/agents/cursor.d.ts +2 -15
  9. package/dist/agents/cursor.js +3 -33
  10. package/dist/agents/defaults-merge.d.ts +15 -0
  11. package/dist/agents/defaults-merge.js +19 -0
  12. package/dist/agents/detection.js +3 -0
  13. package/dist/agents/intro-skill.js +5 -3
  14. package/dist/agents/registry-data.d.ts +59 -2
  15. package/dist/agents/registry-data.js +176 -0
  16. package/dist/commands/__tests__/clone-args.test.js +8 -1
  17. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  18. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  19. package/dist/commands/clone.d.ts +33 -0
  20. package/dist/commands/clone.js +105 -10
  21. package/dist/commands/deploy.js +128 -11
  22. package/dist/commands/dev.js +72 -32
  23. package/dist/commands/doctor.js +64 -3
  24. package/dist/commands/info.d.ts +3 -0
  25. package/dist/commands/info.js +11 -0
  26. package/dist/commands/sync.js +14 -1
  27. package/dist/commands/validate.d.ts +2 -0
  28. package/dist/commands/validate.js +89 -0
  29. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  30. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  31. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  32. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  33. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  34. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  35. package/dist/deploy/deploy-state.d.ts +36 -0
  36. package/dist/deploy/deploy-state.js +63 -0
  37. package/dist/deploy/deploy-status.d.ts +21 -0
  38. package/dist/deploy/deploy-status.js +36 -0
  39. package/dist/deploy/detach.d.ts +32 -0
  40. package/dist/deploy/detach.js +71 -0
  41. package/dist/dev/__tests__/session.test.js +39 -0
  42. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  43. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  44. package/dist/dev/session.d.ts +9 -0
  45. package/dist/dev/session.js +4 -1
  46. package/dist/dev/startup-sync.d.ts +23 -0
  47. package/dist/dev/startup-sync.js +14 -0
  48. package/dist/generated/version.d.ts +1 -1
  49. package/dist/generated/version.js +1 -1
  50. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  51. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  52. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  53. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  54. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  55. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  56. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  57. package/dist/git/__tests__/repo-config.test.js +26 -0
  58. package/dist/git/classify-sync-error.d.ts +21 -0
  59. package/dist/git/classify-sync-error.js +111 -0
  60. package/dist/git/credentials.d.ts +43 -0
  61. package/dist/git/credentials.js +67 -0
  62. package/dist/git/remote.d.ts +37 -0
  63. package/dist/git/remote.js +75 -0
  64. package/dist/git/repo-config.d.ts +8 -0
  65. package/dist/git/repo-config.js +29 -0
  66. package/dist/health/__tests__/checks.test.js +2 -2
  67. package/dist/health/__tests__/fix.test.d.ts +1 -0
  68. package/dist/health/__tests__/fix.test.js +72 -0
  69. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  70. package/dist/health/__tests__/runner-filter.test.js +61 -0
  71. package/dist/health/checks.d.ts +7 -0
  72. package/dist/health/checks.js +50 -36
  73. package/dist/health/fix.d.ts +22 -0
  74. package/dist/health/fix.js +62 -0
  75. package/dist/health/runner.d.ts +7 -1
  76. package/dist/health/runner.js +40 -27
  77. package/dist/index.js +2 -0
  78. package/dist/tools/types.d.ts +5 -0
  79. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  80. package/dist/utils/__tests__/help-json.test.js +39 -0
  81. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  82. package/dist/utils/help-json.d.ts +14 -0
  83. package/dist/utils/help-json.js +4 -1
  84. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  85. package/dist/validate/__tests__/validate.test.js +42 -0
  86. package/dist/validate/freshness.d.ts +28 -0
  87. package/dist/validate/freshness.js +48 -0
  88. package/dist/validate/preview.d.ts +21 -0
  89. package/dist/validate/preview.js +39 -0
  90. package/package.json +1 -1
@@ -165,6 +165,39 @@ describe('install.sh (end-to-end)', () => {
165
165
  const defaultBinary = join(workDir, '.runwork', 'bin', 'runwork');
166
166
  expect(existsSync(defaultBinary)).toBe(true);
167
167
  });
168
+ it('persists ~/.runwork/bin to a shell profile, idempotently, on a default install', async () => {
169
+ const linkDir = join(workDir, 'link');
170
+ const exportLine = 'export PATH="$HOME/.runwork/bin:$PATH"';
171
+ const marker = '# Added by Runwork CLI installer';
172
+ const profiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map((p) => join(workDir, p));
173
+ const read = (p) => (existsSync(p) ? readFileSync(p, 'utf-8') : '');
174
+ // First install with the default install dir ($HOME/.runwork/bin) triggers
175
+ // PATH persistence.
176
+ const r1 = await runInstallSh(envFor({ RUNWORK_BIN_LINK_DIR: linkDir }));
177
+ expect(r1.status, `installer failed:\n${r1.stdout}\n${r1.stderr}`).toBe(0);
178
+ const written = profiles.filter((p) => read(p).includes(exportLine));
179
+ expect(written.length, 'expected the export line in at least one profile').toBeGreaterThan(0);
180
+ expect(profiles.some((p) => read(p).includes(marker))).toBe(true);
181
+ // Re-running must NOT append a second time (guarded on .runwork/bin).
182
+ const r2 = await runInstallSh(envFor({ RUNWORK_BIN_LINK_DIR: linkDir }));
183
+ expect(r2.status).toBe(0);
184
+ for (const p of written) {
185
+ const occurrences = read(p).split('.runwork/bin').length - 1;
186
+ expect(occurrences, `profile ${p} should reference .runwork/bin exactly once`).toBe(1);
187
+ }
188
+ });
189
+ it('does NOT touch shell profiles when a custom RUNWORK_INSTALL_DIR is used', async () => {
190
+ const result = await runInstallSh(envFor({
191
+ RUNWORK_INSTALL_DIR: join(workDir, 'install'),
192
+ RUNWORK_BIN_LINK_DIR: join(workDir, 'link'),
193
+ }));
194
+ expect(result.status).toBe(0);
195
+ const profiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map((p) => join(workDir, p));
196
+ for (const p of profiles) {
197
+ if (existsSync(p))
198
+ expect(readFileSync(p, 'utf-8')).not.toContain('.runwork/bin');
199
+ }
200
+ });
168
201
  it('aborts on sha256 mismatch and leaves the install directory untouched', async () => {
169
202
  // Tamper with the archive the server is about to serve.
170
203
  writeFileSync(archivePath, Buffer.from('tampered payload'));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getAgent, getAgentFamily, getFamilyAgents, isConnectOnlyAgent, isWebAgent, } from '../registry-data.js';
3
+ describe('ChatGPT registry entries', () => {
4
+ it('registers both Web and Desktop entries', () => {
5
+ const web = getAgent('chatgpt');
6
+ const app = getAgent('chatgpt-app');
7
+ expect(web).toBeDefined();
8
+ expect(app).toBeDefined();
9
+ expect(web?.category).toBe('web');
10
+ expect(app?.category).toBe('desktop');
11
+ expect(web?.firstClass).toBe(true);
12
+ expect(app?.firstClass).toBe(true);
13
+ });
14
+ it('treats Web entry as always-available with a URL launch', () => {
15
+ const web = getAgent('chatgpt');
16
+ expect(web?.detection).toEqual({ method: 'always' });
17
+ expect(web?.launch?.url).toContain('chatgpt.com');
18
+ expect(isWebAgent('chatgpt')).toBe(true);
19
+ expect(isWebAgent('chatgpt-app')).toBe(false);
20
+ });
21
+ it('marks both entries connect-only (no local config paths)', () => {
22
+ for (const slug of ['chatgpt', 'chatgpt-app']) {
23
+ const agent = getAgent(slug);
24
+ expect(isConnectOnlyAgent(agent)).toBe(true);
25
+ expect(agent?.skillsPaths).toBeUndefined();
26
+ expect(agent?.mcpConfigPath).toBeUndefined();
27
+ expect(agent?.instructionFile).toBeUndefined();
28
+ }
29
+ });
30
+ it('does not mark file-backed agents as connect-only', () => {
31
+ expect(isConnectOnlyAgent(getAgent('claude-code'))).toBe(false);
32
+ expect(isConnectOnlyAgent(getAgent('codex'))).toBe(false);
33
+ });
34
+ it('groups both entries under one family', () => {
35
+ expect(getAgentFamily('chatgpt')).toBe('chatgpt');
36
+ expect(getAgentFamily('chatgpt-app')).toBe('chatgpt');
37
+ const family = getFamilyAgents('chatgpt').map((a) => a.slug).sort();
38
+ expect(family).toEqual(['chatgpt', 'chatgpt-app']);
39
+ });
40
+ it('falls back to the slug as family for standalone agents', () => {
41
+ expect(getAgentFamily('claude-code')).toBe('claude-code');
42
+ expect(getFamilyAgents('claude-code').map((a) => a.slug)).toEqual(['claude-code']);
43
+ });
44
+ it('provides team/personal manual-setup variants and a copyable MCP URL', () => {
45
+ for (const slug of ['chatgpt', 'chatgpt-app']) {
46
+ const setup = getAgent(slug)?.manualSetup;
47
+ expect(setup?.showAfter).toBe('connecting');
48
+ expect(setup?.copyValue?.token).toBe('{mcpUrl}');
49
+ const keys = setup?.variants?.map((v) => v.key).sort();
50
+ expect(keys).toEqual(['personal', 'team']);
51
+ // Every variant carries at least one step.
52
+ for (const variant of setup?.variants ?? []) {
53
+ expect(variant.steps.length).toBeGreaterThan(0);
54
+ }
55
+ }
56
+ });
57
+ it('uses url-prompt resume for connect-only ChatGPT entries', () => {
58
+ expect(getAgent('chatgpt')?.resumeCapability?.mode).toBe('url-prompt');
59
+ expect(getAgent('chatgpt-app')?.resumeCapability?.mode).toBe('url-prompt');
60
+ });
61
+ });
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { mkdirSync, writeFileSync, rmSync } from 'fs';
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { ClaudeCodeAdapter } from '../claude-code.js';
6
+ const MARKER = '# runwork-managed';
6
7
  let homeDir;
7
8
  let originalHome;
8
9
  beforeEach(() => {
@@ -10,7 +11,6 @@ beforeEach(() => {
10
11
  homeDir = join(tmpdir(), `runwork-cc-managed-${Date.now()}-${Math.random().toString(36).slice(2)}`);
11
12
  mkdirSync(join(homeDir, '.claude'), { recursive: true });
12
13
  process.env.HOME = homeDir;
13
- // Reset the homedir() cache by mocking it via env (Node's os.homedir reads HOME on macOS/Linux).
14
14
  });
15
15
  afterEach(() => {
16
16
  if (originalHome)
@@ -23,75 +23,83 @@ afterEach(() => {
23
23
  catch { /* ignore */ }
24
24
  vi.restoreAllMocks();
25
25
  });
26
+ function settingsPath() {
27
+ return join(homeDir, '.claude', 'settings.json');
28
+ }
26
29
  function writeSettings(settings) {
27
- writeFileSync(join(homeDir, '.claude', 'settings.json'), JSON.stringify(settings, null, 2));
30
+ writeFileSync(settingsPath(), JSON.stringify(settings, null, 2));
31
+ }
32
+ function readSettings() {
33
+ return JSON.parse(readFileSync(settingsPath(), 'utf-8'));
28
34
  }
29
- describe('ClaudeCodeAdapter.readManagedBlock (user scope)', () => {
35
+ describe('ClaudeCodeAdapter.readManagedBlock (markerless)', () => {
30
36
  it('returns undefined when settings.json is missing', async () => {
31
37
  const adapter = new ClaudeCodeAdapter();
32
- const result = await adapter.readManagedBlock('user');
33
- expect(result).toBeUndefined();
38
+ expect(await adapter.readManagedBlock('user')).toBeUndefined();
34
39
  });
35
- it('returns empty arrays when permissions key is absent', async () => {
36
- writeSettings({ model: 'claude-sonnet-4-6' });
40
+ it('returns undefined even when a legacy marker is present (no on-disk read)', async () => {
41
+ writeSettings({ permissions: { allow: [MARKER, 'Bash(runwork *)'], deny: [] } });
37
42
  const adapter = new ClaudeCodeAdapter();
38
- const result = await adapter.readManagedBlock('user');
39
- expect(result).toEqual({ allow: [], deny: [] });
43
+ expect(await adapter.readManagedBlock('user')).toBeUndefined();
40
44
  });
41
- it('returns empty arrays when marker is absent', async () => {
42
- writeSettings({ permissions: { allow: ['Bash(npm test)'], deny: ['Bash(rm)'] } });
43
- const adapter = new ClaudeCodeAdapter();
44
- const result = await adapter.readManagedBlock('user');
45
- expect(result).toEqual({ allow: [], deny: [] });
46
- });
47
- it('returns entries after marker in allow', async () => {
45
+ });
46
+ describe('ClaudeCodeAdapter.writeAgentConfig legacy-marker migration', () => {
47
+ it('strips the legacy marker from allow and deny when injecting new rules', async () => {
48
48
  writeSettings({
49
49
  permissions: {
50
- allow: ['Bash(npm test)', '# runwork-managed', 'Bash(runwork *)', 'Bash(git status)'],
51
- deny: [],
50
+ allow: [MARKER, 'Bash(runwork *)', 'Bash(git status)'],
51
+ deny: [MARKER, 'Bash(rm -rf *)'],
52
52
  },
53
53
  });
54
54
  const adapter = new ClaudeCodeAdapter();
55
- const result = await adapter.readManagedBlock('user');
56
- expect(result?.allow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
57
- expect(result?.deny).toEqual([]);
55
+ await adapter.writeAgentConfig({ permissionRules: { allow: ['Bash(runwork *)', 'Bash(git status)'], deny: ['Bash(rm -rf *)'] } }, 'user', { allow: ['Bash(runwork *)', 'Bash(git status)'], deny: ['Bash(rm -rf *)'] });
56
+ const perms = readSettings().permissions;
57
+ expect(perms.allow).not.toContain(MARKER);
58
+ expect(perms.deny).not.toContain(MARKER);
59
+ expect(perms.allow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
60
+ expect(perms.deny).toEqual(['Bash(rm -rf *)']);
58
61
  });
59
- it('returns entries after marker in deny', async () => {
62
+ it('strips the legacy marker even when there are no new permission rules', async () => {
60
63
  writeSettings({
61
64
  permissions: {
62
- allow: [],
63
- deny: ['Bash(curl evil.com)', '# runwork-managed', 'Bash(rm -rf *)'],
65
+ allow: ['Bash(user-custom)', MARKER, 'Bash(runwork *)'],
66
+ deny: [MARKER],
64
67
  },
65
68
  });
66
69
  const adapter = new ClaudeCodeAdapter();
67
- const result = await adapter.readManagedBlock('user');
68
- expect(result?.deny).toEqual(['Bash(rm -rf *)']);
70
+ await adapter.writeAgentConfig({}, 'user');
71
+ const perms = readSettings().permissions;
72
+ expect(perms.allow).toEqual(['Bash(user-custom)', 'Bash(runwork *)']);
73
+ expect(perms.deny).toEqual([]);
69
74
  });
70
- it('returns empty when marker exists but nothing follows', async () => {
75
+ });
76
+ describe('ClaudeCodeAdapter.writeAgentConfig markerless merge', () => {
77
+ it('preserves user entries and subtracts the baseline before re-injecting', async () => {
71
78
  writeSettings({
72
79
  permissions: {
73
- allow: ['Bash(npm test)', '# runwork-managed'],
74
- deny: [],
80
+ allow: ['Bash(user-custom)', 'Bash(runwork *)', 'Bash(old-default)'],
75
81
  },
76
82
  });
77
83
  const adapter = new ClaudeCodeAdapter();
78
- const result = await adapter.readManagedBlock('user');
79
- expect(result?.allow).toEqual([]);
84
+ await adapter.writeAgentConfig({ permissionRules: { allow: ['Bash(runwork *)', 'Bash(git status)'] } }, 'user', { allow: ['Bash(runwork *)', 'Bash(old-default)'] });
85
+ // user entry kept; baseline-only entry (old-default) dropped; new managed set appended
86
+ expect(readSettings().permissions.allow).toEqual([
87
+ 'Bash(user-custom)',
88
+ 'Bash(runwork *)',
89
+ 'Bash(git status)',
90
+ ]);
80
91
  });
81
- it('returns undefined on JSON parse error', async () => {
82
- writeFileSync(join(homeDir, '.claude', 'settings.json'), '{ not valid json');
92
+ it('writes model preference and defaultMode', async () => {
83
93
  const adapter = new ClaudeCodeAdapter();
84
- const result = await adapter.readManagedBlock('user');
85
- expect(result).toBeUndefined();
94
+ await adapter.writeAgentConfig({ modelPreference: 'claude-sonnet-4-6', permissionRules: { allow: ['Bash(runwork *)'], defaultMode: 'acceptEdits' } }, 'user');
95
+ const settings = readSettings();
96
+ expect(settings.model).toBe('claude-sonnet-4-6');
97
+ expect(settings.permissions.defaultMode).toBe('acceptEdits');
98
+ expect(settings.permissions.allow).toEqual(['Bash(runwork *)']);
86
99
  });
87
- it('filters out non-string entries after marker (defensive)', async () => {
88
- writeSettings({
89
- permissions: {
90
- allow: ['# runwork-managed', 'Bash(runwork *)', 42, null, 'Bash(git status)'],
91
- },
92
- });
100
+ it('does not create an empty settings file when there is nothing to write', async () => {
93
101
  const adapter = new ClaudeCodeAdapter();
94
- const result = await adapter.readManagedBlock('user');
95
- expect(result?.allow).toEqual(['Bash(runwork *)', 'Bash(git status)']);
102
+ await adapter.writeAgentConfig({}, 'user');
103
+ expect(existsSync(settingsPath())).toBe(false);
96
104
  });
97
105
  });
@@ -83,4 +83,7 @@ describe('runAgentDetection', () => {
83
83
  ],
84
84
  })).toBe(false);
85
85
  });
86
+ it('always method reports true without any local probe (web-only agents)', async () => {
87
+ expect(await runAgentDetection({ method: 'always' })).toBe(true);
88
+ });
86
89
  });
@@ -31,11 +31,17 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
31
31
  writeBuiltInHooks(scope: 'project' | 'user'): Promise<void>;
32
32
  writeInstructionHint(hint: string, scope: 'project' | 'user'): Promise<void>;
33
33
  writeTeamInstructions(instructions: string, scope: 'project' | 'user'): Promise<void>;
34
- writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user'): Promise<void>;
35
- /** Remove the managed marker and all entries after it from a permissions array */
36
- private removeAfterMarker;
37
- /** Return entries after the managed marker for both allow and deny lists. */
38
- readManagedBlock(scope: 'project' | 'user'): Promise<{
34
+ writeAgentConfig(config: AgentConfigOverride, scope: 'project' | 'user', baseline?: {
35
+ allow?: string[];
36
+ deny?: string[];
37
+ }): Promise<void>;
38
+ /**
39
+ * Markerless adapter: removal detection relies on the `baseline`
40
+ * (state.lastInjected) passed to writeAgentConfig, exactly like Cursor, since
41
+ * the adapter no longer writes an on-disk sentinel to delimit its entries.
42
+ * Returning undefined tells the sync loop to skip on-disk removal detection.
43
+ */
44
+ readManagedBlock(_scope: 'project' | 'user'): Promise<{
39
45
  allow?: string[];
40
46
  deny?: string[];
41
47
  } | undefined>;
@@ -7,8 +7,17 @@ import { buildSkillMd } from './types.js';
7
7
  import { mergeJsonMcpServers, readJsonConfig, writeJsonConfig, removeRunworkMcpServers } from './utils/json-config.js';
8
8
  import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile } from './utils/instruction-hint.js';
9
9
  import { SESSION_START_HOOK_SCRIPT } from './session-start-hook.js';
10
+ import { mergeMarkerless } from './defaults-merge.js';
10
11
  const PLUGIN_NAME = 'runwork';
11
12
  const PLUGIN_VERSION = '1.0.0';
13
+ /**
14
+ * Legacy in-array sentinel that older CLI versions injected at the head of the
15
+ * managed permission block. Claude Code >= 2.1.x validates every allow/deny
16
+ * entry as a tool rule and rejects this with "matches no known tool" on
17
+ * startup, so the adapter is now markerless and actively strips this marker
18
+ * from any existing user's settings on their next sync.
19
+ */
20
+ const LEGACY_MANAGED_MARKER = '# runwork-managed';
12
21
  function getPluginJson() {
13
22
  return {
14
23
  name: PLUGIN_NAME,
@@ -215,18 +224,31 @@ export class ClaudeCodeAdapter {
215
224
  }
216
225
  }
217
226
  // ── Agent config (model, permissions) ──────────────────────────────
218
- async writeAgentConfig(config, scope) {
227
+ async writeAgentConfig(config, scope, baseline) {
219
228
  const settingsPath = scope === 'project'
220
229
  ? join(process.cwd(), '.claude', 'settings.json')
221
230
  : join(homedir(), '.claude', 'settings.json');
231
+ const hadFile = existsSync(settingsPath);
222
232
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
223
233
  let settings = {};
224
- if (existsSync(settingsPath)) {
234
+ if (hadFile) {
225
235
  try {
226
236
  settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
227
237
  }
228
238
  catch { /* start fresh */ }
229
239
  }
240
+ // Retro-fix migration: strip the legacy in-array sentinel from any existing
241
+ // settings, regardless of whether we inject new rules this sync. This is
242
+ // what removes the broken "# runwork-managed" entry that makes Claude Code
243
+ // >= 2.1.x print "matches no known tool" on startup.
244
+ if (settings.permissions && typeof settings.permissions === 'object') {
245
+ for (const key of ['allow', 'deny']) {
246
+ const arr = settings.permissions[key];
247
+ if (Array.isArray(arr)) {
248
+ settings.permissions[key] = arr.filter((x) => x !== LEGACY_MANAGED_MARKER);
249
+ }
250
+ }
251
+ }
230
252
  if (config.modelPreference) {
231
253
  settings.model = config.modelPreference;
232
254
  }
@@ -234,55 +256,32 @@ export class ClaudeCodeAdapter {
234
256
  if (!settings.permissions)
235
257
  settings.permissions = {};
236
258
  const perms = settings.permissions;
237
- const MANAGED_MARKER = '# runwork-managed';
259
+ // Markerless merge: subtract our previous contribution (baseline) to leave
260
+ // the user's own entries intact, then append the freshly resolved set.
238
261
  if (config.permissionRules.allow) {
239
- const cleanExisting = this.removeAfterMarker(perms.allow ?? [], MANAGED_MARKER);
240
- perms.allow = [...cleanExisting, MANAGED_MARKER, ...config.permissionRules.allow];
262
+ perms.allow = mergeMarkerless(Array.isArray(perms.allow) ? perms.allow : [], baseline?.allow ?? [], config.permissionRules.allow);
241
263
  }
242
264
  if (config.permissionRules.deny) {
243
- const cleanExisting = this.removeAfterMarker(perms.deny ?? [], MANAGED_MARKER);
244
- perms.deny = [...cleanExisting, MANAGED_MARKER, ...config.permissionRules.deny];
265
+ perms.deny = mergeMarkerless(Array.isArray(perms.deny) ? perms.deny : [], baseline?.deny ?? [], config.permissionRules.deny);
245
266
  }
246
267
  if (config.permissionRules.defaultMode) {
247
268
  perms.defaultMode = config.permissionRules.defaultMode;
248
269
  }
249
270
  }
271
+ // Nothing to persist and no file to clean up: avoid creating an empty file.
272
+ if (!hadFile && !config.modelPreference && !config.permissionRules)
273
+ return;
250
274
  mkdirSync(join(settingsPath, '..'), { recursive: true });
251
275
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
252
276
  }
253
- /** Remove the managed marker and all entries after it from a permissions array */
254
- removeAfterMarker(arr, marker) {
255
- const idx = arr.indexOf(marker);
256
- return idx >= 0 ? arr.slice(0, idx) : [...arr];
257
- }
258
- /** Return entries after the managed marker for both allow and deny lists. */
259
- async readManagedBlock(scope) {
260
- const settingsPath = scope === 'project'
261
- ? join(process.cwd(), '.claude', 'settings.json')
262
- : join(homedir(), '.claude', 'settings.json');
263
- if (!existsSync(settingsPath))
264
- return undefined;
265
- const MANAGED_MARKER = '# runwork-managed';
266
- try {
267
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
268
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
269
- const perms = settings?.permissions;
270
- if (!perms || typeof perms !== 'object')
271
- return { allow: [], deny: [] };
272
- const extractAfter = (arr) => {
273
- if (!Array.isArray(arr))
274
- return [];
275
- const idx = arr.indexOf(MANAGED_MARKER);
276
- return idx >= 0 ? arr.slice(idx + 1).filter((x) => typeof x === 'string') : [];
277
- };
278
- return {
279
- allow: extractAfter(perms.allow),
280
- deny: extractAfter(perms.deny),
281
- };
282
- }
283
- catch {
284
- return undefined;
285
- }
277
+ /**
278
+ * Markerless adapter: removal detection relies on the `baseline`
279
+ * (state.lastInjected) passed to writeAgentConfig, exactly like Cursor, since
280
+ * the adapter no longer writes an on-disk sentinel to delimit its entries.
281
+ * Returning undefined tells the sync loop to skip on-disk removal detection.
282
+ */
283
+ async readManagedBlock(_scope) {
284
+ return undefined;
286
285
  }
287
286
  // ── Cleanup (uninstall) ────────────────────────────────────────────
288
287
  async cleanup(scope, manifest) {
@@ -320,18 +319,18 @@ export class ClaudeCodeAdapter {
320
319
  if (existsSync(settingsPath)) {
321
320
  try {
322
321
  const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
323
- // Remove managed permission entries
322
+ // Remove managed permission entries. Legacy installs carry the
323
+ // in-array marker, so everything from the marker onward is ours.
324
+ // Always also drop any stray marker so uninstall never leaves the
325
+ // entry that breaks Claude Code startup.
324
326
  if (settings.permissions) {
325
- const MANAGED_MARKER = '# runwork-managed';
326
- if (settings.permissions.allow) {
327
- const idx = settings.permissions.allow.indexOf(MANAGED_MARKER);
328
- if (idx >= 0)
329
- settings.permissions.allow = settings.permissions.allow.slice(0, idx);
330
- }
331
- if (settings.permissions.deny) {
332
- const idx = settings.permissions.deny.indexOf(MANAGED_MARKER);
333
- if (idx >= 0)
334
- settings.permissions.deny = settings.permissions.deny.slice(0, idx);
327
+ for (const key of ['allow', 'deny']) {
328
+ const arr = settings.permissions[key];
329
+ if (!Array.isArray(arr))
330
+ continue;
331
+ const idx = arr.indexOf(LEGACY_MANAGED_MARKER);
332
+ settings.permissions[key] = (idx >= 0 ? arr.slice(0, idx) : arr)
333
+ .filter((x) => x !== LEGACY_MANAGED_MARKER);
335
334
  }
336
335
  }
337
336
  // Remove plugin enablement
@@ -1,19 +1,6 @@
1
1
  import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
2
- /**
3
- * Merge a new managed list into a markerless on-disk array while preserving
4
- * user-added entries.
5
- *
6
- * existing = what's currently on disk
7
- * baseline = what we wrote on the previous sync (state.lastInjected). Items
8
- * present here are assumed to be ours; subtracting them from
9
- * existing leaves entries the user added themselves.
10
- * incoming = the new managed set to inject (defaults already filtered for
11
- * opt-outs, merged with team rules)
12
- *
13
- * If baseline is empty (bootstrap or first run with this CLI version), we
14
- * assume nothing currently on disk is ours and preserve all existing entries.
15
- */
16
- export declare function mergeMarkerless(existing: string[], baseline: string[], incoming: string[]): string[];
2
+ import { mergeMarkerless } from './defaults-merge.js';
3
+ export { mergeMarkerless };
17
4
  export declare class CursorAdapter implements AgentAdapter {
18
5
  name: string;
19
6
  slug: string;
@@ -5,39 +5,9 @@ import { execFileSync } from '../utils/subprocess.js';
5
5
  import { querySqlite, openWritableSqlite } from '../utils/sqlite.js';
6
6
  import { whichBinary } from '../utils/which.js';
7
7
  import { mergeJsonMcpServers, removeRunworkMcpServers, readJsonConfig, writeJsonConfig } from './utils/json-config.js';
8
- /**
9
- * Merge a new managed list into a markerless on-disk array while preserving
10
- * user-added entries.
11
- *
12
- * existing = what's currently on disk
13
- * baseline = what we wrote on the previous sync (state.lastInjected). Items
14
- * present here are assumed to be ours; subtracting them from
15
- * existing leaves entries the user added themselves.
16
- * incoming = the new managed set to inject (defaults already filtered for
17
- * opt-outs, merged with team rules)
18
- *
19
- * If baseline is empty (bootstrap or first run with this CLI version), we
20
- * assume nothing currently on disk is ours and preserve all existing entries.
21
- */
22
- export function mergeMarkerless(existing, baseline, incoming) {
23
- const baselineSet = new Set(baseline);
24
- const userEntries = existing.filter((item) => !baselineSet.has(item));
25
- const seen = new Set();
26
- const result = [];
27
- for (const item of userEntries) {
28
- if (!seen.has(item)) {
29
- seen.add(item);
30
- result.push(item);
31
- }
32
- }
33
- for (const item of incoming) {
34
- if (!seen.has(item)) {
35
- seen.add(item);
36
- result.push(item);
37
- }
38
- }
39
- return result;
40
- }
8
+ import { mergeMarkerless } from './defaults-merge.js';
9
+ // Re-exported for backward compatibility with existing importers/tests.
10
+ export { mergeMarkerless };
41
11
  export class CursorAdapter {
42
12
  name = 'Cursor';
43
13
  slug = 'cursor';
@@ -20,6 +20,21 @@ export declare function computeApplicable(baked: string[], optOuts: string[]): s
20
20
  */
21
21
  export declare function mergeAllow(applicableDefaults: string[], teamRules: string[]): string[];
22
22
  export declare function unique(items: string[]): string[];
23
+ /**
24
+ * Merge a new managed list into a markerless on-disk array while preserving
25
+ * user-added entries. Shared by all markerless adapters (Cursor, Claude Code).
26
+ *
27
+ * existing = what's currently on disk
28
+ * baseline = what we wrote on the previous sync (state.lastInjected). Items
29
+ * present here are assumed to be ours; subtracting them from
30
+ * existing leaves entries the user added themselves.
31
+ * incoming = the new managed set to inject (defaults already filtered for
32
+ * opt-outs, merged with team rules)
33
+ *
34
+ * If baseline is empty (bootstrap or first run with this CLI version), we
35
+ * assume nothing currently on disk is ours and preserve all existing entries.
36
+ */
37
+ export declare function mergeMarkerless(existing: string[], baseline: string[], incoming: string[]): string[];
23
38
  /** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
24
39
  export declare function isBootstrap(state: AgentDefaultsState | undefined): boolean;
25
40
  export interface ResolveResult {
@@ -45,6 +45,25 @@ export function unique(items) {
45
45
  }
46
46
  return out;
47
47
  }
48
+ /**
49
+ * Merge a new managed list into a markerless on-disk array while preserving
50
+ * user-added entries. Shared by all markerless adapters (Cursor, Claude Code).
51
+ *
52
+ * existing = what's currently on disk
53
+ * baseline = what we wrote on the previous sync (state.lastInjected). Items
54
+ * present here are assumed to be ours; subtracting them from
55
+ * existing leaves entries the user added themselves.
56
+ * incoming = the new managed set to inject (defaults already filtered for
57
+ * opt-outs, merged with team rules)
58
+ *
59
+ * If baseline is empty (bootstrap or first run with this CLI version), we
60
+ * assume nothing currently on disk is ours and preserve all existing entries.
61
+ */
62
+ export function mergeMarkerless(existing, baseline, incoming) {
63
+ const baselineSet = new Set(baseline);
64
+ const userEntries = existing.filter((item) => !baselineSet.has(item));
65
+ return unique([...userEntries, ...incoming]);
66
+ }
48
67
  /** True if no per-agent state has ever been recorded — first sync after upgrade or fresh install. */
49
68
  export function isBootstrap(state) {
50
69
  return state === undefined;
@@ -85,6 +85,9 @@ export async function runAgentDetection(detection) {
85
85
  const probes = await Promise.all(detection.target.map((p) => runAgentDetection(p)));
86
86
  return probes.some(Boolean);
87
87
  }
88
+ case 'always':
89
+ // Web-only agents have no local footprint; always treated as available.
90
+ return true;
88
91
  default:
89
92
  return false;
90
93
  }
@@ -300,14 +300,14 @@ export function generateIntroSkill(ctx) {
300
300
  lines.push('| `runwork info --json` | Full workspace and app context |');
301
301
  lines.push('| `runwork init my-app` | Create a new app |');
302
302
  lines.push('| `runwork dev` | Start local development (watches files, syncs automatically) |');
303
- lines.push('| `runwork deploy` | Deploy to production |');
303
+ lines.push('| `runwork deploy` | Deploy to production (`--detach` runs in the background to survive shell timeouts; `runwork deploy --status` polls the result) |');
304
304
  lines.push('| `runwork entities list` | Browse data across apps |');
305
305
  lines.push('| `runwork entities records <name>` | Query entity records |');
306
306
  lines.push('| `runwork workflows trigger <name>` | Trigger a workflow |');
307
307
  lines.push('| `runwork integrations call <id> <method> <path>` | Call an integration API |');
308
308
  lines.push('| `runwork integrations search <query>` | Search 3,200+ available integrations |');
309
309
  lines.push('| `runwork logs` | View app logs |');
310
- lines.push('| `runwork doctor` | Check system health |');
310
+ lines.push('| `runwork doctor` | Check system health (`--fix` auto-remediates git auth/remote; `--check <names>` scopes output) |');
311
311
  lines.push('| `runwork setup` | Set up workspace connection and sync to agents |');
312
312
  lines.push('| `runwork sync` | Sync skills, MCP servers, and settings to local agents |');
313
313
  lines.push('');
@@ -323,6 +323,8 @@ export function generateIntroSkill(ctx) {
323
323
  lines.push('');
324
324
  lines.push('> By default, `runwork init` scaffolds into `~/.runwork/apps/<slug>` so every app has a predictable home. Pass `--here` to scaffold in the current directory instead.');
325
325
  lines.push('');
326
+ lines.push('> Clone/init into a normal working directory (e.g. under your home folder). Some sandboxed "outputs" mounts disallow the unlink operations git uses for pack files, which can corrupt the repo ("unable to unlink \'.git/objects/pack/...\': Operation not permitted"). If you hit that, switch to a standard path.');
327
+ lines.push('');
326
328
  lines.push('**CLI development workflow:**');
327
329
  lines.push('1. `runwork init my-app` -- scaffold the app (created at `~/.runwork/apps/my-app`)');
328
330
  lines.push('2. `cd ~/.runwork/apps/my-app` -- enter the app directory');
@@ -385,7 +387,7 @@ export function generateIntroSkill(ctx) {
385
387
  lines.push('- **Connect workspace:** `runwork login` then `runwork setup`');
386
388
  lines.push('- **Connect an integration:** Visit the workspace settings in the web dashboard at https://runwork.ai, or use `runwork integrations search <query>` to find services');
387
389
  lines.push('- **Web dashboard:** https://runwork.ai for visual management of everything');
388
- lines.push('- **Troubleshoot:** `runwork doctor` checks system health; `runwork logs` shows app output');
390
+ lines.push('- **Troubleshoot:** `runwork doctor` checks system health (`runwork doctor --fix` auto-repairs git auth and the runwork remote); `runwork logs` shows app output');
389
391
  lines.push('');
390
392
  // References
391
393
  lines.push('### References');