@catalyst-cloud/cli 0.16.33 → 0.16.34
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/CHANGELOG.md +8 -0
- package/README.md +20 -1
- package/dist/args.js +25 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +36 -46
- package/dist/codex-attribution.js +685 -0
- package/dist/codex-env.js +222 -0
- package/dist/config.js +4 -0
- package/dist/runner-auto-update.js +5 -3
- package/dist/runner-update.js +48 -16
- package/dist/update-inventory.js +13 -28
- package/dist/update-plugin.js +181 -58
- package/dist/update-skill-source.js +145 -0
- package/dist/update-skill-state.js +175 -0
- package/dist/update-skills.js +247 -48
- package/dist/update.js +62 -51
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/skill-sources.md +5 -4
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// codex-attribution.ts: the launch hooks `catalyst setup` installs around `catalyst codex-env`, so
|
|
2
|
+
// every Codex launch on this machine names the coding account it runs as.
|
|
3
|
+
//
|
|
4
|
+
// A terminal launch: one marked block in ~/.zshrc and ~/.bashrc defines a `codex` function. It asks
|
|
5
|
+
// `catalyst codex-env --print` about the Codex home in effect and exports the answer for that one
|
|
6
|
+
// call only.
|
|
7
|
+
// A Dock launch (macOS): the dev.catalystcloud.codex-attribution LaunchAgent runs
|
|
8
|
+
// `catalyst codex-env --apply` at login and whenever the default Codex home's login file changes.
|
|
9
|
+
//
|
|
10
|
+
// Which rc files: ~/.zshrc ($ZDOTDIR/.zshrc when ZDOTDIR is set) and ~/.bashrc, each only when it
|
|
11
|
+
// exists or is the rc file of the login shell ($SHELL). A zsh-only Mac never gains a ~/.bashrc.
|
|
12
|
+
// A file that defines `codex` itself (a function or an alias outside the block) is never written,
|
|
13
|
+
// and neither is a link to a file outside the home folder. Files are read and written as latin1, so
|
|
14
|
+
// every byte outside the block survives exactly, CRLF included.
|
|
15
|
+
//
|
|
16
|
+
// Nothing is installed when Codex is not on this computer (codexPresence); a later run adds it.
|
|
17
|
+
//
|
|
18
|
+
// Removal puts each file back byte for byte: the block records inside itself whether setup created
|
|
19
|
+
// the file or added a line break before it. It unloads and deletes the agent, and takes back out of
|
|
20
|
+
// the launch environment exactly the two attributes `--apply` sets (`codex-env --apply` keeps every
|
|
21
|
+
// other pair already there, so removal does too).
|
|
22
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
23
|
+
import { userInfo } from "node:os";
|
|
24
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
25
|
+
import { ACCOUNT_SLOT_ATTRIBUTE, HOST_NAME_ATTRIBUTE, attrValue, defaultCodexHome, readCodexAccountId, readCodexAccountsCache, resolveSlot, } from "./codex-env.js";
|
|
26
|
+
import { configDirFor, loadConfig } from "./config.js";
|
|
27
|
+
import { defaultRun, scheduledPath, writeFileAtomic, xml, } from "./runner-auto-update.js";
|
|
28
|
+
export const CODEX_ATTRIBUTION_LABEL = "dev.catalystcloud.codex-attribution";
|
|
29
|
+
export const BLOCK_BEGIN = "# >>> catalyst codex attribution >>>";
|
|
30
|
+
export const BLOCK_END = "# <<< catalyst codex attribution <<<";
|
|
31
|
+
/** Written into the block when setup created the file for it, so removal deletes the file again. */
|
|
32
|
+
export const CREATED_NOTE = "# (setup created this file for the block; removing the block deletes the file)";
|
|
33
|
+
/** Written when the file did not end in a line break, so removal takes back the one setup added. */
|
|
34
|
+
export const NEWLINE_NOTE = "# (setup added the line break before this block; removing the block takes it back)";
|
|
35
|
+
const ENV_NAME = "OTEL_RESOURCE_ATTRIBUTES";
|
|
36
|
+
const defaultFileOps = {
|
|
37
|
+
writeFile: (path, data, options) => writeFileSync(path, data, options),
|
|
38
|
+
rename: renameSync,
|
|
39
|
+
unlink: unlinkSync,
|
|
40
|
+
};
|
|
41
|
+
const fileOps = (deps) => ({
|
|
42
|
+
...defaultFileOps,
|
|
43
|
+
...deps.files,
|
|
44
|
+
});
|
|
45
|
+
/** A temp file beside the target with the target's mode set past the umask, then a rename over the
|
|
46
|
+
* target (never over a link that names it). A failure removes the temp file, so the target is
|
|
47
|
+
* either as it was or wholly new. */
|
|
48
|
+
export function writeRcAtomic(path, data, mode, ops = defaultFileOps) {
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
51
|
+
try {
|
|
52
|
+
ops.writeFile(temp, data, { mode });
|
|
53
|
+
chmodSync(temp, mode);
|
|
54
|
+
ops.rename(temp, path);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(temp);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// The temp file was never created.
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The desktop app's bundle name. */
|
|
67
|
+
export const CODEX_APP_BUNDLE = "Codex.app";
|
|
68
|
+
/** The block, always ending in one line break. The function runs the real codex with `command`. */
|
|
69
|
+
export function codexBlock(notes = {}) {
|
|
70
|
+
return [
|
|
71
|
+
BLOCK_BEGIN,
|
|
72
|
+
...(notes.created ? [CREATED_NOTE] : []),
|
|
73
|
+
...(notes.newlineAdded ? [NEWLINE_NOTE] : []),
|
|
74
|
+
"# Added by catalyst setup: each codex launch names the coding account it runs as.",
|
|
75
|
+
"# Remove it with: catalyst setup --remove-codex-attribution",
|
|
76
|
+
"function codex {",
|
|
77
|
+
" local catalyst_attributes",
|
|
78
|
+
' catalyst_attributes="$(command catalyst codex-env --print --codex-home "${CODEX_HOME:-$HOME/.codex}" 2>/dev/null)" || catalyst_attributes=""',
|
|
79
|
+
' if [ -n "$catalyst_attributes" ]; then',
|
|
80
|
+
' OTEL_RESOURCE_ATTRIBUTES="$catalyst_attributes" command codex "$@"',
|
|
81
|
+
" else",
|
|
82
|
+
' command codex "$@"',
|
|
83
|
+
" fi",
|
|
84
|
+
"}",
|
|
85
|
+
BLOCK_END,
|
|
86
|
+
"",
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
function linesOf(text) {
|
|
90
|
+
const out = [];
|
|
91
|
+
let start = 0;
|
|
92
|
+
while (start < text.length) {
|
|
93
|
+
const nl = text.indexOf("\n", start);
|
|
94
|
+
const end = nl === -1 ? text.length : nl + 1;
|
|
95
|
+
out.push({
|
|
96
|
+
start,
|
|
97
|
+
end,
|
|
98
|
+
text: text.slice(start, nl === -1 ? end : nl).replace(/\r$/, ""),
|
|
99
|
+
});
|
|
100
|
+
start = end;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/** Every complete block in order, or "malformed" when a marker has no partner. */
|
|
105
|
+
export function findBlocks(text) {
|
|
106
|
+
const blocks = [];
|
|
107
|
+
let open = null;
|
|
108
|
+
for (const line of linesOf(text)) {
|
|
109
|
+
if (open === null) {
|
|
110
|
+
if (line.text === BLOCK_BEGIN)
|
|
111
|
+
open = { start: line.start, notes: new Set() };
|
|
112
|
+
else if (line.text === BLOCK_END)
|
|
113
|
+
return "malformed";
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (line.text === BLOCK_BEGIN)
|
|
117
|
+
return "malformed";
|
|
118
|
+
if (line.text === BLOCK_END) {
|
|
119
|
+
blocks.push({
|
|
120
|
+
start: open.start,
|
|
121
|
+
end: line.end,
|
|
122
|
+
created: open.notes.has(CREATED_NOTE),
|
|
123
|
+
newlineAdded: open.notes.has(NEWLINE_NOTE),
|
|
124
|
+
});
|
|
125
|
+
open = null;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
open.notes.add(line.text);
|
|
129
|
+
}
|
|
130
|
+
return open === null ? blocks : "malformed";
|
|
131
|
+
}
|
|
132
|
+
function outsideBlocks(text, blocks) {
|
|
133
|
+
let out = "";
|
|
134
|
+
let cursor = 0;
|
|
135
|
+
for (const b of blocks) {
|
|
136
|
+
out += text.slice(cursor, b.start);
|
|
137
|
+
cursor = b.end;
|
|
138
|
+
}
|
|
139
|
+
return out + text.slice(cursor);
|
|
140
|
+
}
|
|
141
|
+
/** `alias codex=…` (zsh's `alias -g` too), `codex()` and `function codex`. Never `codex_helper` or a comment. */
|
|
142
|
+
const CODEX_DEFINITIONS = [
|
|
143
|
+
/^\s*alias(?:\s+-\S+)*\s+(?:\S+\s+)*codex=/,
|
|
144
|
+
/^\s*function\s+codex(?:\s*\(\s*\))?(?=[\s{]|$)/,
|
|
145
|
+
/^\s*codex\s*\(\s*\)/,
|
|
146
|
+
];
|
|
147
|
+
export function definesCodex(text) {
|
|
148
|
+
return linesOf(text).some(({ text: line }) => !/^\s*#/.test(line) && CODEX_DEFINITIONS.some((re) => re.test(line)));
|
|
149
|
+
}
|
|
150
|
+
function present(path) {
|
|
151
|
+
try {
|
|
152
|
+
lstatSync(path);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function shellRcFiles(ctx) {
|
|
160
|
+
const zdot = ctx.env.ZDOTDIR?.trim();
|
|
161
|
+
const login = basename(ctx.env.SHELL?.trim() ?? "");
|
|
162
|
+
const files = [
|
|
163
|
+
["zsh", join(zdot && isAbsolute(zdot) ? zdot : ctx.home, ".zshrc")],
|
|
164
|
+
["bash", join(ctx.home, ".bashrc")],
|
|
165
|
+
];
|
|
166
|
+
return files.map(([shell, path]) => ({
|
|
167
|
+
shell,
|
|
168
|
+
path,
|
|
169
|
+
wanted: present(path) || login === shell,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
function realOr(path) {
|
|
173
|
+
try {
|
|
174
|
+
return realpathSync(path);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return path;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function inside(path, home) {
|
|
181
|
+
const rel = relative(realOr(home), path);
|
|
182
|
+
return (rel !== "" &&
|
|
183
|
+
rel !== ".." &&
|
|
184
|
+
!rel.startsWith(`..${sep}`) &&
|
|
185
|
+
!isAbsolute(rel));
|
|
186
|
+
}
|
|
187
|
+
function sameHome(a, b) {
|
|
188
|
+
return realOr(a) === realOr(b);
|
|
189
|
+
}
|
|
190
|
+
/** Whether HOME is this account's own home folder. A test or a borrowed HOME is not. */
|
|
191
|
+
export function ownsHome(home, deps = {}) {
|
|
192
|
+
return sameHome(home, (deps.realHome ?? (() => userInfo().homedir))());
|
|
193
|
+
}
|
|
194
|
+
/** A link is followed only to a file inside the home folder; the file it names is the one written. */
|
|
195
|
+
function rcTarget(path, home) {
|
|
196
|
+
let link;
|
|
197
|
+
try {
|
|
198
|
+
link = lstatSync(path);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return { kind: "missing" };
|
|
202
|
+
}
|
|
203
|
+
let target = path;
|
|
204
|
+
if (link.isSymbolicLink()) {
|
|
205
|
+
try {
|
|
206
|
+
target = realpathSync(path);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return {
|
|
210
|
+
kind: "skip",
|
|
211
|
+
why: "it is a link to a file that does not exist",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!inside(target, home))
|
|
215
|
+
return {
|
|
216
|
+
kind: "skip",
|
|
217
|
+
why: `it is a link to ${target}, outside your home folder, which setup does not write`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const stat = statSync(target);
|
|
221
|
+
if (!stat.isFile())
|
|
222
|
+
return { kind: "skip", why: "it is not a regular file" };
|
|
223
|
+
return { kind: "file", target, mode: stat.mode & 0o7777 };
|
|
224
|
+
}
|
|
225
|
+
const message = (err) => err instanceof Error ? err.message : String(err);
|
|
226
|
+
/** One file: add the block, replace it in place (a second copy is dropped), or leave the file alone.
|
|
227
|
+
* Null when the file does not exist and setup has no reason to create it. */
|
|
228
|
+
export function installShellHook(file, home, ops = defaultFileOps) {
|
|
229
|
+
const where = rcTarget(file.path, home);
|
|
230
|
+
if (where.kind === "skip")
|
|
231
|
+
return { path: file.path, state: "skipped", why: where.why };
|
|
232
|
+
if (where.kind === "missing") {
|
|
233
|
+
if (!file.wanted)
|
|
234
|
+
return null;
|
|
235
|
+
writeRcAtomic(file.path, codexBlock({ created: true }), 0o644, ops);
|
|
236
|
+
return { path: file.path, state: "added" };
|
|
237
|
+
}
|
|
238
|
+
const before = readFileSync(where.target, "latin1");
|
|
239
|
+
const blocks = findBlocks(before);
|
|
240
|
+
if (blocks === "malformed")
|
|
241
|
+
return {
|
|
242
|
+
path: file.path,
|
|
243
|
+
state: "skipped",
|
|
244
|
+
why: `it has a "${BLOCK_BEGIN}" or "${BLOCK_END}" line without its partner; fix or delete that line, then run catalyst setup again`,
|
|
245
|
+
};
|
|
246
|
+
if (definesCodex(outsideBlocks(before, blocks)))
|
|
247
|
+
return {
|
|
248
|
+
path: file.path,
|
|
249
|
+
state: "skipped",
|
|
250
|
+
why: "it already defines codex itself, as a function or an alias. To use the hook, remove that definition and run catalyst setup again",
|
|
251
|
+
};
|
|
252
|
+
let after;
|
|
253
|
+
const first = blocks[0];
|
|
254
|
+
if (first === undefined) {
|
|
255
|
+
after =
|
|
256
|
+
before === "" || before.endsWith("\n")
|
|
257
|
+
? before + codexBlock()
|
|
258
|
+
: `${before}\n${codexBlock({ newlineAdded: true })}`;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
after =
|
|
262
|
+
before.slice(0, first.start) +
|
|
263
|
+
codexBlock({ created: first.created, newlineAdded: first.newlineAdded });
|
|
264
|
+
let cursor = first.end;
|
|
265
|
+
for (const b of blocks.slice(1)) {
|
|
266
|
+
after += before.slice(cursor, b.start);
|
|
267
|
+
cursor = b.end;
|
|
268
|
+
}
|
|
269
|
+
after += before.slice(cursor);
|
|
270
|
+
}
|
|
271
|
+
if (after === before)
|
|
272
|
+
return { path: file.path, state: "unchanged" };
|
|
273
|
+
writeRcAtomic(where.target, Buffer.from(after, "latin1"), where.mode, ops);
|
|
274
|
+
return { path: file.path, state: first === undefined ? "added" : "replaced" };
|
|
275
|
+
}
|
|
276
|
+
/** One file: take every block out. Null when the file has none. */
|
|
277
|
+
export function removeShellHook(path, home, ops = defaultFileOps) {
|
|
278
|
+
let link;
|
|
279
|
+
try {
|
|
280
|
+
link = lstatSync(path);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
let target = path;
|
|
286
|
+
if (link.isSymbolicLink()) {
|
|
287
|
+
try {
|
|
288
|
+
target = realpathSync(path);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (!inside(target, home)) {
|
|
294
|
+
let text = "";
|
|
295
|
+
try {
|
|
296
|
+
text = readFileSync(target, "latin1");
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return text.includes(BLOCK_BEGIN)
|
|
302
|
+
? {
|
|
303
|
+
path,
|
|
304
|
+
state: "skipped",
|
|
305
|
+
why: `it is a link to ${target}, outside your home folder; remove the block from that file yourself`,
|
|
306
|
+
}
|
|
307
|
+
: null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const stat = statSync(target);
|
|
311
|
+
if (!stat.isFile())
|
|
312
|
+
return null;
|
|
313
|
+
const before = readFileSync(target, "latin1");
|
|
314
|
+
const blocks = findBlocks(before);
|
|
315
|
+
if (blocks === "malformed")
|
|
316
|
+
return {
|
|
317
|
+
path,
|
|
318
|
+
state: "skipped",
|
|
319
|
+
why: `it has a "${BLOCK_BEGIN}" or "${BLOCK_END}" line without its partner; remove the block yourself`,
|
|
320
|
+
};
|
|
321
|
+
if (blocks.length === 0)
|
|
322
|
+
return null;
|
|
323
|
+
let after = before;
|
|
324
|
+
let created = false;
|
|
325
|
+
for (const b of [...blocks].reverse()) {
|
|
326
|
+
let start = b.start;
|
|
327
|
+
// The break setup added belongs to the block only while the block still ends the file.
|
|
328
|
+
if (b.newlineAdded &&
|
|
329
|
+
b.end === after.length &&
|
|
330
|
+
start > 0 &&
|
|
331
|
+
after[start - 1] === "\n")
|
|
332
|
+
start -= 1;
|
|
333
|
+
after = after.slice(0, start) + after.slice(b.end);
|
|
334
|
+
created ||= b.created;
|
|
335
|
+
}
|
|
336
|
+
if (created && after === "")
|
|
337
|
+
ops.unlink(target);
|
|
338
|
+
else
|
|
339
|
+
writeRcAtomic(target, Buffer.from(after, "latin1"), stat.mode & 0o7777, ops);
|
|
340
|
+
return { path, state: "removed" };
|
|
341
|
+
}
|
|
342
|
+
function hasBlock(path) {
|
|
343
|
+
try {
|
|
344
|
+
const blocks = findBlocks(readFileSync(path, "latin1"));
|
|
345
|
+
return blocks !== "malformed" && blocks.length > 0;
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
export function codexAgentPlistPath(home) {
|
|
352
|
+
return join(home, "Library", "LaunchAgents", `${CODEX_ATTRIBUTION_LABEL}.plist`);
|
|
353
|
+
}
|
|
354
|
+
/** The default Codex home's login file: the agent runs again whenever it changes. */
|
|
355
|
+
export function codexLoginWatchPath(home) {
|
|
356
|
+
return join(home, ".codex", "auth.json");
|
|
357
|
+
}
|
|
358
|
+
export function codexAgentPlist(o) {
|
|
359
|
+
const log = join(configDirFor(o.home), "codex-attribution.log");
|
|
360
|
+
return [
|
|
361
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
362
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
363
|
+
'<plist version="1.0"><dict>',
|
|
364
|
+
`<key>Label</key><string>${CODEX_ATTRIBUTION_LABEL}</string>`,
|
|
365
|
+
`<key>ProgramArguments</key><array>${[o.cli, "codex-env", "--apply"].map((a) => `<string>${xml(a)}</string>`).join("")}</array>`,
|
|
366
|
+
`<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(o.path)}</string><key>HOME</key><string>${xml(o.home)}</string></dict>`,
|
|
367
|
+
"<key>RunAtLoad</key><true/>",
|
|
368
|
+
`<key>WatchPaths</key><array><string>${xml(codexLoginWatchPath(o.home))}</string></array>`,
|
|
369
|
+
"<key>ProcessType</key><string>Background</string>",
|
|
370
|
+
`<key>StandardOutPath</key><string>${xml(log)}</string>`,
|
|
371
|
+
`<key>StandardErrorPath</key><string>${xml(log)}</string>`,
|
|
372
|
+
"</dict></plist>",
|
|
373
|
+
"",
|
|
374
|
+
].join("\n");
|
|
375
|
+
}
|
|
376
|
+
function resolved(ctx, deps) {
|
|
377
|
+
const cli = deps.cli ?? resolve(process.argv[1] ?? "catalyst");
|
|
378
|
+
return {
|
|
379
|
+
platform: deps.platform ?? process.platform,
|
|
380
|
+
run: deps.run ?? defaultRun,
|
|
381
|
+
uid: deps.uid ?? process.getuid?.() ?? 0,
|
|
382
|
+
cli,
|
|
383
|
+
path: deps.path ?? scheduledPath(cli, process.execPath, ctx.env.PATH),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const readOr = (path) => {
|
|
387
|
+
try {
|
|
388
|
+
return readFileSync(path, "utf8");
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
/** Write, load and verify the agent, as the runner update agent does. An unchanged, loaded agent is left running. */
|
|
395
|
+
export function installCodexAgent(ctx, deps = {}) {
|
|
396
|
+
const d = resolved(ctx, deps);
|
|
397
|
+
if (d.platform !== "darwin")
|
|
398
|
+
return { state: "not_macos" };
|
|
399
|
+
if (!ownsHome(ctx.home, deps))
|
|
400
|
+
return { state: "other_home" };
|
|
401
|
+
const path = codexAgentPlistPath(ctx.home);
|
|
402
|
+
const text = codexAgentPlist({ cli: d.cli, path: d.path, home: ctx.home });
|
|
403
|
+
const target = `gui/${d.uid}/${CODEX_ATTRIBUTION_LABEL}`;
|
|
404
|
+
const loaded = () => d.run("launchctl", ["print", target]).status === 0;
|
|
405
|
+
const wasLoaded = loaded();
|
|
406
|
+
if (readOr(path) === text && wasLoaded)
|
|
407
|
+
return { state: "unchanged", path };
|
|
408
|
+
try {
|
|
409
|
+
if (wasLoaded)
|
|
410
|
+
d.run("launchctl", ["bootout", target]);
|
|
411
|
+
// launchd opens the log itself and starts nothing when its folder is missing.
|
|
412
|
+
mkdirSync(configDirFor(ctx.home), { recursive: true });
|
|
413
|
+
writeFileAtomic(path, text);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
return {
|
|
417
|
+
state: "failed",
|
|
418
|
+
detail: `could not write ${path}: ${message(err)}`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const boot = d.run("launchctl", ["bootstrap", `gui/${d.uid}`, path]);
|
|
422
|
+
if (boot.status !== 0)
|
|
423
|
+
return {
|
|
424
|
+
state: "failed",
|
|
425
|
+
detail: `launchctl bootstrap exited ${boot.status}: ${boot.stderr.trim().slice(0, 160)}`,
|
|
426
|
+
};
|
|
427
|
+
if (!loaded())
|
|
428
|
+
return {
|
|
429
|
+
state: "failed",
|
|
430
|
+
detail: `launchd did not keep ${CODEX_ATTRIBUTION_LABEL} loaded`,
|
|
431
|
+
};
|
|
432
|
+
// RunAtLoad already starts it; a busy Mac can hold that start back (runner-auto-update.ts), so ask once more.
|
|
433
|
+
d.run("launchctl", ["kickstart", target]);
|
|
434
|
+
return { state: "loaded", path };
|
|
435
|
+
}
|
|
436
|
+
/** The value with `--apply`'s two pairs taken out, and whether `--apply` had set them at all. */
|
|
437
|
+
export function withoutAppliedAttributes(value) {
|
|
438
|
+
const pairs = value.split(",").filter((pair) => pair.trim() !== "");
|
|
439
|
+
const key = (pair) => (pair.includes("=") ? pair.slice(0, pair.indexOf("=")) : pair).trim();
|
|
440
|
+
return {
|
|
441
|
+
applied: pairs.some((pair) => key(pair) === ACCOUNT_SLOT_ATTRIBUTE),
|
|
442
|
+
rest: pairs
|
|
443
|
+
.filter((pair) => key(pair) !== ACCOUNT_SLOT_ATTRIBUTE &&
|
|
444
|
+
key(pair) !== HOST_NAME_ATTRIBUTE)
|
|
445
|
+
.join(","),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/** A value without the coding-account pair is not ours and stays as it is. */
|
|
449
|
+
function undoLaunchEnvironment(run) {
|
|
450
|
+
const read = run("launchctl", ["getenv", ENV_NAME]);
|
|
451
|
+
const { applied, rest } = withoutAppliedAttributes(read.status === 0 ? read.stdout.trim() : "");
|
|
452
|
+
if (!applied)
|
|
453
|
+
return "untouched";
|
|
454
|
+
const done = rest === ""
|
|
455
|
+
? run("launchctl", ["unsetenv", ENV_NAME])
|
|
456
|
+
: run("launchctl", ["setenv", ENV_NAME, rest]);
|
|
457
|
+
return done.status !== 0 ? "failed" : rest === "" ? "unset" : "kept_others";
|
|
458
|
+
}
|
|
459
|
+
export function removeCodexAgent(ctx, deps = {}) {
|
|
460
|
+
const d = resolved(ctx, deps);
|
|
461
|
+
if (d.platform !== "darwin")
|
|
462
|
+
return { agent: "not_macos", environment: "untouched" };
|
|
463
|
+
if (!ownsHome(ctx.home, deps))
|
|
464
|
+
return { agent: "other_home", environment: "untouched" };
|
|
465
|
+
const path = codexAgentPlistPath(ctx.home);
|
|
466
|
+
const target = `gui/${d.uid}/${CODEX_ATTRIBUTION_LABEL}`;
|
|
467
|
+
const loaded = () => d.run("launchctl", ["print", target]).status === 0;
|
|
468
|
+
const was = existsSync(path) || loaded();
|
|
469
|
+
if (was)
|
|
470
|
+
d.run("launchctl", ["bootout", target]);
|
|
471
|
+
// A loaded agent would set the value again, so the environment waits until it is gone.
|
|
472
|
+
if (loaded())
|
|
473
|
+
return {
|
|
474
|
+
agent: "failed",
|
|
475
|
+
detail: `it is still loaded: run launchctl bootout ${target}`,
|
|
476
|
+
environment: "untouched",
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
if (existsSync(path))
|
|
480
|
+
fileOps(deps).unlink(path);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
return {
|
|
484
|
+
agent: "failed",
|
|
485
|
+
detail: `could not delete ${path}: ${message(err)}`,
|
|
486
|
+
environment: undoLaunchEnvironment(d.run),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
agent: was ? "removed" : "absent",
|
|
491
|
+
environment: undoLaunchEnvironment(d.run),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function tilde(path, home) {
|
|
495
|
+
const rel = relative(home, path);
|
|
496
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)
|
|
497
|
+
? `~/${rel}`
|
|
498
|
+
: path;
|
|
499
|
+
}
|
|
500
|
+
function ageText(seconds) {
|
|
501
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
502
|
+
if (seconds < 60)
|
|
503
|
+
return "under a minute";
|
|
504
|
+
if (seconds < 3600)
|
|
505
|
+
return plural(Math.floor(seconds / 60), "minute");
|
|
506
|
+
if (seconds < 2 * 86400)
|
|
507
|
+
return plural(Math.floor(seconds / 3600), "hour");
|
|
508
|
+
return plural(Math.floor(seconds / 86400), "day");
|
|
509
|
+
}
|
|
510
|
+
/** `catalyst status`'s one line. Read only and offline: the cached account map, never a refresh. */
|
|
511
|
+
export function codexAttributionStatusLine(ctx, deps = {}) {
|
|
512
|
+
const d = resolved(ctx, deps);
|
|
513
|
+
const hooked = shellRcFiles(ctx)
|
|
514
|
+
.filter((file) => hasBlock(file.path))
|
|
515
|
+
.map((file) => tilde(file.path, ctx.home));
|
|
516
|
+
const agent = d.platform !== "darwin"
|
|
517
|
+
? null
|
|
518
|
+
: !existsSync(codexAgentPlistPath(ctx.home))
|
|
519
|
+
? "not installed"
|
|
520
|
+
: d.run("launchctl", [
|
|
521
|
+
"print",
|
|
522
|
+
`gui/${d.uid}/${CODEX_ATTRIBUTION_LABEL}`,
|
|
523
|
+
]).status === 0
|
|
524
|
+
? "loaded"
|
|
525
|
+
: "not loaded";
|
|
526
|
+
const installed = hooked.length > 0 && (agent === null || agent === "loaded");
|
|
527
|
+
let workspace = null;
|
|
528
|
+
try {
|
|
529
|
+
workspace = loadConfig(ctx.home)?.account ?? null;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
workspace = null;
|
|
533
|
+
}
|
|
534
|
+
const cached = workspace === null ? null : readCodexAccountsCache(ctx.home);
|
|
535
|
+
const cache = cached !== null && cached.workspace === workspace ? cached : null;
|
|
536
|
+
const codexHome = defaultCodexHome(ctx);
|
|
537
|
+
const slot = attrValue(resolveSlot(readCodexAccountId(codexHome), cache));
|
|
538
|
+
const fetchedMs = cache === null ? Number.NaN : Date.parse(cache.fetchedAt);
|
|
539
|
+
const age = Number.isFinite(fetchedMs)
|
|
540
|
+
? `account list cached ${ageText(Math.max(0, Math.floor((ctx.now().getTime() - fetchedMs) / 1000)))} ago`
|
|
541
|
+
: "no account list cached";
|
|
542
|
+
const parts = [
|
|
543
|
+
hooked.length > 0
|
|
544
|
+
? `shell hook in ${hooked.join(" and ")}`
|
|
545
|
+
: "no shell hook",
|
|
546
|
+
...(agent === null ? [] : [`login agent ${agent}`]),
|
|
547
|
+
];
|
|
548
|
+
return `Codex attribution: ${installed ? "installed" : "not installed"} (${parts.join(", ")}); ${tilde(codexHome, ctx.home)} resolves to coding account ${slot}, ${age}.${installed ? "" : " Run catalyst setup to add it."}`;
|
|
549
|
+
}
|
|
550
|
+
/** How setup knows Codex is on this computer: an executable `codex` on the PATH setup runs with
|
|
551
|
+
* (the command a terminal launch runs), or the desktop app's bundle (what a Dock launch starts).
|
|
552
|
+
* A Codex home folder alone does not count: it outlives an uninstalled Codex. Null when neither. */
|
|
553
|
+
export function codexPresence(ctx, deps = {}) {
|
|
554
|
+
for (const dir of (ctx.env.PATH ?? "").split(delimiter)) {
|
|
555
|
+
if (!isAbsolute(dir))
|
|
556
|
+
continue;
|
|
557
|
+
const candidate = join(dir, "codex");
|
|
558
|
+
try {
|
|
559
|
+
accessSync(candidate, constants.X_OK);
|
|
560
|
+
if (statSync(candidate).isFile())
|
|
561
|
+
return candidate;
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
// Not here; the next PATH entry may have it.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const apps = deps.applicationDirs ?? [
|
|
568
|
+
"/Applications",
|
|
569
|
+
join(ctx.home, "Applications"),
|
|
570
|
+
];
|
|
571
|
+
for (const dir of apps) {
|
|
572
|
+
const bundle = join(dir, CODEX_APP_BUNDLE);
|
|
573
|
+
try {
|
|
574
|
+
if (statSync(bundle).isDirectory())
|
|
575
|
+
return bundle;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Not installed in this folder.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
export const CODEX_ABSENT_LINE = `Codex attribution skipped: Codex is not installed on this computer (no codex command on PATH and no ${CODEX_APP_BUNDLE} in /Applications or ~/Applications). Run catalyst setup again after you install Codex to add it.`;
|
|
584
|
+
/** Setup's part: the rc blocks and, on a Mac, the agent. Silent under another HOME, where it changes nothing. */
|
|
585
|
+
export function setupCodexAttribution(ctx, deps = {}) {
|
|
586
|
+
if (!ownsHome(ctx.home, deps))
|
|
587
|
+
return [];
|
|
588
|
+
// No Codex, no hook: the agent would otherwise set the launch environment for every Dock app.
|
|
589
|
+
if (codexPresence(ctx, deps) === null)
|
|
590
|
+
return [CODEX_ABSENT_LINE];
|
|
591
|
+
const files = shellRcFiles(ctx).flatMap((file) => {
|
|
592
|
+
try {
|
|
593
|
+
const outcome = installShellHook(file, ctx.home, fileOps(deps));
|
|
594
|
+
return outcome === null ? [] : [outcome];
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
return [
|
|
598
|
+
{
|
|
599
|
+
path: file.path,
|
|
600
|
+
state: "skipped",
|
|
601
|
+
why: `it could not be read or written (${message(err)})`,
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
const lines = [];
|
|
607
|
+
const written = files
|
|
608
|
+
.filter((f) => f.state !== "skipped")
|
|
609
|
+
.map((f) => `${tilde(f.path, ctx.home)} (${f.state})`);
|
|
610
|
+
if (written.length > 0)
|
|
611
|
+
lines.push(`Codex launches from a terminal name their coding account: the codex function is in ${written.join(" and ")}.`);
|
|
612
|
+
for (const f of files)
|
|
613
|
+
if (f.state === "skipped")
|
|
614
|
+
lines.push(`Left ${tilde(f.path, ctx.home)} without the Codex launch hook: ${f.why}.`);
|
|
615
|
+
if (files.length === 0)
|
|
616
|
+
lines.push("No shell rc file got the Codex launch hook: there is no ~/.zshrc or ~/.bashrc, and your login shell is neither zsh nor bash.");
|
|
617
|
+
const agent = installCodexAgent(ctx, deps);
|
|
618
|
+
if (agent.state === "loaded")
|
|
619
|
+
lines.push(`Codex launches from the Dock name their coding account: the ${CODEX_ATTRIBUTION_LABEL} LaunchAgent is loaded. A Codex app that is already open picks this up the next time it starts.`);
|
|
620
|
+
else if (agent.state === "unchanged")
|
|
621
|
+
lines.push(`The ${CODEX_ATTRIBUTION_LABEL} LaunchAgent is loaded and unchanged.`);
|
|
622
|
+
else if (agent.state === "failed")
|
|
623
|
+
lines.push(`Setup could not load the ${CODEX_ATTRIBUTION_LABEL} LaunchAgent (${agent.detail}), so apps started from the Dock do not name their coding account. Run catalyst setup again in Terminal on this Mac's desktop.`);
|
|
624
|
+
return lines;
|
|
625
|
+
}
|
|
626
|
+
/** What a full setup run adds after its steps, done or waiting. A dry run, a run scoped with
|
|
627
|
+
* --only and --no-shell-hook add nothing. With --json the lines go to stderr, so stdout keeps its one
|
|
628
|
+
* document. */
|
|
629
|
+
export function codexAttributionAfterSetup(args, ctx, deps = {}) {
|
|
630
|
+
if (args.flags["dry-run"] === true ||
|
|
631
|
+
args.flags.only !== undefined ||
|
|
632
|
+
args.flags["no-shell-hook"] === true)
|
|
633
|
+
return;
|
|
634
|
+
const say = args.json ? ctx.stderr : ctx.stdout;
|
|
635
|
+
for (const line of setupCodexAttribution(ctx, deps))
|
|
636
|
+
say(line);
|
|
637
|
+
}
|
|
638
|
+
/** `catalyst setup --remove-codex-attribution`. Run twice, the second run finds nothing to remove. */
|
|
639
|
+
export function removeCodexAttribution(ctx, deps = {}) {
|
|
640
|
+
const files = shellRcFiles(ctx).flatMap((file) => {
|
|
641
|
+
try {
|
|
642
|
+
const outcome = removeShellHook(file.path, ctx.home, fileOps(deps));
|
|
643
|
+
return outcome === null ? [] : [outcome];
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
return [
|
|
647
|
+
{
|
|
648
|
+
path: file.path,
|
|
649
|
+
state: "skipped",
|
|
650
|
+
why: `it could not be read or written (${message(err)})`,
|
|
651
|
+
},
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
const agent = removeCodexAgent(ctx, deps);
|
|
656
|
+
const lines = [];
|
|
657
|
+
for (const f of files)
|
|
658
|
+
lines.push(f.state === "skipped"
|
|
659
|
+
? `Left ${tilde(f.path, ctx.home)} as it is: ${f.why}.`
|
|
660
|
+
: `Removed the Codex launch hook from ${tilde(f.path, ctx.home)}; the file is as it was before setup added it.`);
|
|
661
|
+
if (files.length === 0)
|
|
662
|
+
lines.push("No shell rc file had the Codex launch hook.");
|
|
663
|
+
if (agent.agent === "removed")
|
|
664
|
+
lines.push(`Unloaded and deleted the ${CODEX_ATTRIBUTION_LABEL} LaunchAgent.`);
|
|
665
|
+
else if (agent.agent === "absent")
|
|
666
|
+
lines.push(`The ${CODEX_ATTRIBUTION_LABEL} LaunchAgent was not installed.`);
|
|
667
|
+
else if (agent.agent === "failed")
|
|
668
|
+
lines.push(`Could not remove the ${CODEX_ATTRIBUTION_LABEL} LaunchAgent: ${agent.detail}.`);
|
|
669
|
+
else if (agent.agent === "other_home")
|
|
670
|
+
lines.push(`HOME (${ctx.home}) is not this account's home folder, so the LaunchAgent and the launch environment were left alone.`);
|
|
671
|
+
if (agent.environment === "unset")
|
|
672
|
+
lines.push(`Unset ${ENV_NAME} in the launch environment. Apps that are already open keep it until they restart.`);
|
|
673
|
+
else if (agent.environment === "kept_others")
|
|
674
|
+
lines.push(`Took the coding account and machine name out of ${ENV_NAME} in the launch environment, and kept the attributes you set there yourself. Apps that are already open keep the old value until they restart.`);
|
|
675
|
+
else if (agent.environment === "failed")
|
|
676
|
+
lines.push(`Could not change ${ENV_NAME} in the launch environment. Run launchctl getenv ${ENV_NAME} to see it, and launchctl unsetenv ${ENV_NAME} to clear it.`);
|
|
677
|
+
return {
|
|
678
|
+
ok: files.every((f) => f.state !== "skipped") &&
|
|
679
|
+
agent.agent !== "failed" &&
|
|
680
|
+
agent.environment !== "failed",
|
|
681
|
+
files,
|
|
682
|
+
agent,
|
|
683
|
+
lines,
|
|
684
|
+
};
|
|
685
|
+
}
|