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.
- package/dist/__tests__/install-scripts.test.js +33 -0
- package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
- package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
- package/dist/agents/__tests__/claude-code-managed-block.test.js +52 -44
- package/dist/agents/__tests__/detection.test.js +3 -0
- package/dist/agents/claude-code.d.ts +11 -5
- package/dist/agents/claude-code.js +50 -51
- package/dist/agents/cursor.d.ts +2 -15
- package/dist/agents/cursor.js +3 -33
- package/dist/agents/defaults-merge.d.ts +15 -0
- package/dist/agents/defaults-merge.js +19 -0
- package/dist/agents/detection.js +3 -0
- package/dist/agents/intro-skill.js +5 -3
- package/dist/agents/registry-data.d.ts +59 -2
- package/dist/agents/registry-data.js +176 -0
- package/dist/commands/__tests__/clone-args.test.js +8 -1
- package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-source-result.test.js +45 -0
- package/dist/commands/clone.d.ts +33 -0
- package/dist/commands/clone.js +105 -10
- package/dist/commands/deploy.js +128 -11
- package/dist/commands/dev.js +72 -32
- package/dist/commands/doctor.js +64 -3
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +11 -0
- package/dist/commands/sync.js +14 -1
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.js +89 -0
- package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-state.test.js +80 -0
- package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-status.test.js +41 -0
- package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
- package/dist/deploy/__tests__/detach-args.test.js +30 -0
- package/dist/deploy/deploy-state.d.ts +36 -0
- package/dist/deploy/deploy-state.js +63 -0
- package/dist/deploy/deploy-status.d.ts +21 -0
- package/dist/deploy/deploy-status.js +36 -0
- package/dist/deploy/detach.d.ts +32 -0
- package/dist/deploy/detach.js +71 -0
- package/dist/dev/__tests__/session.test.js +39 -0
- package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
- package/dist/dev/__tests__/startup-sync.test.js +32 -0
- package/dist/dev/session.d.ts +9 -0
- package/dist/dev/session.js +4 -1
- package/dist/dev/startup-sync.d.ts +23 -0
- package/dist/dev/startup-sync.js +14 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
- package/dist/git/__tests__/classify-sync-error.test.js +57 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
- package/dist/git/__tests__/repo-config.test.d.ts +1 -0
- package/dist/git/__tests__/repo-config.test.js +26 -0
- package/dist/git/classify-sync-error.d.ts +21 -0
- package/dist/git/classify-sync-error.js +111 -0
- package/dist/git/credentials.d.ts +43 -0
- package/dist/git/credentials.js +67 -0
- package/dist/git/remote.d.ts +37 -0
- package/dist/git/remote.js +75 -0
- package/dist/git/repo-config.d.ts +8 -0
- package/dist/git/repo-config.js +29 -0
- package/dist/health/__tests__/checks.test.js +2 -2
- package/dist/health/__tests__/fix.test.d.ts +1 -0
- package/dist/health/__tests__/fix.test.js +72 -0
- package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
- package/dist/health/__tests__/runner-filter.test.js +61 -0
- package/dist/health/checks.d.ts +7 -0
- package/dist/health/checks.js +50 -36
- package/dist/health/fix.d.ts +22 -0
- package/dist/health/fix.js +62 -0
- package/dist/health/runner.d.ts +7 -1
- package/dist/health/runner.js +40 -27
- package/dist/index.js +2 -0
- package/dist/tools/types.d.ts +5 -0
- package/dist/utils/__tests__/help-json.test.d.ts +1 -0
- package/dist/utils/__tests__/help-json.test.js +39 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
- package/dist/utils/help-json.d.ts +14 -0
- package/dist/utils/help-json.js +4 -1
- package/dist/validate/__tests__/validate.test.d.ts +1 -0
- package/dist/validate/__tests__/validate.test.js +42 -0
- package/dist/validate/freshness.d.ts +28 -0
- package/dist/validate/freshness.js +48 -0
- package/dist/validate/preview.d.ts +21 -0
- package/dist/validate/preview.js +39 -0
- 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(
|
|
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 (
|
|
35
|
+
describe('ClaudeCodeAdapter.readManagedBlock (markerless)', () => {
|
|
30
36
|
it('returns undefined when settings.json is missing', async () => {
|
|
31
37
|
const adapter = new ClaudeCodeAdapter();
|
|
32
|
-
|
|
33
|
-
expect(result).toBeUndefined();
|
|
38
|
+
expect(await adapter.readManagedBlock('user')).toBeUndefined();
|
|
34
39
|
});
|
|
35
|
-
it('returns
|
|
36
|
-
writeSettings({
|
|
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
|
-
|
|
39
|
-
expect(result).toEqual({ allow: [], deny: [] });
|
|
43
|
+
expect(await adapter.readManagedBlock('user')).toBeUndefined();
|
|
40
44
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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: [
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
expect(
|
|
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('
|
|
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: [
|
|
65
|
+
allow: ['Bash(user-custom)', MARKER, 'Bash(runwork *)'],
|
|
66
|
+
deny: [MARKER],
|
|
64
67
|
},
|
|
65
68
|
});
|
|
66
69
|
const adapter = new ClaudeCodeAdapter();
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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(
|
|
74
|
-
deny: [],
|
|
80
|
+
allow: ['Bash(user-custom)', 'Bash(runwork *)', 'Bash(old-default)'],
|
|
75
81
|
},
|
|
76
82
|
});
|
|
77
83
|
const adapter = new ClaudeCodeAdapter();
|
|
78
|
-
|
|
79
|
-
|
|
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('
|
|
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
|
-
|
|
85
|
-
|
|
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('
|
|
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
|
-
|
|
95
|
-
expect(
|
|
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'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
async readManagedBlock(
|
|
260
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
package/dist/agents/cursor.d.ts
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
|
|
2
|
-
|
|
3
|
-
|
|
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;
|
package/dist/agents/cursor.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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;
|
package/dist/agents/detection.js
CHANGED
|
@@ -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');
|