runwork 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/__tests__/claude-desktop-memory.test.d.ts +1 -0
- package/dist/agents/__tests__/claude-desktop-memory.test.js +154 -0
- package/dist/agents/claude-desktop.d.ts +9 -1
- package/dist/agents/claude-desktop.js +71 -7
- package/dist/agents/codex.d.ts +11 -0
- package/dist/agents/codex.js +69 -1
- package/dist/agents/types.d.ts +12 -0
- package/dist/auth/store.js +12 -1
- package/dist/commands/__tests__/setup.test.d.ts +1 -0
- package/dist/commands/__tests__/setup.test.js +81 -0
- package/dist/commands/init.js +4 -0
- package/dist/commands/setup.d.ts +18 -0
- package/dist/commands/setup.js +38 -10
- package/dist/commands/sync.js +21 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/utils/which.d.ts +8 -0
- package/dist/utils/which.js +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
let mockHomeDir = '/tmp/runwork-cli-cowork-memory-tests';
|
|
6
|
+
vi.mock('os', async () => {
|
|
7
|
+
const actual = await vi.importActual('os');
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
homedir: vi.fn(() => mockHomeDir),
|
|
11
|
+
platform: vi.fn(() => 'darwin'),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
import { ClaudeDesktopAdapter, getCoworkMemoryClaudeMdPaths } from '../claude-desktop.js';
|
|
15
|
+
const SESSION_A = 'session-a';
|
|
16
|
+
const SESSION_B = 'session-b';
|
|
17
|
+
const ORG_1 = 'org-1';
|
|
18
|
+
const ORG_2 = 'org-2';
|
|
19
|
+
function coworkBase(homeDir) {
|
|
20
|
+
return join(homeDir, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
|
|
21
|
+
}
|
|
22
|
+
function makeOrg(homeDir, sessionId, orgId) {
|
|
23
|
+
const orgDir = join(coworkBase(homeDir), sessionId, orgId);
|
|
24
|
+
mkdirSync(orgDir, { recursive: true });
|
|
25
|
+
return orgDir;
|
|
26
|
+
}
|
|
27
|
+
describe('ClaudeDesktopAdapter Cowork memory/CLAUDE.md', () => {
|
|
28
|
+
let tempHomeDir;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tempHomeDir = mkdtempSync(join(tmpdir(), 'runwork-cowork-memory-'));
|
|
31
|
+
mockHomeDir = tempHomeDir;
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
rmSync(tempHomeDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
describe('getCoworkMemoryClaudeMdPaths', () => {
|
|
37
|
+
it('returns no paths when the cowork base dir does not exist', () => {
|
|
38
|
+
expect(getCoworkMemoryClaudeMdPaths()).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
it('collects one path per session/org', () => {
|
|
41
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
42
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_2);
|
|
43
|
+
makeOrg(tempHomeDir, SESSION_B, ORG_1);
|
|
44
|
+
const paths = getCoworkMemoryClaudeMdPaths();
|
|
45
|
+
expect(paths).toHaveLength(3);
|
|
46
|
+
expect(paths).toContain(join(coworkBase(tempHomeDir), SESSION_A, ORG_1, 'memory', 'CLAUDE.md'));
|
|
47
|
+
expect(paths).toContain(join(coworkBase(tempHomeDir), SESSION_A, ORG_2, 'memory', 'CLAUDE.md'));
|
|
48
|
+
expect(paths).toContain(join(coworkBase(tempHomeDir), SESSION_B, ORG_1, 'memory', 'CLAUDE.md'));
|
|
49
|
+
});
|
|
50
|
+
it('ignores hidden entries and the skills-plugin sibling directory', () => {
|
|
51
|
+
const base = coworkBase(tempHomeDir);
|
|
52
|
+
mkdirSync(join(base, '.DS_Store'), { recursive: true });
|
|
53
|
+
mkdirSync(join(base, 'skills-plugin'), { recursive: true });
|
|
54
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
55
|
+
expect(getCoworkMemoryClaudeMdPaths()).toEqual([
|
|
56
|
+
join(base, SESSION_A, ORG_1, 'memory', 'CLAUDE.md'),
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('writeInstructionHint', () => {
|
|
61
|
+
it('writes a marker-delimited hint to each org memory/CLAUDE.md', async () => {
|
|
62
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
63
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_2);
|
|
64
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
65
|
+
await adapter.writeInstructionHint('<!-- runwork:start -->\nhello from runwork\n<!-- runwork:end -->', 'user');
|
|
66
|
+
for (const orgId of [ORG_1, ORG_2]) {
|
|
67
|
+
const filePath = join(coworkBase(tempHomeDir), SESSION_A, orgId, 'memory', 'CLAUDE.md');
|
|
68
|
+
expect(existsSync(filePath)).toBe(true);
|
|
69
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
70
|
+
expect(content).toContain('<!-- runwork:start -->');
|
|
71
|
+
expect(content).toContain('hello from runwork');
|
|
72
|
+
expect(content).toContain('<!-- runwork:end -->');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
it('preserves user-authored content above and below the marker block', async () => {
|
|
76
|
+
const orgDir = makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
77
|
+
const memoryDir = join(orgDir, 'memory');
|
|
78
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
79
|
+
const filePath = join(memoryDir, 'CLAUDE.md');
|
|
80
|
+
writeFileSync(filePath, 'user-authored line 1\nuser-authored line 2\n');
|
|
81
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
82
|
+
await adapter.writeInstructionHint('<!-- runwork:start -->\nhint\n<!-- runwork:end -->', 'user');
|
|
83
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
84
|
+
expect(content).toContain('user-authored line 1');
|
|
85
|
+
expect(content).toContain('user-authored line 2');
|
|
86
|
+
expect(content).toContain('hint');
|
|
87
|
+
});
|
|
88
|
+
it('replaces an existing Runwork hint block in place', async () => {
|
|
89
|
+
const orgDir = makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
90
|
+
const memoryDir = join(orgDir, 'memory');
|
|
91
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
92
|
+
const filePath = join(memoryDir, 'CLAUDE.md');
|
|
93
|
+
writeFileSync(filePath, 'before\n<!-- runwork:start -->\nOLD HINT\n<!-- runwork:end -->\nafter\n');
|
|
94
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
95
|
+
await adapter.writeInstructionHint('<!-- runwork:start -->\nNEW HINT\n<!-- runwork:end -->', 'user');
|
|
96
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
97
|
+
expect(content).toContain('before');
|
|
98
|
+
expect(content).toContain('after');
|
|
99
|
+
expect(content).toContain('NEW HINT');
|
|
100
|
+
expect(content).not.toContain('OLD HINT');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('writeTeamInstructions', () => {
|
|
104
|
+
it('writes team instructions to each org memory/CLAUDE.md', async () => {
|
|
105
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
106
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
107
|
+
await adapter.writeTeamInstructions('Always reply in British English.', 'user');
|
|
108
|
+
const filePath = join(coworkBase(tempHomeDir), SESSION_A, ORG_1, 'memory', 'CLAUDE.md');
|
|
109
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
110
|
+
expect(content).toContain('<!-- runwork-team:start -->');
|
|
111
|
+
expect(content).toContain('Always reply in British English.');
|
|
112
|
+
expect(content).toContain('<!-- runwork-team:end -->');
|
|
113
|
+
});
|
|
114
|
+
it('coexists with a hint block in the same file', async () => {
|
|
115
|
+
makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
116
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
117
|
+
await adapter.writeInstructionHint('<!-- runwork:start -->\nhint body\n<!-- runwork:end -->', 'user');
|
|
118
|
+
await adapter.writeTeamInstructions('team body', 'user');
|
|
119
|
+
const filePath = join(coworkBase(tempHomeDir), SESSION_A, ORG_1, 'memory', 'CLAUDE.md');
|
|
120
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
121
|
+
expect(content).toContain('hint body');
|
|
122
|
+
expect(content).toContain('<!-- runwork-team:start -->');
|
|
123
|
+
expect(content).toContain('team body');
|
|
124
|
+
expect(content).toContain('<!-- runwork-team:end -->');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('cleanup', () => {
|
|
128
|
+
it('strips both marker blocks from each org memory/CLAUDE.md, preserving user content', async () => {
|
|
129
|
+
const orgDir = makeOrg(tempHomeDir, SESSION_A, ORG_1);
|
|
130
|
+
const memoryDir = join(orgDir, 'memory');
|
|
131
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
132
|
+
const filePath = join(memoryDir, 'CLAUDE.md');
|
|
133
|
+
writeFileSync(filePath, [
|
|
134
|
+
'user content top',
|
|
135
|
+
'<!-- runwork:start -->',
|
|
136
|
+
'hint body',
|
|
137
|
+
'<!-- runwork:end -->',
|
|
138
|
+
'<!-- runwork-team:start -->',
|
|
139
|
+
'team body',
|
|
140
|
+
'<!-- runwork-team:end -->',
|
|
141
|
+
'user content bottom',
|
|
142
|
+
].join('\n'));
|
|
143
|
+
const adapter = new ClaudeDesktopAdapter();
|
|
144
|
+
await adapter.cleanup('user');
|
|
145
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
146
|
+
expect(content).toContain('user content top');
|
|
147
|
+
expect(content).toContain('user content bottom');
|
|
148
|
+
expect(content).not.toContain('runwork:start');
|
|
149
|
+
expect(content).not.toContain('runwork-team:start');
|
|
150
|
+
expect(content).not.toContain('hint body');
|
|
151
|
+
expect(content).not.toContain('team body');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { AgentAdapter, AgentConfigOverride, AgentUsageStats, CleanupManifest, McpServerEntry, SkillFile } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Collect every `<session>/<org>/memory/CLAUDE.md` path under the Cowork
|
|
4
|
+
* base directory. Claude Desktop loads this file on every conversation in
|
|
5
|
+
* the matching session/org — writing here gets Runwork instructions applied
|
|
6
|
+
* foundationally, without relying on the agent invoking a skill. We target
|
|
7
|
+
* every org we find because users may flip between sessions or orgs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getCoworkMemoryClaudeMdPaths(): string[];
|
|
2
10
|
export interface RpmPluginLocation {
|
|
3
11
|
/** Full path to the rpm plugin directory, e.g. `<org>/rpm/plugin_01YBqw...` */
|
|
4
12
|
pluginPath: string;
|
|
@@ -24,7 +32,7 @@ export declare class ClaudeDesktopAdapter implements AgentAdapter {
|
|
|
24
32
|
supportsSkills(): boolean;
|
|
25
33
|
writeMcpServers(servers: McpServerEntry[], _scope: 'project' | 'user'): Promise<void>;
|
|
26
34
|
writeSkills(skills: SkillFile[], _scope: 'project' | 'user'): Promise<void>;
|
|
27
|
-
writeInstructionHint(
|
|
35
|
+
writeInstructionHint(hint: string, _scope: 'project' | 'user'): Promise<void>;
|
|
28
36
|
buildPluginZip(skills: SkillFile[], mcpServers: McpServerEntry[], teamInstructions: string | null, outputPath: string): Promise<void>;
|
|
29
37
|
writeTeamInstructions(instructions: string, _scope: 'project' | 'user'): Promise<void>;
|
|
30
38
|
writeAgentConfig(config: AgentConfigOverride, _scope: 'project' | 'user'): Promise<void>;
|
|
@@ -2,6 +2,7 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, w
|
|
|
2
2
|
import { dirname, join } from 'path';
|
|
3
3
|
import { homedir, platform, tmpdir } from 'os';
|
|
4
4
|
import { mergeJsonMcpServers, readJsonConfig, writeJsonConfig, removeRunworkMcpServers } from './utils/json-config.js';
|
|
5
|
+
import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile, } from './utils/instruction-hint.js';
|
|
5
6
|
import { createZipFromDir } from '../utils/zip.js';
|
|
6
7
|
import { writePluginMcpConfig, writePluginMetadata, writePluginSkills, writePluginTeamInstructions, writePluginTree, } from './claude-desktop-plugin-tree.js';
|
|
7
8
|
const PLUGIN_NAME = 'runwork';
|
|
@@ -68,6 +69,39 @@ function walkOrgDirs(visit) {
|
|
|
68
69
|
}
|
|
69
70
|
return null;
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Collect every `<session>/<org>/memory/CLAUDE.md` path under the Cowork
|
|
74
|
+
* base directory. Claude Desktop loads this file on every conversation in
|
|
75
|
+
* the matching session/org — writing here gets Runwork instructions applied
|
|
76
|
+
* foundationally, without relying on the agent invoking a skill. We target
|
|
77
|
+
* every org we find because users may flip between sessions or orgs.
|
|
78
|
+
*/
|
|
79
|
+
export function getCoworkMemoryClaudeMdPaths() {
|
|
80
|
+
const paths = [];
|
|
81
|
+
const baseDir = getCoworkBaseDir();
|
|
82
|
+
if (!existsSync(baseDir))
|
|
83
|
+
return paths;
|
|
84
|
+
try {
|
|
85
|
+
const sessionDirs = readdirSync(baseDir).filter(d => !d.startsWith('.') && d !== 'skills-plugin');
|
|
86
|
+
for (const sessionId of sessionDirs) {
|
|
87
|
+
const sessionPath = join(baseDir, sessionId);
|
|
88
|
+
let orgDirs;
|
|
89
|
+
try {
|
|
90
|
+
orgDirs = readdirSync(sessionPath).filter(d => !d.startsWith('.'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const orgId of orgDirs) {
|
|
96
|
+
paths.push(join(sessionPath, orgId, 'memory', 'CLAUDE.md'));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
return paths;
|
|
104
|
+
}
|
|
71
105
|
/**
|
|
72
106
|
* Find the cowork_plugins directory by discovering the session/org ID structure.
|
|
73
107
|
* Path: local-agent-mode-sessions/<session-id>/<org-id>/cowork_plugins/
|
|
@@ -241,9 +275,18 @@ export class ClaudeDesktopAdapter {
|
|
|
241
275
|
// Sync should recover a previously disabled plugin so it shows up in Customize.
|
|
242
276
|
setCoworkPluginEnabled(pluginsDir, true);
|
|
243
277
|
}
|
|
244
|
-
async writeInstructionHint(
|
|
245
|
-
//
|
|
246
|
-
//
|
|
278
|
+
async writeInstructionHint(hint, _scope) {
|
|
279
|
+
// Cowork loads `<session>/<org>/memory/CLAUDE.md` on every conversation.
|
|
280
|
+
// This is the same always-on memory surface Claude Code uses with
|
|
281
|
+
// `~/.claude/CLAUDE.md`, so it's the right home for foundational hints.
|
|
282
|
+
// Merge into marker blocks so any user-authored content above/below our
|
|
283
|
+
// block is preserved.
|
|
284
|
+
for (const path of getCoworkMemoryClaudeMdPaths()) {
|
|
285
|
+
try {
|
|
286
|
+
writeHintToFile(path, hint);
|
|
287
|
+
}
|
|
288
|
+
catch { /* best-effort per org */ }
|
|
289
|
+
}
|
|
247
290
|
}
|
|
248
291
|
async buildPluginZip(skills, mcpServers, teamInstructions, outputPath) {
|
|
249
292
|
// Materialise the plugin tree into a temp dir, zip it, then clean up.
|
|
@@ -273,10 +316,19 @@ export class ClaudeDesktopAdapter {
|
|
|
273
316
|
}
|
|
274
317
|
}
|
|
275
318
|
async writeTeamInstructions(instructions, _scope) {
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
319
|
+
// Primary surface: `<org>/memory/CLAUDE.md`. Cowork auto-loads this on
|
|
320
|
+
// every conversation, matching the Claude Code convention. Write through
|
|
321
|
+
// the team-instructions marker block so user-authored content is safe.
|
|
322
|
+
for (const path of getCoworkMemoryClaudeMdPaths()) {
|
|
323
|
+
try {
|
|
324
|
+
writeTeamInstructionsToFile(path, instructions);
|
|
325
|
+
}
|
|
326
|
+
catch { /* best-effort per org */ }
|
|
327
|
+
}
|
|
328
|
+
// Secondary surface: the plugin's `runwork-team-instructions` SKILL.md.
|
|
329
|
+
// Kept for the build-plugin zip flow and as a slash-command fallback
|
|
330
|
+
// when users inspect plugin skills. Prefer the rpm upload location when
|
|
331
|
+
// present, otherwise fall back to the legacy cowork_plugins layout.
|
|
280
332
|
const rpm = findRpmPluginByName(PLUGIN_NAME);
|
|
281
333
|
if (rpm) {
|
|
282
334
|
writePluginTeamInstructions(rpm.pluginPath, instructions);
|
|
@@ -324,6 +376,18 @@ export class ClaudeDesktopAdapter {
|
|
|
324
376
|
async cleanup(_scope, _manifest) {
|
|
325
377
|
// 1. Remove MCP entries from claude_desktop_config.json
|
|
326
378
|
removeRunworkMcpServers(getMcpConfigPath(), 'mcpServers');
|
|
379
|
+
// Remove Runwork marker blocks from each org's memory/CLAUDE.md, leaving
|
|
380
|
+
// any user-authored content in that file intact.
|
|
381
|
+
for (const path of getCoworkMemoryClaudeMdPaths()) {
|
|
382
|
+
try {
|
|
383
|
+
removeHintFromFile(path);
|
|
384
|
+
}
|
|
385
|
+
catch { /* best-effort per org */ }
|
|
386
|
+
try {
|
|
387
|
+
removeTeamInstructionsFromFile(path);
|
|
388
|
+
}
|
|
389
|
+
catch { /* best-effort per org */ }
|
|
390
|
+
}
|
|
327
391
|
// 2. Remove the uploaded rpm plugin if the user installed via
|
|
328
392
|
// Customize > Create plugin > Upload plugin. We delete both the
|
|
329
393
|
// plugin directory and the manifest entry so Claude Desktop does
|
package/dist/agents/codex.d.ts
CHANGED
|
@@ -14,4 +14,15 @@ export declare class CodexAdapter implements AgentAdapter {
|
|
|
14
14
|
cleanup(scope: 'project' | 'user', manifest?: CleanupManifest): Promise<void>;
|
|
15
15
|
readUsageStats(lastSyncAt: string | null): Promise<AgentUsageStats | null>;
|
|
16
16
|
readVersion(): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Register a workspace directory in the Codex desktop app's project list.
|
|
19
|
+
* Adds the path to electron-saved-workspace-roots, project-order, and
|
|
20
|
+
* electron-workspace-root-labels. Skips active-workspace-roots to avoid
|
|
21
|
+
* force-switching the user's active project.
|
|
22
|
+
*
|
|
23
|
+
* Returns 'written' if changes were made, 'already_registered' if the
|
|
24
|
+
* path was already present, or 'app_running' if Codex is open and would
|
|
25
|
+
* overwrite our changes.
|
|
26
|
+
*/
|
|
27
|
+
registerDesktopWorkspace(workspacePath: string, label: string): 'written' | 'already_registered' | 'app_running';
|
|
17
28
|
}
|
package/dist/agents/codex.js
CHANGED
|
@@ -10,7 +10,7 @@ function isRunworkManagedCodexKey(key) {
|
|
|
10
10
|
}
|
|
11
11
|
import { writeHintToFile, writeTeamInstructionsToFile, removeHintFromFile, removeTeamInstructionsFromFile } from './utils/instruction-hint.js';
|
|
12
12
|
import { querySqlite } from '../utils/sqlite.js';
|
|
13
|
-
import { whichBinary } from '../utils/which.js';
|
|
13
|
+
import { whichBinary, isAppRunning } from '../utils/which.js';
|
|
14
14
|
export class CodexAdapter {
|
|
15
15
|
name = 'Codex';
|
|
16
16
|
slug = 'codex';
|
|
@@ -107,6 +107,18 @@ export class CodexAdapter {
|
|
|
107
107
|
};
|
|
108
108
|
parsed.approval_policy = modeMap[config.permissionRules.defaultMode] ?? config.permissionRules.defaultMode;
|
|
109
109
|
}
|
|
110
|
+
// Apply minimum permission floors: only upgrade, never downgrade
|
|
111
|
+
if (config.minimumPermissions) {
|
|
112
|
+
for (const { field, order, minimum } of config.minimumPermissions) {
|
|
113
|
+
const current = typeof parsed[field] === 'string' ? parsed[field] : '';
|
|
114
|
+
const currentIdx = order.indexOf(current);
|
|
115
|
+
const minimumIdx = order.indexOf(minimum);
|
|
116
|
+
// Upgrade if current is unknown (not in order) or more restrictive than minimum
|
|
117
|
+
if (currentIdx < minimumIdx) {
|
|
118
|
+
parsed[field] = minimum;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
110
122
|
mkdirSync(join(configPath, '..'), { recursive: true });
|
|
111
123
|
writeFileSync(configPath, stringify(parsed));
|
|
112
124
|
}
|
|
@@ -225,4 +237,60 @@ export class CodexAdapter {
|
|
|
225
237
|
catch { /* best-effort */ }
|
|
226
238
|
return null;
|
|
227
239
|
}
|
|
240
|
+
// ── Codex Desktop app workspace registration ──────────────────────
|
|
241
|
+
/**
|
|
242
|
+
* Register a workspace directory in the Codex desktop app's project list.
|
|
243
|
+
* Adds the path to electron-saved-workspace-roots, project-order, and
|
|
244
|
+
* electron-workspace-root-labels. Skips active-workspace-roots to avoid
|
|
245
|
+
* force-switching the user's active project.
|
|
246
|
+
*
|
|
247
|
+
* Returns 'written' if changes were made, 'already_registered' if the
|
|
248
|
+
* path was already present, or 'app_running' if Codex is open and would
|
|
249
|
+
* overwrite our changes.
|
|
250
|
+
*/
|
|
251
|
+
registerDesktopWorkspace(workspacePath, label) {
|
|
252
|
+
const statePath = join(homedir(), '.codex', '.codex-global-state.json');
|
|
253
|
+
// Read current state (or start fresh if file doesn't exist)
|
|
254
|
+
let state = {};
|
|
255
|
+
if (existsSync(statePath)) {
|
|
256
|
+
try {
|
|
257
|
+
state = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return 'app_running'; // corrupt file, don't touch
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Check if already registered in all required keys
|
|
264
|
+
const savedRoots = (Array.isArray(state['electron-saved-workspace-roots'])
|
|
265
|
+
? state['electron-saved-workspace-roots'] : []);
|
|
266
|
+
const projectOrder = (Array.isArray(state['project-order'])
|
|
267
|
+
? state['project-order'] : []);
|
|
268
|
+
const labels = (state['electron-workspace-root-labels'] && typeof state['electron-workspace-root-labels'] === 'object'
|
|
269
|
+
? state['electron-workspace-root-labels'] : {});
|
|
270
|
+
const inSaved = savedRoots.includes(workspacePath);
|
|
271
|
+
const inOrder = projectOrder.includes(workspacePath);
|
|
272
|
+
const inLabels = labels[workspacePath] === label;
|
|
273
|
+
if (inSaved && inOrder && inLabels) {
|
|
274
|
+
return 'already_registered';
|
|
275
|
+
}
|
|
276
|
+
// Codex desktop app holds this file in memory and overwrites on any state
|
|
277
|
+
// change. Writing while it's open is futile. Check if it's running.
|
|
278
|
+
if (isAppRunning('Codex')) {
|
|
279
|
+
return 'app_running';
|
|
280
|
+
}
|
|
281
|
+
// Merge our workspace into the state
|
|
282
|
+
if (!inSaved) {
|
|
283
|
+
savedRoots.push(workspacePath);
|
|
284
|
+
state['electron-saved-workspace-roots'] = savedRoots;
|
|
285
|
+
}
|
|
286
|
+
if (!inOrder) {
|
|
287
|
+
projectOrder.push(workspacePath);
|
|
288
|
+
state['project-order'] = projectOrder;
|
|
289
|
+
}
|
|
290
|
+
labels[workspacePath] = label;
|
|
291
|
+
state['electron-workspace-root-labels'] = labels;
|
|
292
|
+
mkdirSync(join(statePath, '..'), { recursive: true });
|
|
293
|
+
writeFileSync(statePath, JSON.stringify(state));
|
|
294
|
+
return 'written';
|
|
295
|
+
}
|
|
228
296
|
}
|
package/dist/agents/types.d.ts
CHANGED
|
@@ -75,6 +75,18 @@ export interface AgentConfigOverride {
|
|
|
75
75
|
};
|
|
76
76
|
/** Domains to allowlist in the agent's sandbox/network config (e.g. for Cursor CLI sandbox) */
|
|
77
77
|
networkAllowlist?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Minimum permission levels required for Runwork tools to function.
|
|
80
|
+
* Adapters should only UPGRADE restrictive settings, never downgrade
|
|
81
|
+
* more permissive ones. Keys are agent-specific config field names,
|
|
82
|
+
* values are ordered arrays from most restrictive to least, plus
|
|
83
|
+
* the minimum required value.
|
|
84
|
+
*/
|
|
85
|
+
minimumPermissions?: Array<{
|
|
86
|
+
field: string;
|
|
87
|
+
order: string[];
|
|
88
|
+
minimum: string;
|
|
89
|
+
}>;
|
|
78
90
|
}
|
|
79
91
|
export interface AgentUsageStats {
|
|
80
92
|
hasNewActivity: boolean;
|
package/dist/auth/store.js
CHANGED
|
@@ -44,7 +44,18 @@ export function clearCredentials() {
|
|
|
44
44
|
export function requireAuth() {
|
|
45
45
|
const creds = getCredentials();
|
|
46
46
|
if (!creds) {
|
|
47
|
-
|
|
47
|
+
const isTTY = process.stdin.isTTY;
|
|
48
|
+
if (isTTY) {
|
|
49
|
+
console.error('Not logged in. Run `runwork login` first.');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Headless / sandbox / AI agent environment: provide actionable guidance
|
|
53
|
+
console.error('Not logged in. In a sandbox or headless environment, authenticate using one of:\n' +
|
|
54
|
+
' 1. Call the get_cli_setup MCP tool, then write the returned credentials to ~/.runwork/.credentials\n' +
|
|
55
|
+
' 2. Set the RUNWORK_API_KEY environment variable\n' +
|
|
56
|
+
' 3. Run: runwork login --api-key <your-api-key>\n' +
|
|
57
|
+
' 4. Run: runwork login --no-open --print-only (prints a URL for browser auth)');
|
|
58
|
+
}
|
|
48
59
|
process.exit(1);
|
|
49
60
|
}
|
|
50
61
|
return creds;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('../../auth/store.js', () => ({
|
|
3
|
+
getCredentials: vi.fn(),
|
|
4
|
+
saveCredentials: vi.fn(),
|
|
5
|
+
requireAuth: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { getCredentials, saveCredentials } from '../../auth/store.js';
|
|
8
|
+
import { resolveAndPersistWorkspace } from '../setup.js';
|
|
9
|
+
const mockedGetCredentials = vi.mocked(getCredentials);
|
|
10
|
+
const mockedSaveCredentials = vi.mocked(saveCredentials);
|
|
11
|
+
const baseCreds = {
|
|
12
|
+
apiKey: 'rw_test',
|
|
13
|
+
email: 'user@example.com',
|
|
14
|
+
baseUrl: 'https://runwork.ai',
|
|
15
|
+
};
|
|
16
|
+
function buildClient(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
listWorkspaces: overrides.listWorkspaces ?? vi.fn().mockResolvedValue([
|
|
19
|
+
{ id: 'ws-motaword', name: 'MotaWord', slug: 'motaword' },
|
|
20
|
+
{ id: 'ws-personal', name: 'Murat Boyraz\'s Workspace', slug: 'murat' },
|
|
21
|
+
]),
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
mockedGetCredentials.mockReturnValue(baseCreds);
|
|
28
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
29
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
30
|
+
});
|
|
31
|
+
describe('resolveAndPersistWorkspace', () => {
|
|
32
|
+
it('persists the workspace picked via --workspace flag into credentials', async () => {
|
|
33
|
+
const client = buildClient();
|
|
34
|
+
const result = await resolveAndPersistWorkspace(client, { workspace: 'ws-motaword' });
|
|
35
|
+
expect(result).toEqual({
|
|
36
|
+
workspaceId: 'ws-motaword',
|
|
37
|
+
workspaceName: 'MotaWord',
|
|
38
|
+
workspaceSlug: 'motaword',
|
|
39
|
+
});
|
|
40
|
+
expect(mockedSaveCredentials).toHaveBeenCalledTimes(1);
|
|
41
|
+
expect(mockedSaveCredentials).toHaveBeenCalledWith({
|
|
42
|
+
...baseCreds,
|
|
43
|
+
defaultWorkspaceId: 'ws-motaword',
|
|
44
|
+
defaultWorkspaceName: 'MotaWord',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it('exits with error when --workspace id is not in the user\'s accessible workspaces', async () => {
|
|
48
|
+
const client = buildClient();
|
|
49
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
50
|
+
throw new Error(`process.exit(${code})`);
|
|
51
|
+
}));
|
|
52
|
+
await expect(resolveAndPersistWorkspace(client, { workspace: 'ws-unknown' })).rejects.toThrow('process.exit(1)');
|
|
53
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
54
|
+
expect(mockedSaveCredentials).not.toHaveBeenCalled();
|
|
55
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('ws-unknown'));
|
|
56
|
+
});
|
|
57
|
+
it('does not overwrite other credential fields when persisting', async () => {
|
|
58
|
+
const richCreds = {
|
|
59
|
+
...baseCreds,
|
|
60
|
+
defaultWorkspaceId: 'ws-old',
|
|
61
|
+
defaultWorkspaceName: 'Old',
|
|
62
|
+
};
|
|
63
|
+
mockedGetCredentials.mockReturnValue(richCreds);
|
|
64
|
+
const client = buildClient();
|
|
65
|
+
await resolveAndPersistWorkspace(client, { workspace: 'ws-motaword' });
|
|
66
|
+
expect(mockedSaveCredentials).toHaveBeenCalledWith({
|
|
67
|
+
apiKey: 'rw_test',
|
|
68
|
+
email: 'user@example.com',
|
|
69
|
+
baseUrl: 'https://runwork.ai',
|
|
70
|
+
defaultWorkspaceId: 'ws-motaword',
|
|
71
|
+
defaultWorkspaceName: 'MotaWord',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it('skips persistence when no credentials are present', async () => {
|
|
75
|
+
mockedGetCredentials.mockReturnValue(null);
|
|
76
|
+
const client = buildClient();
|
|
77
|
+
const result = await resolveAndPersistWorkspace(client, { workspace: 'ws-motaword' });
|
|
78
|
+
expect(result.workspaceId).toBe('ws-motaword');
|
|
79
|
+
expect(mockedSaveCredentials).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/commands/init.js
CHANGED
|
@@ -107,6 +107,10 @@ export async function runCreateFlow(name, workspaceFlag, options = {}) {
|
|
|
107
107
|
if (workspaceFlag) {
|
|
108
108
|
workspace = await resolveWorkspace(client, workspaceFlag);
|
|
109
109
|
}
|
|
110
|
+
else if (creds.defaultWorkspaceId) {
|
|
111
|
+
// Auto-select default workspace from credentials (set during login/onboarding)
|
|
112
|
+
workspace = { id: creds.defaultWorkspaceId, name: creds.defaultWorkspaceName || creds.defaultWorkspaceId };
|
|
113
|
+
}
|
|
110
114
|
else {
|
|
111
115
|
const workspaces = await client.listWorkspaces();
|
|
112
116
|
if (workspaces.length === 0) {
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -1,2 +1,20 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
+
import { ApiClient } from '../api/client.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the workspace for `setup` and persist it as the default in credentials.
|
|
5
|
+
*
|
|
6
|
+
* `setup` is the canonical "configure this machine for this workspace" command, so
|
|
7
|
+
* the choice should stick across subsequent commands (including the desktop app,
|
|
8
|
+
* which reads `defaultWorkspaceId` to pick the initial workspace on the Workspace tab).
|
|
9
|
+
*
|
|
10
|
+
* When `--workspace <id>` is passed, the id is validated against the user's accessible
|
|
11
|
+
* workspaces and the command exits with a non-zero code if it isn't found.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveAndPersistWorkspace(client: ApiClient, opts: {
|
|
14
|
+
workspace?: string;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
workspaceName: string;
|
|
18
|
+
workspaceSlug?: string;
|
|
19
|
+
}>;
|
|
2
20
|
export declare const setupCommand: Command;
|
package/dist/commands/setup.js
CHANGED
|
@@ -2,12 +2,47 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { writeFileSync, mkdirSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
-
import { requireAuth } from '../auth/store.js';
|
|
5
|
+
import { requireAuth, getCredentials, saveCredentials } from '../auth/store.js';
|
|
6
6
|
import { ApiClient } from '../api/client.js';
|
|
7
7
|
import { resolveWorkspace, hasProjectConfig } from '../workspace/resolve.js';
|
|
8
8
|
import { promptSelect, promptConfirm } from '../utils/prompt.js';
|
|
9
9
|
import { detectAgents, printNoAgentsMessage } from '../agents/detect.js';
|
|
10
10
|
import { syncFromState } from './sync.js';
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the workspace for `setup` and persist it as the default in credentials.
|
|
13
|
+
*
|
|
14
|
+
* `setup` is the canonical "configure this machine for this workspace" command, so
|
|
15
|
+
* the choice should stick across subsequent commands (including the desktop app,
|
|
16
|
+
* which reads `defaultWorkspaceId` to pick the initial workspace on the Workspace tab).
|
|
17
|
+
*
|
|
18
|
+
* When `--workspace <id>` is passed, the id is validated against the user's accessible
|
|
19
|
+
* workspaces and the command exits with a non-zero code if it isn't found.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveAndPersistWorkspace(client, opts) {
|
|
22
|
+
const resolved = await resolveWorkspace(client, opts);
|
|
23
|
+
const { workspaceId } = resolved;
|
|
24
|
+
let allWorkspaces = [];
|
|
25
|
+
try {
|
|
26
|
+
allWorkspaces = await client.listWorkspaces();
|
|
27
|
+
}
|
|
28
|
+
catch { /* best-effort */ }
|
|
29
|
+
const workspaceMeta = allWorkspaces.find(w => w.id === workspaceId);
|
|
30
|
+
if (opts.workspace && allWorkspaces.length > 0 && !workspaceMeta) {
|
|
31
|
+
console.error(`Workspace "${opts.workspace}" not found or not accessible.`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const workspaceName = resolved.workspaceName || workspaceMeta?.name || '';
|
|
35
|
+
const workspaceSlug = workspaceMeta?.slug;
|
|
36
|
+
const freshCreds = getCredentials();
|
|
37
|
+
if (freshCreds) {
|
|
38
|
+
saveCredentials({
|
|
39
|
+
...freshCreds,
|
|
40
|
+
defaultWorkspaceId: workspaceId,
|
|
41
|
+
defaultWorkspaceName: workspaceName,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return { workspaceId, workspaceName, workspaceSlug };
|
|
45
|
+
}
|
|
11
46
|
export const setupCommand = new Command('setup')
|
|
12
47
|
.description('Configure local AI agents with workspace skills and MCP servers')
|
|
13
48
|
.option('--workspace <id>', 'Workspace ID')
|
|
@@ -17,8 +52,8 @@ export const setupCommand = new Command('setup')
|
|
|
17
52
|
.action(async (opts) => {
|
|
18
53
|
const credentials = requireAuth();
|
|
19
54
|
const client = new ApiClient(credentials);
|
|
20
|
-
// 1. Resolve workspace
|
|
21
|
-
const { workspaceId, workspaceName } = await
|
|
55
|
+
// 1. Resolve workspace + persist as default
|
|
56
|
+
const { workspaceId, workspaceName, workspaceSlug } = await resolveAndPersistWorkspace(client, opts);
|
|
22
57
|
console.log(`\nWorkspace: ${workspaceName || workspaceId}\n`);
|
|
23
58
|
// 2. Detect agents
|
|
24
59
|
let agents = await detectAgents();
|
|
@@ -75,13 +110,6 @@ export const setupCommand = new Command('setup')
|
|
|
75
110
|
const chosen = await promptSelect('Configure for:', scopeChoices);
|
|
76
111
|
scope = chosen.value;
|
|
77
112
|
}
|
|
78
|
-
// 4. Get workspace slug
|
|
79
|
-
let workspaceSlug;
|
|
80
|
-
try {
|
|
81
|
-
const workspaces = await client.listWorkspaces();
|
|
82
|
-
workspaceSlug = workspaces.find(w => w.id === workspaceId)?.slug;
|
|
83
|
-
}
|
|
84
|
-
catch { /* best-effort */ }
|
|
85
113
|
// 5. Save setup state (minimal, sync populates the rest)
|
|
86
114
|
const state = {
|
|
87
115
|
workspaceId,
|
package/dist/commands/sync.js
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
|
|
5
5
|
import { requireAuth } from '../auth/store.js';
|
|
6
6
|
import { ApiClient } from '../api/client.js';
|
|
7
7
|
import { getAdapterBySlug } from '../agents/detect.js';
|
|
8
|
+
import { CodexAdapter } from '../agents/codex.js';
|
|
8
9
|
import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
|
|
9
10
|
import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
|
|
10
11
|
import { generateIntroSkill, generateInstructionHint, buildAppSkillDescription } from '../agents/intro-skill.js';
|
|
@@ -360,12 +361,31 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
360
361
|
if (!adapter.writeAgentConfig)
|
|
361
362
|
continue;
|
|
362
363
|
try {
|
|
363
|
-
await adapter.writeAgentConfig({
|
|
364
|
+
await adapter.writeAgentConfig({
|
|
365
|
+
networkAllowlist: networkDomains,
|
|
366
|
+
minimumPermissions: [
|
|
367
|
+
{ field: 'approval_policy', order: ['always', 'untrusted', 'on-request', 'never'], minimum: 'on-request' },
|
|
368
|
+
{ field: 'sandbox_mode', order: ['full', 'read-only', 'workspace-write', 'off'], minimum: 'workspace-write' },
|
|
369
|
+
],
|
|
370
|
+
}, 'user');
|
|
364
371
|
}
|
|
365
372
|
catch {
|
|
366
373
|
// Best-effort
|
|
367
374
|
}
|
|
368
375
|
}
|
|
376
|
+
// Register ~/.runwork as a project in the Codex desktop app (best-effort).
|
|
377
|
+
// Only attempts when Codex adapter is configured and the desktop app is closed.
|
|
378
|
+
for (const adapter of adapters) {
|
|
379
|
+
if (adapter instanceof CodexAdapter) {
|
|
380
|
+
const runworkDir = join(homedir(), '.runwork');
|
|
381
|
+
const result = adapter.registerDesktopWorkspace(runworkDir, 'Runwork');
|
|
382
|
+
if (result === 'written') {
|
|
383
|
+
console.log(` [${adapter.name}] Registered workspace in Codex desktop app`);
|
|
384
|
+
}
|
|
385
|
+
// 'app_running' and 'already_registered' are silently skipped
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
369
389
|
// Update state
|
|
370
390
|
state.lastSyncAt = new Date().toISOString();
|
|
371
391
|
state.mcpServers = mcpEntries.map(e => e.name);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.9.0";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-types.ts -- do not edit
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.9.0";
|
package/dist/utils/which.d.ts
CHANGED
|
@@ -4,3 +4,11 @@
|
|
|
4
4
|
* Returns the resolved path on success, null if not found.
|
|
5
5
|
*/
|
|
6
6
|
export declare function whichBinary(name: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Check if a desktop app is currently running by process name.
|
|
9
|
+
* On macOS: checks for the .app bundle process via pgrep.
|
|
10
|
+
* On Windows: uses tasklist to find the .exe.
|
|
11
|
+
* On Linux: uses pgrep for the binary name.
|
|
12
|
+
* Returns true if the process is found, false otherwise.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isAppRunning(appName: string): boolean;
|
package/dist/utils/which.js
CHANGED
|
@@ -16,3 +16,32 @@ export function whichBinary(name) {
|
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Check if a desktop app is currently running by process name.
|
|
21
|
+
* On macOS: checks for the .app bundle process via pgrep.
|
|
22
|
+
* On Windows: uses tasklist to find the .exe.
|
|
23
|
+
* On Linux: uses pgrep for the binary name.
|
|
24
|
+
* Returns true if the process is found, false otherwise.
|
|
25
|
+
*/
|
|
26
|
+
export function isAppRunning(appName) {
|
|
27
|
+
try {
|
|
28
|
+
const os = platform();
|
|
29
|
+
if (os === 'darwin') {
|
|
30
|
+
// pgrep -f matches against the full command line, catching Electron apps
|
|
31
|
+
execFileSync('pgrep', ['-f', `${appName}.app`], { stdio: 'pipe' });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
else if (os === 'win32') {
|
|
35
|
+
const result = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${appName}.exe`, '/NH'], { stdio: 'pipe' }).toString();
|
|
36
|
+
return result.includes(`${appName}.exe`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
execFileSync('pgrep', ['-x', appName.toLowerCase()], { stdio: 'pipe' });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// pgrep exits non-zero when no processes match
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED