clean-room-skill 0.1.12 → 0.1.13
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +32 -5
- package/agents/clean-architect.md +3 -0
- package/agents/clean-implementer-verifier-shell.md +3 -0
- package/agents/clean-polish-reviewer.md +3 -0
- package/agents/clean-qa-editor.md +3 -0
- package/agents/contaminated-handoff-sanitizer.md +3 -0
- package/agents/contaminated-manager-verifier.md +3 -0
- package/agents/contaminated-source-analyst.md +3 -0
- package/bin/install.js +11 -1621
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/HOOKS.md +14 -10
- package/docs/REFERENCE.md +24 -4
- package/examples/codex/.codex/agents/clean-architect.toml +3 -3
- package/examples/codex/.codex/agents/clean-polish-reviewer.toml +2 -2
- package/examples/codex/.codex/agents/clean-qa-editor.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-handoff-sanitizer.toml +2 -2
- package/examples/codex/.codex/agents/contaminated-manager-verifier.toml +3 -3
- package/examples/codex/.codex/agents/contaminated-source-analyst.toml +2 -2
- package/lib/bootstrap.cjs +5 -1
- package/lib/doctor.cjs +157 -5
- package/lib/hooks.cjs +18 -0
- package/lib/install-artifacts.cjs +178 -4
- package/lib/install-claude-plugin.cjs +374 -0
- package/lib/install-cli.cjs +99 -0
- package/lib/install-operations.cjs +376 -0
- package/lib/install-options.cjs +149 -0
- package/lib/install-runtime-selection.cjs +180 -0
- package/lib/install-status.cjs +292 -0
- package/lib/install-tui.cjs +359 -0
- package/lib/preflight-bootstrap.cjs +39 -0
- package/lib/preflight-cli.cjs +95 -0
- package/lib/preflight-constants.cjs +25 -0
- package/lib/preflight-output.cjs +37 -0
- package/lib/preflight-paths.cjs +67 -0
- package/lib/preflight-template.cjs +103 -0
- package/lib/preflight-validation.cjs +276 -0
- package/lib/preflight.cjs +18 -461
- package/lib/run-clean-artifacts.cjs +276 -0
- package/lib/run-cli.cjs +90 -0
- package/lib/run-constants.cjs +171 -0
- package/lib/run-controller.cjs +247 -0
- package/lib/run-coverage.cjs +350 -0
- package/lib/run-hooks.cjs +96 -0
- package/lib/run-manifest.cjs +111 -0
- package/lib/run-progress.cjs +160 -0
- package/lib/run-results.cjs +433 -0
- package/lib/run-roots.cjs +230 -0
- package/lib/run-stages.cjs +409 -0
- package/lib/run.cjs +4 -2254
- package/lib/runtime-layout.cjs +12 -5
- package/package.json +8 -2
- package/plugin.json +1 -1
- package/skills/attended/SKILL.md +2 -0
- package/skills/clean-room/SKILL.md +2 -2
- package/skills/unattended/SKILL.md +2 -0
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
listFiles,
|
|
8
8
|
readJsonFile,
|
|
9
9
|
} = require('./fs-utils.cjs');
|
|
10
|
+
const { OPENCODE_PLUGIN_MARKER } = require('./hooks.cjs');
|
|
10
11
|
const { resolveRuntimeLayout } = require('./runtime-layout.cjs');
|
|
11
12
|
|
|
12
13
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
@@ -104,6 +105,168 @@ function generatePluginManifest() {
|
|
|
104
105
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
function generateOpenCodePlugin(layout, hookMode) {
|
|
109
|
+
const wrapperPath = path.join(layout.targetRoot, 'hooks', 'clean-room', 'clean-room-hook.py');
|
|
110
|
+
const mode = hookMode === 'strict' ? 'strict' : 'safe';
|
|
111
|
+
return `import { spawn } from "node:child_process"
|
|
112
|
+
|
|
113
|
+
const CLEAN_ROOM_OPENCODE_PLUGIN_MARKER = ${JSON.stringify(OPENCODE_PLUGIN_MARKER)}
|
|
114
|
+
const CLEAN_ROOM_HOOK_MODE = ${JSON.stringify(mode)}
|
|
115
|
+
const CLEAN_ROOM_HOOK_WRAPPER = ${JSON.stringify(wrapperPath)}
|
|
116
|
+
const CLEAN_ROOM_HOOK_PYTHON = process.env.CLEAN_ROOM_HOOK_PYTHON || "python3"
|
|
117
|
+
const CLEAN_ROOM_HOOK_TIMEOUT_MS = 30_000
|
|
118
|
+
const MAX_HOOK_OUTPUT_CHARS = 256 * 1024
|
|
119
|
+
|
|
120
|
+
const CHECKS = {
|
|
121
|
+
shell: ["require-clean-room-env.py", "deny-clean-room-shell.py"],
|
|
122
|
+
read: ["require-clean-room-env.py", "deny-clean-source-read.py"],
|
|
123
|
+
write: ["require-clean-room-env.py", "deny-contaminated-clean-write.py"],
|
|
124
|
+
postWrite: [
|
|
125
|
+
"require-clean-room-env.py",
|
|
126
|
+
"check-artifact-leakage.py",
|
|
127
|
+
"validate-json-schema.py",
|
|
128
|
+
"validate-handoff-package.py",
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SHELL_TOOLS = new Set([
|
|
133
|
+
"bash",
|
|
134
|
+
"shell",
|
|
135
|
+
"powershell",
|
|
136
|
+
"terminal",
|
|
137
|
+
"exec_command",
|
|
138
|
+
"shell_command",
|
|
139
|
+
"writestdin",
|
|
140
|
+
"write_stdin",
|
|
141
|
+
])
|
|
142
|
+
|
|
143
|
+
const READ_TOOLS = new Set([
|
|
144
|
+
"read",
|
|
145
|
+
"glob",
|
|
146
|
+
"grep",
|
|
147
|
+
"list",
|
|
148
|
+
"ls",
|
|
149
|
+
"lsp",
|
|
150
|
+
"notebookread",
|
|
151
|
+
"viewimage",
|
|
152
|
+
"view_image",
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
const DIRECTORY_READ_TOOLS = new Set(["glob", "grep", "list", "ls", "lsp"])
|
|
156
|
+
const WRITE_TOOLS = new Set(["write", "edit", "multiedit", "notebookedit", "applypatch", "apply_patch"])
|
|
157
|
+
const PATH_KEYS = ["file_path", "filePath", "path", "notebook_path", "notebookPath"]
|
|
158
|
+
|
|
159
|
+
function normalizeTool(tool) {
|
|
160
|
+
return String(tool || "").toLowerCase().replace(/[^a-z0-9_]/g, "")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function objectArgs(value) {
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
|
165
|
+
return { ...value }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cwdFor(args, directory, worktree) {
|
|
169
|
+
if (typeof args.cwd === "string" && args.cwd) return args.cwd
|
|
170
|
+
if (typeof directory === "string" && directory) return directory
|
|
171
|
+
if (typeof worktree === "string" && worktree) return worktree
|
|
172
|
+
return process.cwd()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function withDirectoryFallbackPath(tool, args, cwd) {
|
|
176
|
+
if (!DIRECTORY_READ_TOOLS.has(tool)) return args
|
|
177
|
+
if (PATH_KEYS.some((key) => typeof args[key] === "string" && args[key])) return args
|
|
178
|
+
return { ...args, path: cwd }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hookPayload(input, args, directory, worktree) {
|
|
182
|
+
const tool = input?.tool
|
|
183
|
+
const normalized = normalizeTool(tool)
|
|
184
|
+
const cwd = cwdFor(args, directory, worktree)
|
|
185
|
+
return {
|
|
186
|
+
tool_name: tool,
|
|
187
|
+
tool,
|
|
188
|
+
tool_input: withDirectoryFallbackPath(normalized, args, cwd),
|
|
189
|
+
cwd,
|
|
190
|
+
opencode: {
|
|
191
|
+
sessionID: input?.sessionID,
|
|
192
|
+
callID: input?.callID,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hookArgs(checks) {
|
|
198
|
+
const args = [CLEAN_ROOM_HOOK_WRAPPER, "--mode", CLEAN_ROOM_HOOK_MODE]
|
|
199
|
+
for (const check of checks) args.push("--check", check)
|
|
200
|
+
return args
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function appendBounded(current, chunk) {
|
|
204
|
+
if (current.length >= MAX_HOOK_OUTPUT_CHARS) return current
|
|
205
|
+
return (current + String(chunk)).slice(0, MAX_HOOK_OUTPUT_CHARS)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runHook(label, checks, payload) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const child = spawn(CLEAN_ROOM_HOOK_PYTHON, hookArgs(checks), {
|
|
211
|
+
env: process.env,
|
|
212
|
+
shell: false,
|
|
213
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
214
|
+
})
|
|
215
|
+
let stdout = ""
|
|
216
|
+
let stderr = ""
|
|
217
|
+
let settled = false
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
settled = true
|
|
220
|
+
child.kill("SIGTERM")
|
|
221
|
+
reject(new Error(\`clean-room \${label} hook timed out after \${CLEAN_ROOM_HOOK_TIMEOUT_MS}ms\`))
|
|
222
|
+
}, CLEAN_ROOM_HOOK_TIMEOUT_MS)
|
|
223
|
+
child.stdout.on("data", (chunk) => {
|
|
224
|
+
stdout = appendBounded(stdout, chunk)
|
|
225
|
+
})
|
|
226
|
+
child.stderr.on("data", (chunk) => {
|
|
227
|
+
stderr = appendBounded(stderr, chunk)
|
|
228
|
+
})
|
|
229
|
+
child.on("error", (error) => {
|
|
230
|
+
if (settled) return
|
|
231
|
+
settled = true
|
|
232
|
+
clearTimeout(timer)
|
|
233
|
+
reject(error)
|
|
234
|
+
})
|
|
235
|
+
child.on("close", (status, signal) => {
|
|
236
|
+
if (settled) return
|
|
237
|
+
settled = true
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
if (status === 0) {
|
|
240
|
+
resolve()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const detail = (stderr || stdout || \`status \${status}\${signal ? \`, signal \${signal}\` : ""}\`).trim()
|
|
244
|
+
reject(new Error(\`clean-room \${label} hook denied tool use: \${detail}\`))
|
|
245
|
+
})
|
|
246
|
+
child.stdin.end(JSON.stringify(payload))
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const CleanRoomPlugin = async ({ directory, worktree }) => {
|
|
251
|
+
return {
|
|
252
|
+
"tool.execute.before": async (input, output) => {
|
|
253
|
+
const tool = normalizeTool(input?.tool)
|
|
254
|
+
const payload = hookPayload(input, objectArgs(output?.args), directory, worktree)
|
|
255
|
+
if (SHELL_TOOLS.has(tool)) await runHook("shell", CHECKS.shell, payload)
|
|
256
|
+
if (READ_TOOLS.has(tool)) await runHook("read", CHECKS.read, payload)
|
|
257
|
+
if (WRITE_TOOLS.has(tool)) await runHook("write", CHECKS.write, payload)
|
|
258
|
+
},
|
|
259
|
+
"tool.execute.after": async (input) => {
|
|
260
|
+
const tool = normalizeTool(input?.tool)
|
|
261
|
+
if (!WRITE_TOOLS.has(tool)) return
|
|
262
|
+
const payload = hookPayload(input, objectArgs(input?.args), directory, worktree)
|
|
263
|
+
await runHook("post-write", CHECKS.postWrite, payload)
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
|
269
|
+
|
|
107
270
|
function addCommandWrappers(desired, artifact) {
|
|
108
271
|
const skillsRoot = path.join(PACKAGE_ROOT, artifact.source);
|
|
109
272
|
const entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
@@ -117,7 +280,15 @@ function addCommandWrappers(desired, artifact) {
|
|
|
117
280
|
}
|
|
118
281
|
}
|
|
119
282
|
|
|
120
|
-
function
|
|
283
|
+
function shouldInstallArtifact(artifact, hookMode) {
|
|
284
|
+
if (!Array.isArray(artifact.hookModes)) return true;
|
|
285
|
+
return artifact.hookModes.includes(hookMode);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function addArtifact(desired, artifact, layout, hookMode) {
|
|
289
|
+
if (!shouldInstallArtifact(artifact, hookMode)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
121
292
|
if (artifact.kind === 'skills' || artifact.kind === 'agents') {
|
|
122
293
|
addTree(desired, artifact.source, artifact.destSubpath);
|
|
123
294
|
return;
|
|
@@ -136,6 +307,10 @@ function addArtifact(desired, artifact) {
|
|
|
136
307
|
desired.set(artifact.destSubpath, Buffer.from(generatePluginManifest(), 'utf8'));
|
|
137
308
|
return;
|
|
138
309
|
}
|
|
310
|
+
if (artifact.kind === 'opencode-plugin') {
|
|
311
|
+
desired.set(artifact.destSubpath, Buffer.from(generateOpenCodePlugin(layout, hookMode), 'utf8'));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
139
314
|
throw new Error(`unsupported artifact kind: ${artifact.kind}`);
|
|
140
315
|
}
|
|
141
316
|
|
|
@@ -147,12 +322,11 @@ function layoutFromInput(runtimeOrLayout, scope, configDir) {
|
|
|
147
322
|
}
|
|
148
323
|
|
|
149
324
|
function buildDesiredFiles(runtimeOrLayout, hookMode, scope = 'global', configDir = null) {
|
|
150
|
-
|
|
151
|
-
void hookMode;
|
|
325
|
+
hookMode = hookMode || 'safe';
|
|
152
326
|
const layout = layoutFromInput(runtimeOrLayout, scope, configDir);
|
|
153
327
|
const desired = new Map();
|
|
154
328
|
for (const artifact of layout.artifacts) {
|
|
155
|
-
addArtifact(desired, artifact);
|
|
329
|
+
addArtifact(desired, artifact, layout, hookMode);
|
|
156
330
|
}
|
|
157
331
|
return desired;
|
|
158
332
|
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const { packageVersion } = require('./install-artifacts.cjs');
|
|
8
|
+
|
|
9
|
+
const CLAUDE_PLUGIN_TIMEOUT_MS = envPositiveInteger('CLEAN_ROOM_INSTALL_CLAUDE_PLUGIN_TIMEOUT_MS', 120_000);
|
|
10
|
+
const CLAUDE_EXECUTABLE_ENV = 'CLEAN_ROOM_CLAUDE_EXECUTABLE';
|
|
11
|
+
const CLAUDE_PLUGIN_MARKETPLACE_NAME = 'clean-room-skill';
|
|
12
|
+
const CLAUDE_PLUGIN_NAME = 'clean-room';
|
|
13
|
+
const CLAUDE_PLUGIN_ID = `${CLAUDE_PLUGIN_NAME}@${CLAUDE_PLUGIN_MARKETPLACE_NAME}`;
|
|
14
|
+
const CLAUDE_PLUGIN_SOURCE_URL = 'https://github.com/whit3rabbit/clean-room-skill.git';
|
|
15
|
+
const CLAUDE_PLUGIN_SCOPE = 'user';
|
|
16
|
+
|
|
17
|
+
function envPositiveInteger(name, fallback) {
|
|
18
|
+
const value = process.env[name];
|
|
19
|
+
if (value === undefined || value === '') {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
return /^[1-9][0-9]*$/.test(value) ? Number(value) : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function usesClaudeGlobalPlugin(layout) {
|
|
26
|
+
return layout.runtime === 'claude' && layout.scope === 'global';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function claudePluginSource() {
|
|
30
|
+
return `${CLAUDE_PLUGIN_SOURCE_URL}#v${packageVersion()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function truncateCommandOutput(value) {
|
|
34
|
+
const text = String(value || '').trim();
|
|
35
|
+
if (text.length <= 2000) return text;
|
|
36
|
+
return `${text.slice(0, 2000)}...`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pathIsUnder(candidate, root) {
|
|
40
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function currentWorkingRoots() {
|
|
44
|
+
const cwd = path.resolve(process.cwd());
|
|
45
|
+
const roots = [cwd];
|
|
46
|
+
try {
|
|
47
|
+
const real = fs.realpathSync.native(cwd);
|
|
48
|
+
if (!roots.includes(real)) roots.push(real);
|
|
49
|
+
} catch {
|
|
50
|
+
// Keep the resolved cwd as the policy root if realpath is unavailable.
|
|
51
|
+
}
|
|
52
|
+
return roots;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pathIsUnderAny(candidate, roots) {
|
|
56
|
+
return roots.some((root) => pathIsUnder(candidate, root));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pathContainsNodeModulesBin(candidate) {
|
|
60
|
+
const parts = path.resolve(candidate).split(path.sep);
|
|
61
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
62
|
+
if (parts[i] === 'node_modules' && parts[i + 1] === '.bin') return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unsafeClaudeExecutableReason(filePath, label) {
|
|
68
|
+
if (!filePath || typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
|
|
69
|
+
return `${label} must be an absolute path`;
|
|
70
|
+
}
|
|
71
|
+
const resolved = path.resolve(filePath);
|
|
72
|
+
const cwdRoots = currentWorkingRoots();
|
|
73
|
+
if (pathIsUnderAny(resolved, cwdRoots)) {
|
|
74
|
+
return `${label} must not be under the current working directory`;
|
|
75
|
+
}
|
|
76
|
+
if (pathContainsNodeModulesBin(resolved)) {
|
|
77
|
+
return `${label} must not be under node_modules/.bin`;
|
|
78
|
+
}
|
|
79
|
+
let real;
|
|
80
|
+
try {
|
|
81
|
+
real = fs.realpathSync.native(resolved);
|
|
82
|
+
} catch {
|
|
83
|
+
return `${label} must resolve to an executable file`;
|
|
84
|
+
}
|
|
85
|
+
if (pathIsUnderAny(real, cwdRoots)) {
|
|
86
|
+
return `${label} target must not be under the current working directory`;
|
|
87
|
+
}
|
|
88
|
+
if (pathContainsNodeModulesBin(real)) {
|
|
89
|
+
return `${label} target must not be under node_modules/.bin`;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const stat = fs.statSync(real);
|
|
93
|
+
fs.accessSync(real, fs.constants.X_OK);
|
|
94
|
+
if (!stat.isFile()) {
|
|
95
|
+
return `${label} must be an executable regular file`;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return `${label} must be an executable regular file`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertClaudeExecutable(filePath, label) {
|
|
104
|
+
const reason = unsafeClaudeExecutableReason(filePath, label);
|
|
105
|
+
if (reason) throw new Error(reason);
|
|
106
|
+
return path.resolve(filePath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sanitizedPathEntriesForClaude(value) {
|
|
110
|
+
const entries = String(value || '').split(path.delimiter).filter(Boolean);
|
|
111
|
+
const cwdRoots = currentWorkingRoots();
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
return entries.filter((entry) => {
|
|
114
|
+
if (!path.isAbsolute(entry)) return false;
|
|
115
|
+
const normalized = path.resolve(entry);
|
|
116
|
+
if (pathIsUnderAny(normalized, cwdRoots)) return false;
|
|
117
|
+
try {
|
|
118
|
+
if (pathIsUnderAny(fs.realpathSync.native(normalized), cwdRoots)) return false;
|
|
119
|
+
} catch {
|
|
120
|
+
// Nonexistent PATH entries cannot provide claude; leave candidate validation to fail later.
|
|
121
|
+
}
|
|
122
|
+
if (pathContainsNodeModulesBin(normalized)) return false;
|
|
123
|
+
if (seen.has(normalized)) return false;
|
|
124
|
+
seen.add(normalized);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sanitizePathForClaude(value) {
|
|
130
|
+
return sanitizedPathEntriesForClaude(value).join(path.delimiter);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveClaudeExecutable() {
|
|
134
|
+
const configuredExecutable = process.env[CLAUDE_EXECUTABLE_ENV];
|
|
135
|
+
const searchPath = sanitizePathForClaude(process.env.PATH);
|
|
136
|
+
if (configuredExecutable) {
|
|
137
|
+
return {
|
|
138
|
+
executable: assertClaudeExecutable(configuredExecutable, CLAUDE_EXECUTABLE_ENV),
|
|
139
|
+
searchPath,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entries = sanitizedPathEntriesForClaude(process.env.PATH);
|
|
144
|
+
if (entries.length === 0) {
|
|
145
|
+
throw new Error(`Claude plugin command requires ${CLAUDE_EXECUTABLE_ENV} or a non-empty sanitized PATH`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const candidates = [];
|
|
149
|
+
const seenCandidates = new Set();
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
const candidate = path.join(entry, 'claude');
|
|
152
|
+
if (unsafeClaudeExecutableReason(candidate, 'Claude executable')) continue;
|
|
153
|
+
const resolved = path.resolve(candidate);
|
|
154
|
+
let realCandidate;
|
|
155
|
+
try {
|
|
156
|
+
realCandidate = fs.realpathSync.native(resolved);
|
|
157
|
+
} catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (seenCandidates.has(realCandidate)) continue;
|
|
161
|
+
seenCandidates.add(realCandidate);
|
|
162
|
+
candidates.push(resolved);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (candidates.length === 1) {
|
|
166
|
+
return { executable: candidates[0], searchPath };
|
|
167
|
+
}
|
|
168
|
+
if (candidates.length > 1) {
|
|
169
|
+
throw new Error(`Claude plugin command found multiple claude executables on sanitized PATH; set ${CLAUDE_EXECUTABLE_ENV} to the intended absolute executable`);
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Claude plugin command requires ${CLAUDE_EXECUTABLE_ENV} or a claude executable on sanitized PATH`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function claudePluginEnv(layout, searchPath) {
|
|
175
|
+
return {
|
|
176
|
+
...process.env,
|
|
177
|
+
PATH: searchPath,
|
|
178
|
+
CLAUDE_CONFIG_DIR: layout.targetRoot,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function claudeCommandLabel(command, args) {
|
|
183
|
+
return [command, ...args].join(' ');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function claudePluginCommandFailure(command, args, result) {
|
|
187
|
+
const parts = [`Claude plugin command failed: ${claudeCommandLabel(command, args)}`];
|
|
188
|
+
if (result.error) {
|
|
189
|
+
parts.push(result.error.message);
|
|
190
|
+
}
|
|
191
|
+
if (result.status !== null && result.status !== undefined) {
|
|
192
|
+
parts.push(`status ${result.status}`);
|
|
193
|
+
}
|
|
194
|
+
if (result.signal) {
|
|
195
|
+
parts.push(`signal ${result.signal}`);
|
|
196
|
+
}
|
|
197
|
+
const stdout = truncateCommandOutput(result.stdout);
|
|
198
|
+
const stderr = truncateCommandOutput(result.stderr);
|
|
199
|
+
if (stdout) parts.push(`stdout: ${stdout}`);
|
|
200
|
+
if (stderr) parts.push(`stderr: ${stderr}`);
|
|
201
|
+
return parts.join('; ');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runClaudePluginCommand(layout, args, options = {}) {
|
|
205
|
+
const { executable: claudeExecutable, searchPath } = resolveClaudeExecutable();
|
|
206
|
+
const result = spawnSync(claudeExecutable, args, {
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
env: claudePluginEnv(layout, searchPath),
|
|
209
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
210
|
+
timeout: CLAUDE_PLUGIN_TIMEOUT_MS,
|
|
211
|
+
});
|
|
212
|
+
result.command = claudeExecutable;
|
|
213
|
+
if (result.error || result.status !== 0) {
|
|
214
|
+
throw new Error(claudePluginCommandFailure(claudeExecutable, args, result));
|
|
215
|
+
}
|
|
216
|
+
if (!options.silent) {
|
|
217
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
218
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readClaudePluginJson(layout, args) {
|
|
224
|
+
const result = runClaudePluginCommand(layout, args, { silent: true });
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(result.stdout || '[]');
|
|
227
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Claude plugin command returned invalid JSON: ${claudeCommandLabel(result.command || 'claude', args)}; ` +
|
|
231
|
+
`stdout: ${truncateCommandOutput(result.stdout)}; ${err.message}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function claudeMarketplaceExists(layout) {
|
|
237
|
+
return readClaudePluginJson(layout, ['plugin', 'marketplace', 'list', '--json'])
|
|
238
|
+
.some((entry) => entry && entry.name === CLAUDE_PLUGIN_MARKETPLACE_NAME);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function claudePluginEntry(layout) {
|
|
242
|
+
return readClaudePluginJson(layout, ['plugin', 'list', '--json'])
|
|
243
|
+
.find((entry) => entry && entry.id === CLAUDE_PLUGIN_ID) || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function claudePluginExists(layout) {
|
|
247
|
+
return Boolean(claudePluginEntry(layout));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function claudePluginMetadata(manifest, state = {}) {
|
|
251
|
+
const previous = manifest?.claude_plugin || {};
|
|
252
|
+
const metadata = {
|
|
253
|
+
plugin_id: CLAUDE_PLUGIN_ID,
|
|
254
|
+
plugin_name: CLAUDE_PLUGIN_NAME,
|
|
255
|
+
marketplace_name: CLAUDE_PLUGIN_MARKETPLACE_NAME,
|
|
256
|
+
source_url: CLAUDE_PLUGIN_SOURCE_URL,
|
|
257
|
+
source: claudePluginSource(),
|
|
258
|
+
scope: CLAUDE_PLUGIN_SCOPE,
|
|
259
|
+
version: packageVersion(),
|
|
260
|
+
marketplace_added_by_installer: previous.marketplace_added_by_installer === true ||
|
|
261
|
+
state.marketplaceAdded === true,
|
|
262
|
+
plugin_installed_by_installer: previous.plugin_installed_by_installer === true ||
|
|
263
|
+
state.pluginInstalled === true,
|
|
264
|
+
recorded_at: new Date().toISOString(),
|
|
265
|
+
};
|
|
266
|
+
if (state.installPath || previous.install_path) {
|
|
267
|
+
metadata.install_path = state.installPath || previous.install_path;
|
|
268
|
+
}
|
|
269
|
+
return metadata;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ensureClaudeGlobalPlugin(layout, manifest, options, action) {
|
|
273
|
+
if (!usesClaudeGlobalPlugin(layout)) return null;
|
|
274
|
+
|
|
275
|
+
const source = claudePluginSource();
|
|
276
|
+
if (options.dryRun) {
|
|
277
|
+
const marketplaceVerb = action === 'update' ? 'refresh' : 'add';
|
|
278
|
+
const pluginVerb = action === 'update' ? 'update or install' : 'install';
|
|
279
|
+
console.log(` Claude plugin marketplace: would ${marketplaceVerb} ${source}`);
|
|
280
|
+
console.log(` Claude plugin: would ${pluginVerb} ${CLAUDE_PLUGIN_ID}`);
|
|
281
|
+
return claudePluginMetadata(manifest);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const marketplaceWasPresent = claudeMarketplaceExists(layout);
|
|
285
|
+
console.log(` Claude plugin marketplace: ${source}`);
|
|
286
|
+
runClaudePluginCommand(layout, [
|
|
287
|
+
'plugin',
|
|
288
|
+
'marketplace',
|
|
289
|
+
'add',
|
|
290
|
+
source,
|
|
291
|
+
'--scope',
|
|
292
|
+
CLAUDE_PLUGIN_SCOPE,
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
const pluginBefore = claudePluginEntry(layout);
|
|
296
|
+
const pluginWasPresent = Boolean(pluginBefore);
|
|
297
|
+
if (action === 'update' && pluginWasPresent) {
|
|
298
|
+
console.log(` Claude plugin: updating ${CLAUDE_PLUGIN_ID}`);
|
|
299
|
+
runClaudePluginCommand(layout, ['plugin', 'update', CLAUDE_PLUGIN_ID]);
|
|
300
|
+
} else if (!pluginWasPresent) {
|
|
301
|
+
console.log(` Claude plugin: installing ${CLAUDE_PLUGIN_ID}`);
|
|
302
|
+
runClaudePluginCommand(layout, [
|
|
303
|
+
'plugin',
|
|
304
|
+
'install',
|
|
305
|
+
CLAUDE_PLUGIN_ID,
|
|
306
|
+
'--scope',
|
|
307
|
+
CLAUDE_PLUGIN_SCOPE,
|
|
308
|
+
]);
|
|
309
|
+
} else {
|
|
310
|
+
console.log(` Claude plugin: already installed ${CLAUDE_PLUGIN_ID}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const pluginAfter = claudePluginEntry(layout) || pluginBefore;
|
|
314
|
+
return claudePluginMetadata(manifest, {
|
|
315
|
+
marketplaceAdded: !marketplaceWasPresent,
|
|
316
|
+
pluginInstalled: !pluginWasPresent,
|
|
317
|
+
installPath: pluginAfter?.installPath,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function removeClaudeGlobalPlugin(layout, manifest, options) {
|
|
322
|
+
if (!usesClaudeGlobalPlugin(layout)) return;
|
|
323
|
+
const plugin = manifest?.claude_plugin;
|
|
324
|
+
if (!plugin) return;
|
|
325
|
+
|
|
326
|
+
if (options.dryRun) {
|
|
327
|
+
if (plugin.plugin_installed_by_installer) {
|
|
328
|
+
console.log(` Claude plugin: would uninstall ${plugin.plugin_id || CLAUDE_PLUGIN_ID}`);
|
|
329
|
+
}
|
|
330
|
+
if (plugin.marketplace_added_by_installer) {
|
|
331
|
+
console.log(` Claude plugin marketplace: would remove ${plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME}`);
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (plugin.plugin_installed_by_installer) {
|
|
337
|
+
const pluginId = plugin.plugin_id || CLAUDE_PLUGIN_ID;
|
|
338
|
+
if (claudePluginExists(layout)) {
|
|
339
|
+
console.log(` Claude plugin: uninstalling ${pluginId}`);
|
|
340
|
+
runClaudePluginCommand(layout, ['plugin', 'uninstall', pluginId]);
|
|
341
|
+
} else {
|
|
342
|
+
console.log(` Claude plugin: already absent ${pluginId}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (plugin.marketplace_added_by_installer) {
|
|
347
|
+
const marketplaceName = plugin.marketplace_name || CLAUDE_PLUGIN_MARKETPLACE_NAME;
|
|
348
|
+
if (claudeMarketplaceExists(layout)) {
|
|
349
|
+
console.log(` Claude plugin marketplace: removing ${marketplaceName}`);
|
|
350
|
+
runClaudePluginCommand(layout, ['plugin', 'marketplace', 'remove', marketplaceName]);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(` Claude plugin marketplace: already absent ${marketplaceName}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
CLAUDE_EXECUTABLE_ENV,
|
|
359
|
+
CLAUDE_PLUGIN_ID,
|
|
360
|
+
CLAUDE_PLUGIN_MARKETPLACE_NAME,
|
|
361
|
+
CLAUDE_PLUGIN_NAME,
|
|
362
|
+
CLAUDE_PLUGIN_SCOPE,
|
|
363
|
+
CLAUDE_PLUGIN_SOURCE_URL,
|
|
364
|
+
assertClaudeExecutable,
|
|
365
|
+
claudePluginSource,
|
|
366
|
+
ensureClaudeGlobalPlugin,
|
|
367
|
+
pathContainsNodeModulesBin,
|
|
368
|
+
removeClaudeGlobalPlugin,
|
|
369
|
+
resolveClaudeExecutable,
|
|
370
|
+
sanitizedPathEntriesForClaude,
|
|
371
|
+
sanitizePathForClaude,
|
|
372
|
+
unsafeClaudeExecutableReason,
|
|
373
|
+
usesClaudeGlobalPlugin,
|
|
374
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runInit } = require('./bootstrap.cjs');
|
|
4
|
+
const { runDoctor } = require('./doctor.cjs');
|
|
5
|
+
const { runPreflight } = require('./preflight.cjs');
|
|
6
|
+
const { parseRunArgs, runCleanRoom } = require('./run.cjs');
|
|
7
|
+
const { resolveInteractiveOptions } = require('./install-tui.cjs');
|
|
8
|
+
const {
|
|
9
|
+
operationForOptions,
|
|
10
|
+
parseArgs,
|
|
11
|
+
validateRuntimeOptions,
|
|
12
|
+
} = require('./install-options.cjs');
|
|
13
|
+
const {
|
|
14
|
+
installRuntime,
|
|
15
|
+
uninstallRuntime,
|
|
16
|
+
updateRuntime,
|
|
17
|
+
} = require('./install-operations.cjs');
|
|
18
|
+
const {
|
|
19
|
+
runStatus,
|
|
20
|
+
selectedUpdateRuntimes,
|
|
21
|
+
} = require('./install-status.cjs');
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const argv = process.argv.slice(2);
|
|
25
|
+
if (argv[0] === 'init') {
|
|
26
|
+
runInit(argv.slice(1));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (argv[0] === 'doctor') {
|
|
30
|
+
runDoctor(argv.slice(1));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (argv[0] === 'preflight') {
|
|
34
|
+
runPreflight(argv.slice(1));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (argv[0] === 'run') {
|
|
38
|
+
await runCleanRoom(parseRunArgs(argv.slice(1)));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (argv[0] === 'status') {
|
|
42
|
+
const options = parseArgs(argv.slice(1));
|
|
43
|
+
options.operation = 'status';
|
|
44
|
+
if (options.configDir && options.runtimes.length === 0) {
|
|
45
|
+
throw new Error('--config-dir can only be used with one runtime');
|
|
46
|
+
}
|
|
47
|
+
if (!options.scope) options.scope = 'global';
|
|
48
|
+
validateRuntimeOptions(options);
|
|
49
|
+
runStatus(options);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (argv[0] === 'update') {
|
|
53
|
+
const options = parseArgs(argv.slice(1));
|
|
54
|
+
options.operation = 'update';
|
|
55
|
+
if (options.configDir && options.runtimes.length === 0) {
|
|
56
|
+
throw new Error('--config-dir can only be used with one runtime');
|
|
57
|
+
}
|
|
58
|
+
if (!options.scope) options.scope = 'global';
|
|
59
|
+
options.runtimes = selectedUpdateRuntimes(options);
|
|
60
|
+
validateRuntimeOptions(options);
|
|
61
|
+
if (options.runtimes.length === 0) {
|
|
62
|
+
console.log(`No installed ${options.scope} runtimes found to update.`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const runtime of options.runtimes) {
|
|
66
|
+
await updateRuntime(runtime, options);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const options = await resolveInteractiveOptions(parseArgs(argv));
|
|
71
|
+
if (!options.scope) {
|
|
72
|
+
options.scope = 'global';
|
|
73
|
+
}
|
|
74
|
+
validateRuntimeOptions(options);
|
|
75
|
+
if (operationForOptions(options) === 'status') {
|
|
76
|
+
runStatus(options);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (operationForOptions(options) === 'update') {
|
|
80
|
+
options.runtimes = selectedUpdateRuntimes(options);
|
|
81
|
+
if (options.runtimes.length === 0) {
|
|
82
|
+
console.log(`No installed ${options.scope} runtimes found to update.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const runtime of options.runtimes) {
|
|
87
|
+
if (operationForOptions(options) === 'uninstall') {
|
|
88
|
+
await uninstallRuntime(runtime, options);
|
|
89
|
+
} else if (operationForOptions(options) === 'update') {
|
|
90
|
+
await updateRuntime(runtime, options);
|
|
91
|
+
} else {
|
|
92
|
+
await installRuntime(runtime, options);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
main,
|
|
99
|
+
};
|