external-subagent 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/external-subagent-debug-mcp.mjs +5 -0
- package/bin/external-subagent-debug.mjs +13 -0
- package/bin/external-subagent-mcp.mjs +20 -0
- package/bin/external-subagent.mjs +8 -0
- package/bin/zas.mjs +8 -0
- package/cli/commands/agents.mjs +117 -0
- package/cli/commands/config.mjs +117 -0
- package/cli/commands/daemon.mjs +36 -0
- package/cli/commands/maintenance.mjs +27 -0
- package/cli/commands/plugin.mjs +96 -0
- package/cli/commands/tasks.mjs +64 -0
- package/cli/commands/update.mjs +219 -0
- package/cli/config/read.mjs +24 -0
- package/cli/config/schema.mjs +75 -0
- package/cli/config/write.mjs +39 -0
- package/cli/constants.mjs +24 -0
- package/cli/errors.mjs +7 -0
- package/cli/fs-atomic.mjs +36 -0
- package/cli/install/codex.mjs +352 -0
- package/cli/install/init.mjs +206 -0
- package/cli/install/layout.mjs +54 -0
- package/cli/install/npm-hook.mjs +49 -0
- package/cli/install/path.mjs +43 -0
- package/cli/install/payload.mjs +84 -0
- package/cli/install/plugin-stage.mjs +232 -0
- package/cli/install/reconcile.mjs +209 -0
- package/cli/install/recovery.mjs +79 -0
- package/cli/install/service-activation.mjs +133 -0
- package/cli/install/service-macos.mjs +146 -0
- package/cli/install/update.mjs +170 -0
- package/cli/install/zcode.mjs +239 -0
- package/cli/main.mjs +429 -0
- package/cli/maintenance.mjs +104 -0
- package/cli/paths.mjs +40 -0
- package/cli/rpc.mjs +261 -0
- package/launchd/com.external-subagent.daemon.plist.template +37 -0
- package/npm/native/README.md +19 -0
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +20 -0
- package/package.json +60 -0
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
- package/plugins/codex/external-subagent/.mcp.json +9 -0
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
- package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
- package/schema/external-subagent-public-api.json +185 -0
- package/schema/zas-observation-v1.1.schema.json +58 -0
- package/schema/zas-public-reasoning-source.json +14 -0
- package/scripts/release/build-native-payload.mjs +119 -0
- package/scripts/release/check-native-tarball.mjs +125 -0
- package/scripts/release/stage-debug-plugin.mjs +60 -0
- package/scripts/release/test-installed-tarball.mjs +73 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { CliError } from '../errors.mjs';
|
|
7
|
+
import { atomicWrite, jsonBytes, readOptional, restoreOptional } from '../fs-atomic.mjs';
|
|
8
|
+
import { MCP_BIN_NAME, PRODUCT_ID } from '../constants.mjs';
|
|
9
|
+
import { nativeBinary, pluginSourceRoot, PLUGIN_NAME } from './layout.mjs';
|
|
10
|
+
import { pluginManifest, treeDigest, preparePluginStage, guardedRestores } from './plugin-stage.mjs';
|
|
11
|
+
|
|
12
|
+
// Managed Codex binding. The staging tree plus a local source marketplace are
|
|
13
|
+
// the product-owned half; every installation/removal goes through the official
|
|
14
|
+
// codex CLI (verified against codex-cli 0.153.4 and re-verified on 0.154.0,
|
|
15
|
+
// see docs/compatibility/codex.md):
|
|
16
|
+
// codex plugin marketplace add <root> --json
|
|
17
|
+
// codex plugin add <name> --marketplace <marketplace> --json
|
|
18
|
+
// codex plugin remove <name>@<marketplace> --json
|
|
19
|
+
// The codex CLI owns its own config/cache writes; this module never edits the
|
|
20
|
+
// Codex cache directly.
|
|
21
|
+
|
|
22
|
+
const CODEX_MCP_SECTION = `mcp_servers.${PRODUCT_ID}`;
|
|
23
|
+
const MARKETPLACE_NAME = 'personal';
|
|
24
|
+
|
|
25
|
+
export const CODEX_TOOLS = Object.freeze([
|
|
26
|
+
'external_subagent_cancel',
|
|
27
|
+
'external_subagent_close',
|
|
28
|
+
'external_subagent_list',
|
|
29
|
+
'external_subagent_observe',
|
|
30
|
+
'external_subagent_respond',
|
|
31
|
+
'external_subagent_result',
|
|
32
|
+
'external_subagent_send',
|
|
33
|
+
'external_subagent_spawn',
|
|
34
|
+
'external_subagent_status',
|
|
35
|
+
'external_subagent_wait',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export function codexHomeFor(options = {}, paths) {
|
|
39
|
+
if (typeof options.codexHome === 'string' && options.codexHome.length > 0) return options.codexHome;
|
|
40
|
+
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
41
|
+
return path.join((paths && paths.home) || os.homedir(), '.codex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runCodex(args, options = {}) {
|
|
45
|
+
const cli = options.codexCli || 'codex';
|
|
46
|
+
const result = spawnSync(cli, args, { encoding: 'utf8', env: { ...process.env, ...(options.env || {}) } });
|
|
47
|
+
if (result.error) throw new CliError('CODEX_CLI_UNAVAILABLE', result.error.message);
|
|
48
|
+
if (result.status !== 0) throw new CliError('CODEX_CLI_FAILED', (result.stderr || result.stdout || '').trim() || `codex exited ${result.status}`);
|
|
49
|
+
let parsed = null;
|
|
50
|
+
if ((result.stdout || '').trim()) { try { parsed = JSON.parse(result.stdout); } catch { /* help text is not JSON */ } }
|
|
51
|
+
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '', json: parsed };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Claiming a Codex home includes bringing it into existence; codex itself
|
|
55
|
+
// refuses to resolve a CODEX_HOME that is missing.
|
|
56
|
+
function ensureCodexHome(codexHome) {
|
|
57
|
+
if (!fs.existsSync(codexHome)) fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
|
|
58
|
+
return codexHome;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function marketplaceRootFor(file) {
|
|
62
|
+
return path.basename(path.dirname(file)) === 'plugins' && path.basename(path.dirname(path.dirname(file))) === '.agents'
|
|
63
|
+
? path.dirname(path.dirname(path.dirname(file)))
|
|
64
|
+
: path.dirname(file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function updateMarketplace(file, staging) {
|
|
68
|
+
const root = marketplaceRootFor(file);
|
|
69
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
70
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
71
|
+
let doc = { name: MARKETPLACE_NAME, interface: { displayName: 'Personal' }, plugins: [] };
|
|
72
|
+
if (fs.existsSync(file)) doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
73
|
+
if (!Array.isArray(doc.plugins)) throw new CliError('PLUGIN_MARKETPLACE_CONFLICT', 'marketplace file is not a source marketplace manifest');
|
|
74
|
+
const rel = `./${path.relative(root, staging)}`;
|
|
75
|
+
const entry = { name: PLUGIN_NAME, source: { source: 'local', path: rel }, policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' };
|
|
76
|
+
const index = doc.plugins.findIndex((p) => p.name === PLUGIN_NAME);
|
|
77
|
+
if (index >= 0) {
|
|
78
|
+
const current = doc.plugins[index];
|
|
79
|
+
const currentPath = current?.source?.path;
|
|
80
|
+
const resolved = currentPath ? path.resolve(root, currentPath) : null;
|
|
81
|
+
const manifest = resolved && fs.existsSync(path.join(resolved, '.codex-plugin', 'plugin.json')) ? JSON.parse(fs.readFileSync(path.join(resolved, '.codex-plugin', 'plugin.json'), 'utf8')) : null;
|
|
82
|
+
if (currentPath !== entry.source.path || !manifest || manifest.name !== PLUGIN_NAME) throw new CliError('PLUGIN_MARKETPLACE_CONFLICT', 'marketplace entry is not a managed external-subagent source');
|
|
83
|
+
return { marketplace: file, marketplace_name: doc.name || MARKETPLACE_NAME, entry: currentPath, digest: treeDigest(staging) };
|
|
84
|
+
}
|
|
85
|
+
doc.plugins.push(entry);
|
|
86
|
+
// atomicWrite, not a bare writeFileSync: a failed/partial write either
|
|
87
|
+
// lands whole or leaves the prior bytes untouched, so the rollback below
|
|
88
|
+
// never inherits a half-written manifest it might skip restoring.
|
|
89
|
+
atomicWrite(file, jsonBytes(doc));
|
|
90
|
+
return { marketplace: file, marketplace_name: doc.name || MARKETPLACE_NAME, entry: rel, digest: treeDigest(staging) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The staged managed binding this install just wrote: the manifest identity
|
|
94
|
+
// plus the facade command and daemon socket stagePlugin pinned. The cache
|
|
95
|
+
// verification below compares the codex-materialized copy against exactly
|
|
96
|
+
// these values, never against recomputed guesses.
|
|
97
|
+
function stagedManagedBinding(staging) {
|
|
98
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(staging, '.codex-plugin', 'plugin.json'), 'utf8'));
|
|
99
|
+
const server = JSON.parse(fs.readFileSync(path.join(staging, '.mcp.json'), 'utf8')).mcpServers?.external_subagent;
|
|
100
|
+
if (!server || typeof server.command !== 'string' || typeof server.env?.ZCODE_AGENTD_SOCKET !== 'string') {
|
|
101
|
+
throw new CliError('INVALID_PLUGIN_SOURCE', 'staged plugin MCP binding is incomplete');
|
|
102
|
+
}
|
|
103
|
+
return { manifest, command: server.command, socket: server.env.ZCODE_AGENTD_SOCKET };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readCacheJson(file, cache, identity) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new CliError('CODEX_CACHE_UNVERIFIABLE', `codex plugin cache for ${identity} is present but unreadable (${file}: ${error.message})`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Shared remediation for any cache that kept another installation's bytes
|
|
115
|
+
// for this identity: the identity (plugin version) must move, or the
|
|
116
|
+
// resolved root must be refreshed — the codex-owned cache is never edited.
|
|
117
|
+
const CACHE_REUSE_REMEDIATION = 'The cache kept another installation\'s content for the same identity: when the CLI freezes cached content for an identity (codex 0.153.4 store behavior), release a distinct plugin version instead of accepting it; when the CLI re-materializes the cache from the registered root (codex 0.154.0, non-reserved marketplace names), refresh the registered marketplace root and re-add';
|
|
118
|
+
|
|
119
|
+
// codex materializes `plugin add` caches from a resolved root, and the
|
|
120
|
+
// resolution differs by codex version and marketplace name (verified on
|
|
121
|
+
// 0.153.4 and 0.154.0, see docs/compatibility/codex.md). 0.153.4 froze the
|
|
122
|
+
// first-cached bytes in a machine-global store keyed by
|
|
123
|
+
// plugin@marketplace@version, so the same identity reinstalled from a
|
|
124
|
+
// different binding (another staging root, installed facade, or daemon
|
|
125
|
+
// socket) or from newer managed content received those foreign bytes while
|
|
126
|
+
// `plugin add` still reported success. 0.154.0 re-materializes non-reserved
|
|
127
|
+
// names from the registered root on every add, but resolves the reserved
|
|
128
|
+
// marketplace name `personal` machine-globally to the real user root,
|
|
129
|
+
// ignoring CODEX_HOME — so an isolated home under the reserved name still
|
|
130
|
+
// meets the real root's foreign bytes. Before installPlugin may return
|
|
131
|
+
// success, the materialized cache is read back and compared with this run's
|
|
132
|
+
// staged binding AND staged managed content; any surprise fails closed
|
|
133
|
+
// below (the checks stay even where 0.154.0 re-materializes, as defense
|
|
134
|
+
// against freeze-behavior CLIs and future regressions). The cache is only
|
|
135
|
+
// ever READ here — codex-owned state is never edited, and the remediation
|
|
136
|
+
// is a distinct release identity (frozen stores), refreshing/re-registering
|
|
137
|
+
// the resolved root, or a non-reserved marketplace name in isolated homes —
|
|
138
|
+
// never a rewrite.
|
|
139
|
+
function verifyCodexCache(add, { codexHome, staging, marketplaceName }) {
|
|
140
|
+
const expected = stagedManagedBinding(staging);
|
|
141
|
+
const identity = `${PLUGIN_NAME}@${marketplaceName}@${expected.manifest.version}`;
|
|
142
|
+
const candidates = [];
|
|
143
|
+
const reported = add.json?.installedPath || add.json?.installed_path;
|
|
144
|
+
if (typeof reported === 'string' && reported.length > 0) candidates.push(reported);
|
|
145
|
+
candidates.push(path.join(codexHome, 'plugins', 'cache', add.json?.marketplaceName || marketplaceName, PLUGIN_NAME, String(expected.manifest.version)));
|
|
146
|
+
for (const cache of [...new Set(candidates)]) {
|
|
147
|
+
if (!fs.existsSync(cache)) continue;
|
|
148
|
+
const manifest = readCacheJson(path.join(cache, '.codex-plugin', 'plugin.json'), cache, identity);
|
|
149
|
+
for (const field of ['name', 'version', 'skills', 'mcpServers']) {
|
|
150
|
+
if (manifest[field] !== expected.manifest[field]) {
|
|
151
|
+
throw new CliError('CODEX_CACHE_BINDING_MISMATCH', `codex plugin cache for ${identity} has manifest ${field}=${JSON.stringify(manifest[field])}, expected the staged ${JSON.stringify(expected.manifest[field])} (${cache})`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const server = readCacheJson(path.join(cache, '.mcp.json'), cache, identity).mcpServers?.external_subagent;
|
|
155
|
+
const socket = server?.env?.ZCODE_AGENTD_SOCKET;
|
|
156
|
+
if (!server || server.command !== expected.command || socket !== expected.socket) {
|
|
157
|
+
throw new CliError('CODEX_CACHE_BINDING_MISMATCH', `codex plugin cache for ${identity} carries a different managed binding (command=${server?.command}, socket=${socket}); expected this install's staged binding (command=${expected.command}, socket=${expected.socket}). The cache resolved to another installation's bytes (${cache}); either the machine-global content store reused them for the same identity (release a distinct plugin version instead of accepting them), or the reserved marketplace name personal resolved machine-globally to the real user root regardless of CODEX_HOME (refresh or re-register that root, or use a non-reserved marketplace name in isolated homes)`);
|
|
158
|
+
}
|
|
159
|
+
// AUD-010: identity and binding alone do not prove the cache carries THIS
|
|
160
|
+
// candidate. A freeze-behavior CLI (0.153.4's machine-global store keyed
|
|
161
|
+
// by plugin@marketplace@version) keeps whatever bytes the FIRST install
|
|
162
|
+
// of that identity staged, so a same-identity refresh can meet every
|
|
163
|
+
// metadata/binding check above while the materialized tree still holds
|
|
164
|
+
// an older SKILL, lacks a file this candidate added, or retains a file
|
|
165
|
+
// the source deleted. codex 0.154.0 re-materializes non-reserved names
|
|
166
|
+
// from the registered root before this read, so on that baseline this
|
|
167
|
+
// comparison is defense-in-depth rather than a live stale-content
|
|
168
|
+
// catch; it stays for freeze-behavior CLIs and future regressions. The
|
|
169
|
+
// managed content is therefore compared with this run's staged tree via
|
|
170
|
+
// treeDigest (every file and byte except `.mcp.json` — that exclusion
|
|
171
|
+
// is treeDigest's existing rule, because the machine-local binding must
|
|
172
|
+
// not churn the content digest). The `.mcp.json` gap that exclusion
|
|
173
|
+
// would leave is closed by a direct comparison right after the binding
|
|
174
|
+
// check: command and socket there, then the whole managed document.
|
|
175
|
+
// Real codex materializes the cache as a verbatim copy of the staged
|
|
176
|
+
// plugin tree with no host-generated additions inside it (see
|
|
177
|
+
// docs/compatibility/codex.md), so the staged file set IS the managed
|
|
178
|
+
// set; any difference fails closed through the existing error chain
|
|
179
|
+
// (S01 keeps staging/marketplace recoverable) instead of reporting
|
|
180
|
+
// cache_verified for stale bytes.
|
|
181
|
+
const stagedMcp = readCacheJson(path.join(staging, '.mcp.json'), staging, identity);
|
|
182
|
+
const cacheMcp = readCacheJson(path.join(cache, '.mcp.json'), cache, identity);
|
|
183
|
+
if (!isDeepStrictEqual(cacheMcp, stagedMcp)) {
|
|
184
|
+
throw new CliError('CODEX_CACHE_CONTENT_MISMATCH', `codex plugin cache for ${identity} carries a different managed MCP config (command and socket already matched, so the divergence is in the remaining .mcp.json fields such as args or env; ${cache}). ${CACHE_REUSE_REMEDIATION}`);
|
|
185
|
+
}
|
|
186
|
+
const stagedDigest = treeDigest(staging);
|
|
187
|
+
const cacheDigest = treeDigest(cache);
|
|
188
|
+
if (cacheDigest !== stagedDigest) {
|
|
189
|
+
throw new CliError('CODEX_CACHE_CONTENT_MISMATCH', `codex plugin cache for ${identity} does not carry this candidate's managed content (staged tree digest ${stagedDigest}, cache digest ${cacheDigest}; ${cache}). ${CACHE_REUSE_REMEDIATION}`);
|
|
190
|
+
}
|
|
191
|
+
return cache;
|
|
192
|
+
}
|
|
193
|
+
// `plugin add` reported success, yet neither the CLI-reported path nor the
|
|
194
|
+
// derived cache location exists on disk — there are no bytes to verify this
|
|
195
|
+
// install against. Real codex always materializes the cache, so a success
|
|
196
|
+
// without one is an unverifiable install: fail closed here instead of
|
|
197
|
+
// reporting installed/cache_verified:false, which let public installs and
|
|
198
|
+
// reconciles record success (claims, last_status=updated) for an install
|
|
199
|
+
// whose binding this run never saw.
|
|
200
|
+
const lookedIn = [...new Set(candidates)].join(', ');
|
|
201
|
+
throw new CliError('CODEX_CACHE_UNVERIFIABLE', `codex plugin add reported success for ${identity} but no plugin cache was materialized to verify (looked in: ${lookedIn}); refusing to record an unverified install`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolveStaging(home, options) {
|
|
205
|
+
let staging = options.stagingPath || path.join(home, 'plugins', PLUGIN_NAME);
|
|
206
|
+
let marketplace = options.marketplacePath || path.join(home, '.agents', 'plugins', 'marketplace.json');
|
|
207
|
+
// The default ~/.agents file can be an installed-registry projection, not a
|
|
208
|
+
// source. Never overwrite it; use a private local marketplace root instead.
|
|
209
|
+
if (!options.marketplacePath && fs.existsSync(marketplace)) {
|
|
210
|
+
const reprobe = () => {
|
|
211
|
+
const root = path.join(home, '.external-subagent-marketplace');
|
|
212
|
+
staging = path.join(root, 'plugins', PLUGIN_NAME);
|
|
213
|
+
marketplace = path.join(root, '.agents', 'plugins', 'marketplace.json');
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
if (!Array.isArray(JSON.parse(fs.readFileSync(marketplace, 'utf8')).plugins)) reprobe();
|
|
217
|
+
} catch { reprobe(); }
|
|
218
|
+
}
|
|
219
|
+
return { staging, marketplace };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function installPlugin(paths, options = {}) {
|
|
223
|
+
const source = options.source || pluginSourceRoot();
|
|
224
|
+
const home = options.home || paths.home;
|
|
225
|
+
const { staging, marketplace } = resolveStaging(home, options);
|
|
226
|
+
const codexHome = ensureCodexHome(codexHomeFor(options, paths));
|
|
227
|
+
const env = { ...(options.env || {}), CODEX_HOME: codexHome };
|
|
228
|
+
const cli = { codexCli: options.codexCli, env };
|
|
229
|
+
const probe = runCodex(['plugin', 'add', '--help'], cli);
|
|
230
|
+
if (options.dryRun) {
|
|
231
|
+
return { dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', source, staging, marketplace, codex_home: codexHome, cli: 'codex', help_exit: probe.status };
|
|
232
|
+
}
|
|
233
|
+
if (options.uninstall) return uninstallPlugin(paths, options);
|
|
234
|
+
pluginManifest(source);
|
|
235
|
+
const priorMarketplace = fs.existsSync(marketplace) ? fs.readFileSync(marketplace) : null;
|
|
236
|
+
const staged = preparePluginStage(source, staging, paths);
|
|
237
|
+
// AUD-002: stage by validated replacement. publish() is self-contained
|
|
238
|
+
// (a failed copy/parse/swap never leaves the live tree moved or damaged);
|
|
239
|
+
// every later failure rolls the marketplace back to its prior bytes and
|
|
240
|
+
// restores the prior coherent staging tree — each as an INDEPENDENT
|
|
241
|
+
// guarded attempt, so a restore that itself fails (a marketplace parent
|
|
242
|
+
// that stays read-only) neither skips the staging restore nor masks the
|
|
243
|
+
// original error, and the unrestored resources ride the thrown error.
|
|
244
|
+
const restoreStaging = () => {
|
|
245
|
+
if (!staged.restore()) {
|
|
246
|
+
throw new CliError('PLUGIN_STAGING_RESTORE_FAILED', `the prior staging tree could not be restored at ${staging}; the retained prior copy is left beside it as recovery material`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const restoreMarketplace = () => restoreOptional(marketplace, priorMarketplace);
|
|
250
|
+
let market;
|
|
251
|
+
try {
|
|
252
|
+
staged.publish();
|
|
253
|
+
market = updateMarketplace(marketplace, staging);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// The marketplace restore runs here too: updateMarketplace may already
|
|
256
|
+
// have written the managed entry (its write is atomic, but a later step
|
|
257
|
+
// in it can still fail), and those bytes must go back to the prior
|
|
258
|
+
// backup — foreign entries included — alongside the staging restore.
|
|
259
|
+
throw guardedRestores(error, [
|
|
260
|
+
{ resource: marketplace, restore: restoreMarketplace },
|
|
261
|
+
{ resource: staging, restore: restoreStaging },
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
// Explicitly configured marketplaces must be registered; the personal
|
|
265
|
+
// default is implicit. Both registrations are idempotent in codex.
|
|
266
|
+
let add;
|
|
267
|
+
let verifiedCache = null;
|
|
268
|
+
try {
|
|
269
|
+
if (options.registerMarketplace !== false) runCodex(['plugin', 'marketplace', 'add', marketplaceRootFor(marketplace), '--json'], cli);
|
|
270
|
+
add = runCodex(['plugin', 'add', PLUGIN_NAME, '--marketplace', market.marketplace_name, '--json'], cli);
|
|
271
|
+
verifiedCache = verifyCodexCache(add, { codexHome, staging, marketplaceName: market.marketplace_name });
|
|
272
|
+
} catch (error) {
|
|
273
|
+
throw guardedRestores(error, [
|
|
274
|
+
{ resource: marketplace, restore: restoreMarketplace },
|
|
275
|
+
{ resource: staging, restore: restoreStaging },
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
staged.complete();
|
|
279
|
+
return {
|
|
280
|
+
installed: true, source, staging, marketplace, codex_home: codexHome,
|
|
281
|
+
cache: verifiedCache,
|
|
282
|
+
cache_verified: true,
|
|
283
|
+
codex: add.json || add.stdout.trim(), ...market,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function uninstallPlugin(paths, options = {}) {
|
|
288
|
+
const source = options.source || pluginSourceRoot();
|
|
289
|
+
const home = options.home || paths.home;
|
|
290
|
+
const { staging, marketplace } = resolveStaging(home, options);
|
|
291
|
+
const codexHome = codexHomeFor(options, paths);
|
|
292
|
+
const env = { ...(options.env || {}), CODEX_HOME: codexHome };
|
|
293
|
+
if (options.dryRun) {
|
|
294
|
+
return { dry_run: true, operation: 'uninstall', source, staging, marketplace, codex_home: codexHome, cli: 'codex' };
|
|
295
|
+
}
|
|
296
|
+
let marketplaceName = MARKETPLACE_NAME;
|
|
297
|
+
try {
|
|
298
|
+
if (fs.existsSync(marketplace)) marketplaceName = JSON.parse(fs.readFileSync(marketplace, 'utf8')).name || MARKETPLACE_NAME;
|
|
299
|
+
} catch { /* keep the default marketplace name */ }
|
|
300
|
+
if (!fs.existsSync(codexHome)) {
|
|
301
|
+
return { uninstalled: true, source, staging, marketplace, codex_home: codexHome, marketplace_name: marketplaceName, codex: null, note: 'codex home absent; nothing to remove' };
|
|
302
|
+
}
|
|
303
|
+
// The verified removal form is <plugin>@<marketplace>; a bare name is
|
|
304
|
+
// rejected by codex 0.153.4.
|
|
305
|
+
const result = runCodex(['plugin', 'remove', `${PLUGIN_NAME}@${marketplaceName}`, '--json'], { codexCli: options.codexCli, env });
|
|
306
|
+
return { uninstalled: true, source, staging, marketplace, codex_home: codexHome, marketplace_name: marketplaceName, codex: result.json || result.stdout.trim() };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function codexMcpConfig(paths) {
|
|
310
|
+
const command = nativeBinary(MCP_BIN_NAME);
|
|
311
|
+
const tools = CODEX_TOOLS.map((tool) => ` "${tool}",`).join('\n');
|
|
312
|
+
return `[${CODEX_MCP_SECTION}]\ncommand = ${JSON.stringify(command)}\nenabled = true\nrequired = true\nstartup_timeout_sec = 10\ntool_timeout_sec = 304\nenabled_tools = [\n${tools}\n]\ndefault_tools_approval_mode = "prompt"\n\n[${CODEX_MCP_SECTION}.env]\nZCODE_AGENTD_SOCKET = ${JSON.stringify(paths.socket)}\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_status]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_list]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_wait]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_result]\napproval_mode = "auto"\n`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function removeTomlSection(text, section) {
|
|
316
|
+
const lines = text.split(/(?<=\n)/u);
|
|
317
|
+
let removing = false;
|
|
318
|
+
const kept = [];
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
const match = line.match(/^\s*\[([^\]]+)\]\s*\r?\n?$/u);
|
|
321
|
+
if (match) removing = match[1] === section || match[1].startsWith(`${section}.`);
|
|
322
|
+
if (!removing) kept.push(line);
|
|
323
|
+
}
|
|
324
|
+
return kept.join('').replace(/\n{3,}$/u, '\n\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function installMcp(paths, options = {}) {
|
|
328
|
+
// The TOML binding lives inside the Codex home, so an explicit --codex-home
|
|
329
|
+
// (then CODEX_HOME) selects the config file for install and removal alike.
|
|
330
|
+
const config = options.configPath || path.join(codexHomeFor(options, paths), 'config.toml');
|
|
331
|
+
const codexHome = codexHomeFor(options, paths);
|
|
332
|
+
const command = nativeBinary(MCP_BIN_NAME);
|
|
333
|
+
if (options.dryRun) {
|
|
334
|
+
return { dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', platform: 'codex', config, codex_home: codexHome, command, socket: paths.socket };
|
|
335
|
+
}
|
|
336
|
+
if (options.uninstall) {
|
|
337
|
+
const prior = readOptional(config);
|
|
338
|
+
if (prior === null) return { uninstalled: false, platform: 'codex', config, codex_home: codexHome };
|
|
339
|
+
const preserved = removeTomlSection(prior.toString('utf8'), CODEX_MCP_SECTION).replace(/^\n+|\n+$/gu, '');
|
|
340
|
+
atomicWrite(config, Buffer.from(preserved ? `${preserved}\n` : ''));
|
|
341
|
+
return { uninstalled: true, platform: 'codex', config, codex_home: codexHome };
|
|
342
|
+
}
|
|
343
|
+
if (!fs.existsSync(command) && !options.skipNativeProbe) {
|
|
344
|
+
throw new CliError('NATIVE_BINARY_NOT_FOUND', 'npm package does not contain the macOS MCP binary');
|
|
345
|
+
}
|
|
346
|
+
const prior = readOptional(config);
|
|
347
|
+
const base = prior === null ? '' : prior.toString('utf8');
|
|
348
|
+
const preserved = removeTomlSection(base, CODEX_MCP_SECTION).replace(/\s*$/u, '');
|
|
349
|
+
const next = `${preserved ? `${preserved}\n\n` : ''}${codexMcpConfig(paths)}`;
|
|
350
|
+
atomicWrite(config, Buffer.from(next));
|
|
351
|
+
return { installed: true, platform: 'codex', config, codex_home: codexHome, command, socket: paths.socket, tools: [...CODEX_TOOLS] };
|
|
352
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { LAUNCH_AGENT_LABEL, ZCODE_RUNTIME } from '../constants.mjs';
|
|
5
|
+
import { CliError } from '../errors.mjs';
|
|
6
|
+
import { parseConfig } from '../config/read.mjs';
|
|
7
|
+
import { writeConfig } from '../config/write.mjs';
|
|
8
|
+
import { verifyPayload } from './payload.mjs';
|
|
9
|
+
import { pathReport } from './path.mjs';
|
|
10
|
+
import { packageRoot, payloadManifestPath } from './layout.mjs';
|
|
11
|
+
import { updateInstallation } from './update.mjs';
|
|
12
|
+
import { loadInstallState, markInstallStep, removeCreatedDirectories, rollbackFiles, snapshotFile } from './recovery.mjs';
|
|
13
|
+
import { bootstrapService, bootoutService, installLaunchAgent } from './service-macos.mjs';
|
|
14
|
+
|
|
15
|
+
// Standalone-install coordination (S05, AUD-005 decision D1). A plain npm
|
|
16
|
+
// install only stages the package and payload; an explicit `init` installs
|
|
17
|
+
// the STANDALONE SERVICE ONLY — every state-changing step below is
|
|
18
|
+
// host-neutral:
|
|
19
|
+
// verify-payload the staged native payload matches the package version
|
|
20
|
+
// check-path PATH findings are reported, never written
|
|
21
|
+
// create-data private data/log directories
|
|
22
|
+
// write-product-config republish the current agent config schema
|
|
23
|
+
// install-launch-agent the one macOS service template
|
|
24
|
+
// start-service launchctl bootstrap (best-effort, reported)
|
|
25
|
+
// publish-active-payload the verified active identity and retained bytes
|
|
26
|
+
// (B-3): a successful init establishes the version and
|
|
27
|
+
// retention baseline itself, so the standard `npm A ->
|
|
28
|
+
// init A -> use A -> npm B` sequence never depends on an
|
|
29
|
+
// extra "A update" the user was never told to run. The
|
|
30
|
+
// publication reuses the existing locked update owner —
|
|
31
|
+
// no second lifecycle or scheduler — and only runs when
|
|
32
|
+
// this init verified the payload it installed.
|
|
33
|
+
// init no longer probes a fixed ZCode runtime, no longer installs the managed
|
|
34
|
+
// Codex plugin, and no longer claims a Codex home (the pre-D1 steps
|
|
35
|
+
// probe-runtime, install-codex-plugin, and claim-codex-home are gone). A
|
|
36
|
+
// host is bound through the existing EXPLICIT commands — `install-plugin
|
|
37
|
+
// codex|zcode` or `install-mcp` — which own staging, the official codex add,
|
|
38
|
+
// and the D08 registry claim. The pinned ZCode runtime is forwarded to the
|
|
39
|
+
// service only when that installation exists (see service-macos.mjs), so a
|
|
40
|
+
// missing ZCode app or Codex home never blocks standalone setup.
|
|
41
|
+
// Failures roll tracked files back through recovery.mjs; a resumable journal
|
|
42
|
+
// lets `init --resume` continue after environmental failures. Journals from
|
|
43
|
+
// pre-D1 inits may still list the retired codex steps; unknown completed ids
|
|
44
|
+
// are simply never consulted again.
|
|
45
|
+
const HOOK_INSTALLER = ['plugins', 'codex', 'external-subagent', 'scripts', 'install-agent-hooks.mjs'];
|
|
46
|
+
|
|
47
|
+
function hookInstallerPath() {
|
|
48
|
+
return path.join(packageRoot(), ...HOOK_INSTALLER);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function installPlan(paths, options = {}) {
|
|
52
|
+
const manifest = payloadManifestPath() ?? path.join(packageRoot(), 'npm', 'native', 'darwin-arm64', 'payload.json');
|
|
53
|
+
const plan = [
|
|
54
|
+
{ id: 'verify-payload', action: 'verify staged native payload', path: manifest },
|
|
55
|
+
{ id: 'check-path', action: 'report PATH availability without writing profiles' },
|
|
56
|
+
{ id: 'create-data', action: 'create private product data and log directories', paths: [paths.data, paths.logs] },
|
|
57
|
+
{ id: 'write-product-config', action: 'republish the product agent config schema', path: paths.config },
|
|
58
|
+
{ id: 'install-launch-agent', action: 'install daemon LaunchAgent', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
|
|
59
|
+
{ id: 'start-service', action: 'bootstrap the daemon service', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
|
|
60
|
+
{ id: 'publish-active-payload', action: 'publish the verified active payload and retained-byte baseline', path: paths.state },
|
|
61
|
+
];
|
|
62
|
+
if (options.installHooks) plan.splice(plan.findIndex((step) => step.id === 'publish-active-payload'), 0, { id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance });
|
|
63
|
+
return plan;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function installHooks(paths, options = {}) {
|
|
67
|
+
if (options.dryRun) return { dry_run: true, plan: [{ id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance }] };
|
|
68
|
+
const result = spawnSync(process.execPath, [hookInstallerPath(), '--config', paths.zcodeConfig, '--provenance', paths.hookProvenance], { encoding: 'utf8' });
|
|
69
|
+
if (result.error) throw result.error;
|
|
70
|
+
if (result.status !== 0) {
|
|
71
|
+
let failure;
|
|
72
|
+
try { failure = JSON.parse((result.stderr || '').trim()); } catch { /* plain-text failure */ }
|
|
73
|
+
throw new CliError(failure?.code || 'HOOK_INSTALL_FAILED', failure?.error || (result.stderr || 'hook installation failed').trim());
|
|
74
|
+
}
|
|
75
|
+
try { return JSON.parse(result.stdout); } catch { throw new CliError('HOOK_INSTALL_FAILED', 'hook installer returned invalid JSON'); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function runInit(options = {}) {
|
|
79
|
+
const paths = options.paths;
|
|
80
|
+
if (!paths) throw new CliError('INTERNAL_ERROR', 'runInit requires product paths');
|
|
81
|
+
const plan = installPlan(paths, options);
|
|
82
|
+
if (options.dryRun) return { dry_run: true, plan };
|
|
83
|
+
|
|
84
|
+
const skipPayload = Boolean(options.skipPayloadProbe);
|
|
85
|
+
const payload = skipPayload ? { status: 'skipped', platform: null, version: null, files: [] } : verifyPayload();
|
|
86
|
+
const pathFindings = pathReport();
|
|
87
|
+
|
|
88
|
+
const tracked = {
|
|
89
|
+
zcodeConfig: { file: paths.zcodeConfig, snapshot: snapshotFile(paths.zcodeConfig) },
|
|
90
|
+
hookProvenance: { file: paths.hookProvenance, snapshot: snapshotFile(paths.hookProvenance) },
|
|
91
|
+
state: { file: paths.state, snapshot: snapshotFile(paths.state) },
|
|
92
|
+
config: { file: paths.config, snapshot: snapshotFile(paths.config) },
|
|
93
|
+
launchAgent: { file: paths.launchAgent, snapshot: snapshotFile(paths.launchAgent) },
|
|
94
|
+
};
|
|
95
|
+
const directories = {
|
|
96
|
+
data: { path: paths.data, existed: fs.existsSync(paths.data) },
|
|
97
|
+
logs: { path: paths.logs, existed: fs.existsSync(paths.logs) },
|
|
98
|
+
};
|
|
99
|
+
const state = options.resume ? loadInstallState(paths.state) : { schema_version: 1, completed: [] };
|
|
100
|
+
const completed = new Set(state.completed || []);
|
|
101
|
+
const mark = (id) => markInstallStep(paths.state, completed, id);
|
|
102
|
+
const failAt = (id) => {
|
|
103
|
+
if (options._failStep === id) throw new Error(`injected failure at ${id}`);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let service = { action: 'bootstrap', skipped: true, reason: 'not attempted' };
|
|
107
|
+
// A rollback must also undo what this run itself loaded: a failure after a
|
|
108
|
+
// successful bootstrap would otherwise leave a launchd job running binary
|
|
109
|
+
// bytes the rolled-back plist/install-state no longer describe (observed
|
|
110
|
+
// live: a codex-binding conflict stranded a running candidate daemon).
|
|
111
|
+
// A service that was already loaded before this init is never touched.
|
|
112
|
+
let serviceLoadedByThisRun = false;
|
|
113
|
+
let baseline = null;
|
|
114
|
+
try {
|
|
115
|
+
if (!completed.has('verify-payload')) mark('verify-payload');
|
|
116
|
+
if (!completed.has('check-path')) mark('check-path');
|
|
117
|
+
if (!completed.has('create-data')) {
|
|
118
|
+
fs.mkdirSync(paths.data, { recursive: true, mode: 0o700 });
|
|
119
|
+
fs.mkdirSync(paths.logs, { recursive: true, mode: 0o700 });
|
|
120
|
+
mark('create-data');
|
|
121
|
+
}
|
|
122
|
+
if (!completed.has('write-product-config')) {
|
|
123
|
+
// The daemon takes its database/socket/runtime from service arguments,
|
|
124
|
+
// never from this file; drop the retired top-level path fields so an
|
|
125
|
+
// install over an older local config republishes the current schema.
|
|
126
|
+
// S05-F01: `subagents.zcode.runtime_path` was a first-class config key
|
|
127
|
+
// before D1 rejected it, so a pre-S05 install can carry it in either
|
|
128
|
+
// the schema-2 or the legacy schema-1 shape. Retire it here — before
|
|
129
|
+
// parseConfig migrates/validates — so `npm new -> init` self-heals such
|
|
130
|
+
// configs instead of failing closed at the read gate (which stays
|
|
131
|
+
// fully strict for every other read/write path).
|
|
132
|
+
const prior = fs.existsSync(paths.config)
|
|
133
|
+
? JSON.parse(fs.readFileSync(paths.config, 'utf8'))
|
|
134
|
+
: {};
|
|
135
|
+
for (const field of ['runtime', 'database', 'socket']) delete prior[field];
|
|
136
|
+
for (const map of ['subagents', 'agents']) delete prior[map]?.zcode?.runtime_path;
|
|
137
|
+
writeConfig(paths.config, parseConfig(prior));
|
|
138
|
+
failAt('write-product-config');
|
|
139
|
+
mark('write-product-config');
|
|
140
|
+
}
|
|
141
|
+
if (!completed.has('install-launch-agent')) {
|
|
142
|
+
installLaunchAgent(paths);
|
|
143
|
+
failAt('install-launch-agent');
|
|
144
|
+
mark('install-launch-agent');
|
|
145
|
+
}
|
|
146
|
+
if (!completed.has('start-service') && !options.skipServiceStart) {
|
|
147
|
+
service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl });
|
|
148
|
+
serviceLoadedByThisRun = !service.skipped && !service.already_loaded;
|
|
149
|
+
mark('start-service');
|
|
150
|
+
}
|
|
151
|
+
if (options.installHooks && !completed.has('install-hooks')) {
|
|
152
|
+
installHooks(paths);
|
|
153
|
+
mark('install-hooks');
|
|
154
|
+
}
|
|
155
|
+
// The baseline publication runs LAST and only for an init that verified
|
|
156
|
+
// the payload it installed (a skipped probe — test harnesses only —
|
|
157
|
+
// leaves the resume journal as the sole state). It reuses the locked
|
|
158
|
+
// update owner, so the active identity and retained bytes are published
|
|
159
|
+
// under the exact verification/retention rules every later update
|
|
160
|
+
// follows. The Codex-home sync stays deferred: init binds no host, so
|
|
161
|
+
// any registered homes belong to explicit install-plugin/install-mcp
|
|
162
|
+
// bindings and remain the reconcile owner's to refresh. The step is
|
|
163
|
+
// recorded in the in-memory report only — writing the schema-1 journal
|
|
164
|
+
// here would clobber the schema-2 activation state it just published.
|
|
165
|
+
if (payload.status === 'verified') {
|
|
166
|
+
failAt('publish-active-payload');
|
|
167
|
+
const published = updateInstallation(paths, { deferCodexSync: true });
|
|
168
|
+
if (published.phase !== 'active' || !published.active) {
|
|
169
|
+
throw new CliError('INIT_BASELINE_FAILED', `activation baseline publication did not complete (phase=${published.phase ?? 'none'})`);
|
|
170
|
+
}
|
|
171
|
+
completed.add('publish-active-payload');
|
|
172
|
+
baseline = {
|
|
173
|
+
version: published.active.version,
|
|
174
|
+
entry_sha256: published.active.entry_sha256,
|
|
175
|
+
daemon_entry: published.active.daemon_entry,
|
|
176
|
+
daemon_entry_sha256: published.active.daemon_entry_sha256,
|
|
177
|
+
retained_root: published.active.retained?.root ?? null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const rollbackErrors = [
|
|
182
|
+
...rollbackFiles(tracked),
|
|
183
|
+
...removeCreatedDirectories(directories),
|
|
184
|
+
];
|
|
185
|
+
if (serviceLoadedByThisRun) {
|
|
186
|
+
try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl }); } catch (bootoutError) {
|
|
187
|
+
rollbackErrors.push(`service rollback (bootout) failed: ${bootoutError.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (rollbackErrors.length > 0) error.rollbackErrors = rollbackErrors;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
installed: true,
|
|
195
|
+
resumed: Boolean(options.resume),
|
|
196
|
+
completed: [...completed],
|
|
197
|
+
// Honest runtime report instead of a hard dependency: the pinned ZCode
|
|
198
|
+
// runtime is an adapter capability, observed and forwarded by the service
|
|
199
|
+
// template only when present.
|
|
200
|
+
runtime: { path: ZCODE_RUNTIME, present: fs.existsSync(ZCODE_RUNTIME) },
|
|
201
|
+
payload,
|
|
202
|
+
baseline,
|
|
203
|
+
path_report: pathFindings,
|
|
204
|
+
service,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DAEMON_BIN_NAME, MCP_BIN_NAME, NATIVE_DIR_NAME, PRODUCT_NAME, VERSION } from '../constants.mjs';
|
|
5
|
+
|
|
6
|
+
// Package and payload layout is the single source of truth for where the
|
|
7
|
+
// staged npm artifact keeps its managed entry points. The MCP binding and
|
|
8
|
+
// the LaunchAgent reference absolute paths below this root, never a shell or
|
|
9
|
+
// GUI PATH lookup. Identity names derive from the variant token in
|
|
10
|
+
// constants.mjs, so the debug variant resolves its own payload directory,
|
|
11
|
+
// binary names, and plugin source without a parallel code path.
|
|
12
|
+
export const PLUGIN_NAME = PRODUCT_NAME;
|
|
13
|
+
export const NATIVE_PLATFORM = 'darwin-arm64';
|
|
14
|
+
export const NATIVE_BINARIES = Object.freeze([DAEMON_BIN_NAME, MCP_BIN_NAME]);
|
|
15
|
+
|
|
16
|
+
export function packageRoot() {
|
|
17
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function nativePlatform(platform = process.platform, arch = process.arch) {
|
|
21
|
+
if (platform === 'darwin' && arch === 'arm64') return NATIVE_PLATFORM;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nativePayloadDir(platform = nativePlatform()) {
|
|
26
|
+
if (platform !== NATIVE_PLATFORM) return null;
|
|
27
|
+
return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function nativeBinary(name, platform = nativePlatform()) {
|
|
31
|
+
const dir = nativePayloadDir(platform);
|
|
32
|
+
if (!dir || !NATIVE_BINARIES.includes(name)) return null;
|
|
33
|
+
return path.join(dir, name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function payloadManifestPath(platform = nativePlatform()) {
|
|
37
|
+
const dir = nativePayloadDir(platform);
|
|
38
|
+
return dir === null ? null : path.join(dir, 'payload.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pluginSourceRoot() {
|
|
42
|
+
return path.join(packageRoot(), 'plugins', 'codex', PLUGIN_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The npm package version and the in-repo CLI version must agree; payload
|
|
46
|
+
// verification rejects any drift between the two.
|
|
47
|
+
export function packageVersion() {
|
|
48
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
|
|
49
|
+
return manifest.version;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function cliVersion() {
|
|
53
|
+
return VERSION;
|
|
54
|
+
}
|