runwork 0.15.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.
@@ -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
  });
@@ -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;
@@ -1 +1 @@
1
- export declare const VERSION = "0.15.0";
1
+ export declare const VERSION = "0.15.1";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.15.0";
2
+ export const VERSION = "0.15.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",