@awak-app/simy-cli 0.4.0 → 0.5.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/README.md +44 -13
- package/package.json +3 -2
- package/src/agent.js +104 -15
- package/src/auto-update.js +256 -9
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +26 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/SKILL.md +154 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/agents/openai.yaml +5 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/change-boundary-matrix.md +67 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/chrome-user-verification.md +136 -0
- package/src/codex-plugins/simy/skills/agentic-delivery-loop/references/state-and-receipts.md +142 -0
- package/src/codex-plugins/simy/skills/pr-review/SKILL.md +18 -0
- package/src/codex-plugins/simy/skills/pr-review/agents/openai.yaml +6 -0
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +21 -0
- package/src/codex-plugins/simy/skills/sqm-loop/agents/openai.yaml +6 -0
- package/src/index.js +138 -67
- package/src/orchestrator/completion-contract.js +15 -1
- package/src/sqm/agent-skill.js +373 -0
- package/src/sqm/bundle-store.js +42 -0
- package/src/sqm/review-worker.js +278 -0
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
5
6
|
|
|
6
7
|
import { startAgent } from "./agent.js";
|
|
7
8
|
import {
|
|
@@ -9,6 +10,8 @@ import {
|
|
|
9
10
|
createCliAutoUpdater,
|
|
10
11
|
readCliPackageInfo,
|
|
11
12
|
restartDaemonProcess,
|
|
13
|
+
runCliUpdateGate,
|
|
14
|
+
shouldRetryIncompatibleCli,
|
|
12
15
|
writeUpdateHandoffFromEnvironment,
|
|
13
16
|
} from "./auto-update.js";
|
|
14
17
|
import { openAuthorizationUrl } from "./browser.js";
|
|
@@ -20,26 +23,75 @@ import { DEFAULT_WEB_ORIGIN, resolveWebOrigin } from "./web-origin.js";
|
|
|
20
23
|
|
|
21
24
|
const argv = process.argv.slice(2);
|
|
22
25
|
const args = new Set(argv);
|
|
26
|
+
const dispatchArgv = argv.filter((value) => value !== "--no-auto-update");
|
|
27
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
28
|
+
const packageInfo = await readCliPackageInfo();
|
|
29
|
+
const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
|
|
23
30
|
|
|
24
|
-
if (
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (dispatchArgv[0] === "update") {
|
|
32
|
+
const checkOnly = args.has("--check");
|
|
33
|
+
const result = await runCliUpdateGate({
|
|
34
|
+
argv,
|
|
35
|
+
packageInfo,
|
|
36
|
+
mode: checkOnly ? "check" : "update",
|
|
37
|
+
disabled: updatesDisabled,
|
|
38
|
+
allowMajor: args.has("--yes"),
|
|
39
|
+
confirmMajor: confirmMajorUpdate,
|
|
40
|
+
});
|
|
41
|
+
process.exitCode = ["check_failed", "install_failed"].includes(result.outcome) ? 1 : 0;
|
|
42
|
+
} else {
|
|
43
|
+
const startupUpdate = await runCliUpdateGate({
|
|
44
|
+
argv,
|
|
45
|
+
packageInfo,
|
|
46
|
+
entryPath,
|
|
47
|
+
disabled: updatesDisabled,
|
|
48
|
+
});
|
|
49
|
+
if (startupUpdate.reexecuted) {
|
|
50
|
+
process.exitCode = startupUpdate.exitCode;
|
|
51
|
+
} else {
|
|
52
|
+
await runCommand();
|
|
32
53
|
}
|
|
33
|
-
process.exit();
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
async function runCommand() {
|
|
57
|
+
if (dispatchArgv[0] === "sqm") {
|
|
58
|
+
try {
|
|
59
|
+
const { runSqmCommand } = await import("./sqm/command.js");
|
|
60
|
+
process.exitCode = await runSqmCommand(dispatchArgv.slice(1), { cliVersion: packageInfo.version });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (shouldRetryIncompatibleCli(error)) {
|
|
63
|
+
const retried = await runCliUpdateGate({
|
|
64
|
+
argv,
|
|
65
|
+
packageInfo,
|
|
66
|
+
entryPath,
|
|
67
|
+
mode: "incompatible",
|
|
68
|
+
disabled: updatesDisabled,
|
|
69
|
+
allowMajor: args.has("--yes"),
|
|
70
|
+
confirmMajor: confirmMajorUpdate,
|
|
71
|
+
incompatibleRetry: true,
|
|
72
|
+
});
|
|
73
|
+
if (retried.reexecuted) {
|
|
74
|
+
process.exitCode = retried.exitCode;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const required = error.minimumVersion ? ` (requires ${error.minimumVersion}+)` : "";
|
|
78
|
+
console.error(`SQM requires a newer SIMY CLI${required}, but automatic recovery did not install one.`);
|
|
79
|
+
}
|
|
80
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
81
|
+
process.exitCode = 2;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (args.has("--help") || args.has("-h")) {
|
|
87
|
+
console.log(`Usage:
|
|
38
88
|
simy Start the local SIMY agent
|
|
39
89
|
simy --daemon Start it in the background
|
|
40
90
|
simy --no-tui Start the foreground HTTP agent without the interactive console
|
|
41
91
|
simy sqm min-check Run the fast deterministic SQM diff preflight
|
|
42
92
|
simy sqm check Run full scenarios, write proof.json, and sign the run
|
|
93
|
+
simy update --check Check the installed and latest stable CLI versions
|
|
94
|
+
simy update Install an eligible stable CLI update
|
|
43
95
|
|
|
44
96
|
Options:
|
|
45
97
|
--daemon, --deamon Run detached in the background
|
|
@@ -47,73 +99,92 @@ Options:
|
|
|
47
99
|
--no-open Do not open the browser when authorization is required
|
|
48
100
|
--port <port> Bind a specific localhost port
|
|
49
101
|
--host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
|
|
50
|
-
--no-auto-update Disable
|
|
102
|
+
--no-auto-update Disable update checks and automatic installation
|
|
51
103
|
|
|
52
104
|
${autoUpdateHelpText()}
|
|
53
105
|
`);
|
|
54
|
-
|
|
55
|
-
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
56
108
|
|
|
57
|
-
|
|
58
|
-
const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
|
|
59
|
-
const port = readPort(argv);
|
|
60
|
-
const packageInfo = await readCliPackageInfo();
|
|
61
|
-
let webOrigin;
|
|
62
|
-
try {
|
|
63
|
-
webOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
64
|
-
} catch (error) {
|
|
65
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
66
|
-
process.exit(2);
|
|
109
|
+
await runLocalAgent();
|
|
67
110
|
}
|
|
68
111
|
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
async function runLocalAgent() {
|
|
113
|
+
const daemon = args.has("--daemon") || args.has("--deamon");
|
|
114
|
+
const interactive = !daemon && !args.has("--no-tui") && process.stdin.isTTY && process.stdout.isTTY;
|
|
115
|
+
const port = readPort(argv);
|
|
116
|
+
let webOrigin;
|
|
117
|
+
try {
|
|
118
|
+
webOrigin = resolveWebOrigin(readOption(argv, "--host"));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
121
|
+
process.exitCode = 2;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
79
124
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
dependencies: { packageInfo },
|
|
86
|
-
});
|
|
87
|
-
const agent = await startAgent({
|
|
88
|
-
requestedPort: port,
|
|
89
|
-
daemon,
|
|
90
|
-
webOrigin,
|
|
91
|
-
quiet: interactive,
|
|
92
|
-
updateManager,
|
|
93
|
-
});
|
|
94
|
-
const shutdownAgent = createAgentShutdown(agent);
|
|
95
|
-
installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
|
|
96
|
-
await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
|
|
97
|
-
updateManager.start({
|
|
98
|
-
registry: agent.registry,
|
|
99
|
-
restartDaemon: async (targetVersion) => {
|
|
100
|
-
await restartDaemonProcess({
|
|
101
|
-
entryPath: fileURLToPath(import.meta.url),
|
|
102
|
-
argv: process.argv.slice(2),
|
|
103
|
-
targetVersion,
|
|
125
|
+
if (daemon && process.env.SIMY_DAEMON_CHILD !== "1") {
|
|
126
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
127
|
+
detached: true,
|
|
128
|
+
stdio: "ignore",
|
|
129
|
+
env: { ...process.env, SIMY_DAEMON_CHILD: "1" },
|
|
104
130
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
131
|
+
child.unref();
|
|
132
|
+
console.log("SIMY local agent started in the background.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const updateManager = await createCliAutoUpdater({
|
|
137
|
+
daemon,
|
|
138
|
+
interactive,
|
|
139
|
+
requestedPort: port,
|
|
140
|
+
disabled: args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0",
|
|
141
|
+
dependencies: { packageInfo },
|
|
142
|
+
});
|
|
143
|
+
const agent = await startAgent({
|
|
144
|
+
requestedPort: port,
|
|
145
|
+
daemon,
|
|
146
|
+
webOrigin,
|
|
147
|
+
quiet: interactive,
|
|
148
|
+
updateManager,
|
|
149
|
+
});
|
|
150
|
+
const shutdownAgent = createAgentShutdown(agent);
|
|
151
|
+
installAgentShutdownSignalHandlers({ shutdown: shutdownAgent });
|
|
152
|
+
await writeUpdateHandoffFromEnvironment({ version: packageInfo.version, port: agent.port });
|
|
153
|
+
updateManager.start({
|
|
154
|
+
registry: agent.registry,
|
|
155
|
+
restartDaemon: async (targetVersion) => {
|
|
156
|
+
await restartDaemonProcess({
|
|
157
|
+
entryPath: fileURLToPath(import.meta.url),
|
|
158
|
+
argv: process.argv.slice(2),
|
|
159
|
+
targetVersion,
|
|
160
|
+
});
|
|
161
|
+
await shutdownAgent();
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
if (agent.loginUrl && !args.has("--no-open")) {
|
|
165
|
+
await openAuthorizationUrl(agent.loginUrl);
|
|
166
|
+
}
|
|
167
|
+
if (interactive) {
|
|
168
|
+
const { runAgenticLoopConsole } = await import("./console/index.js");
|
|
169
|
+
try {
|
|
170
|
+
await runAgenticLoopConsole(agent);
|
|
171
|
+
} finally {
|
|
172
|
+
await shutdownAgent();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
110
175
|
}
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
|
|
177
|
+
async function confirmMajorUpdate({ currentVersion, latestVersion }) {
|
|
178
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
179
|
+
console.log(`Major update ${currentVersion} → ${latestVersion} requires confirmation. Re-run with \`simy update --yes\`.`);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
113
183
|
try {
|
|
114
|
-
await
|
|
184
|
+
const answer = await prompt.question(`Install major SIMY CLI update ${currentVersion} → ${latestVersion}? [y/N] `);
|
|
185
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
115
186
|
} finally {
|
|
116
|
-
|
|
187
|
+
prompt.close();
|
|
117
188
|
}
|
|
118
189
|
}
|
|
119
190
|
|
|
@@ -79,8 +79,14 @@ export function completionEvidenceChecks(charter, attempt) {
|
|
|
79
79
|
if (contract.version !== COMPLETION_CONTRACT_VERSION) return [];
|
|
80
80
|
|
|
81
81
|
const uncertainty = normalizeUncertaintyVerification(attempt?.uncertainty_verification);
|
|
82
|
-
const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
|
|
83
82
|
const rows = acceptanceCriterionRows(charter);
|
|
83
|
+
const criterionEvidence = normalizeCriterionEvidence(attempt?.criterion_evidence);
|
|
84
|
+
const expectedCriterionIds = new Set(rows.map((row) => row.id));
|
|
85
|
+
const unexpectedCriterionIds = uniqueStrings(
|
|
86
|
+
criterionEvidence
|
|
87
|
+
.map((item) => item.criterion_id)
|
|
88
|
+
.filter((criterionId) => !expectedCriterionIds.has(criterionId)),
|
|
89
|
+
);
|
|
84
90
|
const checks = [
|
|
85
91
|
check(
|
|
86
92
|
"top_uncertainty_recorded",
|
|
@@ -110,6 +116,14 @@ export function completionEvidenceChecks(charter, attempt) {
|
|
|
110
116
|
? "The executor marked the highest uncertainty resolved with observed evidence."
|
|
111
117
|
: uncertainty.fallback || "The highest uncertainty remains unresolved.",
|
|
112
118
|
),
|
|
119
|
+
check(
|
|
120
|
+
"criterion_evidence_scope",
|
|
121
|
+
"Criterion evidence matches the charter acceptance criteria",
|
|
122
|
+
unexpectedCriterionIds.length === 0,
|
|
123
|
+
unexpectedCriterionIds.length === 0
|
|
124
|
+
? "Every criterion evidence row references a chartered acceptance criterion."
|
|
125
|
+
: `Unexpected criterion evidence: ${unexpectedCriterionIds.join(", ")}.`,
|
|
126
|
+
),
|
|
113
127
|
];
|
|
114
128
|
|
|
115
129
|
for (const row of rows) {
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
|
|
8
|
+
export const SQM_AGENT_SKILL_VERSION = "2.0.0";
|
|
9
|
+
export const SIMY_CODEX_PLUGIN_ID = "simy";
|
|
10
|
+
export const SIMY_CODEX_PLUGIN_VERSION = "0.1.0";
|
|
11
|
+
|
|
12
|
+
const BUNDLED_CODEX_PLUGIN_ROOT = fileURLToPath(
|
|
13
|
+
new URL(`../codex-plugins/${SIMY_CODEX_PLUGIN_ID}/`, import.meta.url),
|
|
14
|
+
);
|
|
15
|
+
const MANAGED_PLUGIN_AUTHOR = "Specialist Doctors Inc.";
|
|
16
|
+
const MANAGED_MARKETPLACE_SOURCE = `./plugins/${SIMY_CODEX_PLUGIN_ID}`;
|
|
17
|
+
|
|
18
|
+
const SKILL_CONTENT = `---
|
|
19
|
+
name: ${SQM_AGENT_SKILL_ID}
|
|
20
|
+
description: Run SIMY Software Quality Monitor checks before creating or proposing a pull request. Use whenever preparing, opening, or updating a PR.
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
# SIMY SQM PR check
|
|
24
|
+
|
|
25
|
+
Before creating or proposing a pull request:
|
|
26
|
+
|
|
27
|
+
1. Run \`simy sqm min-check\` from the repository root.
|
|
28
|
+
2. Run \`simy sqm check --proof proof.json --signed-evidence proof.json.signed.json\`.
|
|
29
|
+
3. Read \`proof.json\` and require \`outcome.status\` to be \`passed\`.
|
|
30
|
+
4. If either command fails, SQM is unavailable, or the proof is not passed, stop. Report the exact blocker and do not create the PR.
|
|
31
|
+
5. Include the proof path and SQM outcome in the PR body when the check passes.
|
|
32
|
+
|
|
33
|
+
Do not replace SQM with an LLM review, and do not claim the check passed without reading the generated proof.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
|
|
39
|
+
let raw = "";
|
|
40
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
41
|
+
let payload = {};
|
|
42
|
+
try { payload = raw ? JSON.parse(raw) : {}; } catch { process.exit(0); }
|
|
43
|
+
const command = String(payload?.tool_input?.command || payload?.tool_input?.cmd || "");
|
|
44
|
+
if (!/(^|[;&|]\\s*)gh\\s+pr\\s+create(?:\\s|$)/.test(command)) process.exit(0);
|
|
45
|
+
|
|
46
|
+
for (const args of [["sqm", "min-check"], ["sqm", "check"]]) {
|
|
47
|
+
const result = spawnSync("simy", args, { cwd: process.cwd(), stdio: "inherit" });
|
|
48
|
+
if (result.error || result.status !== 0) {
|
|
49
|
+
console.error(result.error ? \`SIMY SQM PR gate failed: \${result.error.message}\` : \`SIMY SQM PR gate blocked the PR (exit \${result.status}).\`);
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const TARGETS = {
|
|
56
|
+
codex: [".agents", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
|
|
57
|
+
claude: [".claude", "skills", SQM_AGENT_SKILL_ID, "SKILL.md"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function sqmAgentSkillDigest() {
|
|
61
|
+
return digestText(SKILL_CONTENT);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
|
|
65
|
+
const digest = sqmAgentSkillDigest();
|
|
66
|
+
const codexPlugin = await inspectCodexPlugin({ home });
|
|
67
|
+
const hookPath = managedHookPath(home);
|
|
68
|
+
const installedHookDigest = await fileDigest(hookPath);
|
|
69
|
+
const hookMatches = installedHookDigest === digestText(HOOK_SCRIPT);
|
|
70
|
+
const platforms = {};
|
|
71
|
+
for (const [platform, segments] of Object.entries(TARGETS)) {
|
|
72
|
+
const target = path.join(home, ...segments);
|
|
73
|
+
const installedDigest = await fileDigest(target);
|
|
74
|
+
const skillMatches = installedDigest === digest;
|
|
75
|
+
const hookConfigured = await hasManagedHook(platform, home, hookPath);
|
|
76
|
+
const platformExtraMatches = platform !== "codex" || codexPlugin.installed;
|
|
77
|
+
platforms[platform] = {
|
|
78
|
+
path: target,
|
|
79
|
+
hook_path: platformHookConfigPath(platform, home),
|
|
80
|
+
installed: skillMatches && hookMatches && hookConfigured && platformExtraMatches,
|
|
81
|
+
state:
|
|
82
|
+
installedDigest === null ||
|
|
83
|
+
!hookConfigured ||
|
|
84
|
+
installedHookDigest === null ||
|
|
85
|
+
(platform === "codex" && codexPlugin.state === "missing")
|
|
86
|
+
? "missing"
|
|
87
|
+
: skillMatches && hookMatches && platformExtraMatches
|
|
88
|
+
? "installed"
|
|
89
|
+
: "outdated",
|
|
90
|
+
...(platform === "codex" ? { plugin: codexPlugin } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
skill_id: SQM_AGENT_SKILL_ID,
|
|
95
|
+
version: SQM_AGENT_SKILL_VERSION,
|
|
96
|
+
digest: `sha256:${digest}`,
|
|
97
|
+
enforcement: "pre_tool_use",
|
|
98
|
+
codex_plugin: codexPlugin,
|
|
99
|
+
installed: Object.values(platforms).every((entry) => entry.installed),
|
|
100
|
+
platforms,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS) } = {}) {
|
|
105
|
+
const requested = [...new Set(platforms)];
|
|
106
|
+
if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
|
|
107
|
+
throw new Error("platforms must contain codex and/or claude");
|
|
108
|
+
}
|
|
109
|
+
const hookPath = managedHookPath(home);
|
|
110
|
+
if (requested.includes("codex")) await installCodexPlugin({ home });
|
|
111
|
+
await atomicText(hookPath, HOOK_SCRIPT, 0o700);
|
|
112
|
+
for (const platform of requested) {
|
|
113
|
+
await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
|
|
114
|
+
await installManagedHook(platform, home, hookPath);
|
|
115
|
+
}
|
|
116
|
+
return inspectSqmAgentSkill({ home });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function inspectCodexPlugin({ home = homedir() } = {}) {
|
|
120
|
+
const expectedDigest = await directoryDigest(BUNDLED_CODEX_PLUGIN_ROOT);
|
|
121
|
+
const target = codexPluginPath(home);
|
|
122
|
+
const installedDigest = await directoryDigest(target, { missing: null });
|
|
123
|
+
const marketplacePath = codexMarketplacePath(home);
|
|
124
|
+
const marketplace = await readJsonFile(marketplacePath, { missing: null, invalid: null });
|
|
125
|
+
const entry = Array.isArray(marketplace?.plugins)
|
|
126
|
+
? marketplace.plugins.find((candidate) => candidate?.name === SIMY_CODEX_PLUGIN_ID)
|
|
127
|
+
: null;
|
|
128
|
+
const marketplaceMatches = isManagedMarketplaceEntry(entry);
|
|
129
|
+
const filesMatch = installedDigest === expectedDigest;
|
|
130
|
+
return {
|
|
131
|
+
plugin_id: SIMY_CODEX_PLUGIN_ID,
|
|
132
|
+
version: SIMY_CODEX_PLUGIN_VERSION,
|
|
133
|
+
path: target,
|
|
134
|
+
marketplace_path: marketplacePath,
|
|
135
|
+
digest: `sha256:${expectedDigest}`,
|
|
136
|
+
installed: filesMatch && marketplaceMatches,
|
|
137
|
+
state:
|
|
138
|
+
installedDigest === null || !entry
|
|
139
|
+
? "missing"
|
|
140
|
+
: filesMatch && marketplaceMatches
|
|
141
|
+
? "installed"
|
|
142
|
+
: "outdated",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function installCodexPlugin({ home }) {
|
|
147
|
+
const target = codexPluginPath(home);
|
|
148
|
+
const marketplacePath = codexMarketplacePath(home);
|
|
149
|
+
const marketplace = await readJsonFile(marketplacePath, { missing: {}, invalid: "throw" });
|
|
150
|
+
if (!marketplace || typeof marketplace !== "object" || Array.isArray(marketplace)) {
|
|
151
|
+
throw new Error(`${marketplacePath} must contain a JSON object before SIMY can install its Codex plugin.`);
|
|
152
|
+
}
|
|
153
|
+
const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
|
|
154
|
+
const collision = plugins.find((entry) => entry?.name === SIMY_CODEX_PLUGIN_ID);
|
|
155
|
+
if (collision && !isManagedMarketplaceEntry(collision)) {
|
|
156
|
+
throw new Error(`Codex marketplace already contains an unmanaged ${SIMY_CODEX_PLUGIN_ID} plugin entry.`);
|
|
157
|
+
}
|
|
158
|
+
await assertManagedPluginTarget(target);
|
|
159
|
+
|
|
160
|
+
const staged = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.tmp`);
|
|
161
|
+
const backup = path.join(path.dirname(target), `.${SIMY_CODEX_PLUGIN_ID}.${process.pid}.bak`);
|
|
162
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
163
|
+
await rm(staged, { recursive: true, force: true });
|
|
164
|
+
await rm(backup, { recursive: true, force: true });
|
|
165
|
+
await cp(BUNDLED_CODEX_PLUGIN_ROOT, staged, { recursive: true, errorOnExist: true });
|
|
166
|
+
let backedUp = false;
|
|
167
|
+
try {
|
|
168
|
+
if (await pathExists(target)) {
|
|
169
|
+
await rename(target, backup);
|
|
170
|
+
backedUp = true;
|
|
171
|
+
}
|
|
172
|
+
await rename(staged, target);
|
|
173
|
+
if (backedUp) await rm(backup, { recursive: true, force: true });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await rm(staged, { recursive: true, force: true });
|
|
176
|
+
if (backedUp && !(await pathExists(target))) await rename(backup, target);
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const nextMarketplace = {
|
|
181
|
+
...marketplace,
|
|
182
|
+
name: typeof marketplace.name === "string" && marketplace.name.trim()
|
|
183
|
+
? marketplace.name
|
|
184
|
+
: "personal",
|
|
185
|
+
interface: {
|
|
186
|
+
displayName: "Personal",
|
|
187
|
+
...(marketplace.interface && typeof marketplace.interface === "object"
|
|
188
|
+
? marketplace.interface
|
|
189
|
+
: {}),
|
|
190
|
+
},
|
|
191
|
+
plugins: [
|
|
192
|
+
...plugins.filter((entry) => entry?.name !== SIMY_CODEX_PLUGIN_ID),
|
|
193
|
+
managedMarketplaceEntry(),
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
await atomicText(marketplacePath, `${JSON.stringify(nextMarketplace, null, 2)}\n`, 0o600);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function managedMarketplaceEntry() {
|
|
200
|
+
return {
|
|
201
|
+
name: SIMY_CODEX_PLUGIN_ID,
|
|
202
|
+
source: { source: "local", path: MANAGED_MARKETPLACE_SOURCE },
|
|
203
|
+
policy: { installation: "INSTALLED_BY_DEFAULT", authentication: "ON_INSTALL" },
|
|
204
|
+
category: "Developer Tools",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isManagedMarketplaceEntry(entry) {
|
|
209
|
+
return entry?.name === SIMY_CODEX_PLUGIN_ID &&
|
|
210
|
+
entry?.source?.source === "local" &&
|
|
211
|
+
entry?.source?.path === MANAGED_MARKETPLACE_SOURCE &&
|
|
212
|
+
entry?.policy?.installation === "INSTALLED_BY_DEFAULT" &&
|
|
213
|
+
entry?.policy?.authentication === "ON_INSTALL";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function assertManagedPluginTarget(target) {
|
|
217
|
+
if (!(await pathExists(target))) return;
|
|
218
|
+
const manifest = await readJsonFile(path.join(target, ".codex-plugin", "plugin.json"), {
|
|
219
|
+
missing: null,
|
|
220
|
+
invalid: null,
|
|
221
|
+
});
|
|
222
|
+
if (manifest?.name !== SIMY_CODEX_PLUGIN_ID || manifest?.author?.name !== MANAGED_PLUGIN_AUTHOR) {
|
|
223
|
+
throw new Error(`${target} already exists and is not a SIMY-managed Codex plugin.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function codexPluginPath(home) {
|
|
228
|
+
return path.join(home, "plugins", SIMY_CODEX_PLUGIN_ID);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function codexMarketplacePath(home) {
|
|
232
|
+
return path.join(home, ".agents", "plugins", "marketplace.json");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function managedHookPath(home) {
|
|
236
|
+
return path.join(home, ".simy", "integrations", "sqm-agent", "pre-pr-hook.mjs");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function platformHookConfigPath(platform, home) {
|
|
240
|
+
return platform === "codex"
|
|
241
|
+
? path.join(home, ".codex", "hooks.json")
|
|
242
|
+
: path.join(home, ".claude", "settings.json");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function managedHookGroup(hookPath) {
|
|
246
|
+
return {
|
|
247
|
+
matcher: "Bash",
|
|
248
|
+
hooks: [
|
|
249
|
+
{
|
|
250
|
+
type: "command",
|
|
251
|
+
command: `node ${JSON.stringify(hookPath)}`,
|
|
252
|
+
statusMessage: "Running SIMY SQM PR gate",
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function installManagedHook(platform, home, hookPath) {
|
|
259
|
+
const target = platformHookConfigPath(platform, home);
|
|
260
|
+
let config = {};
|
|
261
|
+
try {
|
|
262
|
+
config = JSON.parse(await readFile(target, "utf8"));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error?.code !== "ENOENT") {
|
|
265
|
+
throw new Error(`${target} must contain valid JSON before SIMY can merge the SQM hook.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const hooks = config && typeof config === "object" && !Array.isArray(config)
|
|
269
|
+
? { ...(config.hooks || {}) }
|
|
270
|
+
: {};
|
|
271
|
+
const existing = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
272
|
+
hooks.PreToolUse = [
|
|
273
|
+
...existing.filter((group) => !groupContainsManagedHook(group)),
|
|
274
|
+
managedHookGroup(hookPath),
|
|
275
|
+
];
|
|
276
|
+
await atomicText(target, `${JSON.stringify({ ...config, hooks }, null, 2)}\n`, 0o600);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function hasManagedHook(platform, home, hookPath) {
|
|
280
|
+
try {
|
|
281
|
+
const config = JSON.parse(await readFile(platformHookConfigPath(platform, home), "utf8"));
|
|
282
|
+
return Array.isArray(config?.hooks?.PreToolUse) &&
|
|
283
|
+
config.hooks.PreToolUse.some((group) => groupContainsManagedHook(group, hookPath));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) return false;
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function groupContainsManagedHook(group, expectedPath = null) {
|
|
291
|
+
if (!Array.isArray(group?.hooks)) return false;
|
|
292
|
+
return group.hooks.some((hook) => {
|
|
293
|
+
const command = typeof hook?.command === "string" ? hook.command : "";
|
|
294
|
+
return expectedPath
|
|
295
|
+
? command === `node ${JSON.stringify(expectedPath)}`
|
|
296
|
+
: command.includes("/.simy/integrations/sqm-agent/pre-pr-hook.mjs") ||
|
|
297
|
+
command.includes("\\.simy\\integrations\\sqm-agent\\pre-pr-hook.mjs");
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function fileDigest(target) {
|
|
302
|
+
try {
|
|
303
|
+
return digestText(await readFile(target, "utf8"));
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error?.code === "ENOENT") return null;
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function directoryDigest(root, { missing = "throw" } = {}) {
|
|
311
|
+
let rootStat;
|
|
312
|
+
try {
|
|
313
|
+
rootStat = await lstat(root);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error?.code === "ENOENT" && missing !== "throw") return missing;
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
|
319
|
+
throw new Error(`${root} must be a real directory.`);
|
|
320
|
+
}
|
|
321
|
+
const hash = createHash("sha256");
|
|
322
|
+
const visit = async (directory, prefix = "") => {
|
|
323
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
324
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
327
|
+
const target = path.join(directory, entry.name);
|
|
328
|
+
if (entry.isSymbolicLink()) throw new Error(`${target} must not be a symbolic link.`);
|
|
329
|
+
if (entry.isDirectory()) {
|
|
330
|
+
await visit(target, relative);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!entry.isFile()) throw new Error(`${target} must be a regular file.`);
|
|
334
|
+
hash.update(relative).update("\0").update(await readFile(target)).update("\0");
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
await visit(root);
|
|
338
|
+
return hash.digest("hex");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function readJsonFile(target, { missing, invalid }) {
|
|
342
|
+
try {
|
|
343
|
+
return JSON.parse(await readFile(target, "utf8"));
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error?.code === "ENOENT") return missing;
|
|
346
|
+
if (error instanceof SyntaxError) {
|
|
347
|
+
if (invalid !== "throw") return invalid;
|
|
348
|
+
throw new Error(`${target} must contain valid JSON before SIMY can update it.`);
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function pathExists(target) {
|
|
355
|
+
try {
|
|
356
|
+
await lstat(target);
|
|
357
|
+
return true;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error?.code === "ENOENT") return false;
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function digestText(content) {
|
|
365
|
+
return createHash("sha256").update(content).digest("hex");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function atomicText(target, content, mode) {
|
|
369
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
370
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
371
|
+
await writeFile(temporary, content, { encoding: "utf8", mode });
|
|
372
|
+
await rename(temporary, target);
|
|
373
|
+
}
|