@catalyst-cloud/cli 0.16.33 → 0.16.35

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.35
4
+
5
+ On a self-hosted machine set up before 0.16.33, the runner update agent kept running the CLI it was installed with, and that CLI could not update itself. The daily job installed newer versions into its own folder, but the agent never ran them. Installing this release globally (`npm install -g @catalyst-cloud/cli`) now points that agent at the newly installed copy. It only ever moves the agent to a newer catalyst, and keeps the agent's runner folder and PATH. It saves the previous agent file beside it, as `<file>.before-0.16.35`, so you can roll back. On a Mac it waits for an update already in progress to finish. An agent file you cannot change is left alone. Each outcome is written to the runner's `update.log`, and a failed attempt puts the agent back exactly as it was. The install itself never fails because of this. To turn it off, set `CATALYST_NO_AGENT_REPOINT=1`.
6
+
7
+ `catalyst runner update --check` now says when the last update failed or was rolled back, with its time, its reason and the release it was offered, between the image result and the component report. The JSON output carries the same as `lastProblem`.
8
+
9
+ ## 0.16.34
10
+
11
+ `catalyst runner update` now tells Catalyst how its previous run went when it reads the approved release. A self-hosted machine that keeps waiting, refusing or rolling back an update shows up without anyone reading its local log. It sends a short report: the outcome, its reason code, the release it runs, the release that run was offered, when it ran and the CLI version. `--check` sends nothing, and nothing beyond that fixed set of words leaves the machine. When a self-hosted Mac cannot re-check its private storage during an update, the update log now names the reason instead of a generic failure.
12
+
13
+ `catalyst update` now puts the Cloud and Dev skills packs and the Cloud Claude plugin at the exact commits the approved release names. Before, a matching version number read as current even from a different commit, and a pack ahead of the approved release stayed ahead. The Cloud plugin moves through Claude Code from the same verified copy, and the CLI no longer copies its bundled skills over an approved installation when it starts.
14
+
15
+ `catalyst codex-env` adds the AI account and the machine name to the telemetry attributes a Codex launch reports, so its usage can be summed per account. `--print` prints the value, `--apply` sets it for apps opened from the Dock on macOS, and `--refresh` fetches the workspace's AI accounts again. `catalyst setup` now installs this for you when Codex is on the machine: a small `codex` wrapper in `~/.zshrc` and `~/.bashrc` for terminal launches, and on macOS a login item that covers Codex opened from the Dock. If the CLI cannot answer, Codex starts exactly as before. `catalyst setup --no-shell-hook` skips both, `catalyst setup --remove-codex-attribution` removes them, and `catalyst status` says whether they are installed.
16
+
3
17
  ## 0.16.33
4
18
 
5
19
  On a self-hosted machine, `catalyst update` now sees the approved versions of the CLI and the Cloud and Dev skills, and moves them. Before this release it reported "no approved version" for all three on every self-hosted machine and left them where they were. The runner images were not affected.
package/README.md CHANGED
@@ -138,6 +138,14 @@ catalyst ready --onboarding --json
138
138
 
139
139
  The dry run writes no files. `--only <step>` runs one step and reports its scope separately from full onboarding. Exit 10 means a failed step, 11 means waiting, and 12 means a guard refused the action. The private receipt is `install/last-run.json` under your machine's Catalyst state directory; credentials stay in the login config. A fake HOME reports old services and keeps them intact.
140
140
 
141
+ ### Codex launches name their coding account
142
+
143
+ When Codex is on this computer (a `codex` command on your PATH, or `Codex.app` in `/Applications` or `~/Applications`), setup adds a `codex` shell function to `~/.zshrc` and `~/.bashrc`, between the lines `# >>> catalyst codex attribution >>>` and `# <<< catalyst codex attribution <<<`. It writes to each of those files that exists, and creates one only for your login shell (`$SHELL`), so a Mac that uses only zsh gets no `~/.bashrc`. Bash on macOS reads `~/.bash_profile` rather than `~/.bashrc`, so if you use bash and your `~/.bash_profile` does not already source `~/.bashrc`, add the line `[ -f ~/.bashrc ] && . ~/.bashrc` to it. Each time you run `codex`, the function asks `catalyst codex-env --print` which coding account the Codex home in use (`$CODEX_HOME`, else `~/.codex`) is signed in to, and passes the answer to that one launch as `OTEL_RESOURCE_ATTRIBUTES`. The answer comes from a local cache, so a launch never waits on the network. Running setup again replaces the block in place. Setup leaves a file alone, and says why, when it already defines `codex` as a function or an alias of yours, or when it is a link to a file outside your home folder. A link to a file inside your home folder is followed, and the file keeps its permissions.
144
+
145
+ On a Mac, setup also installs the `dev.catalystcloud.codex-attribution` LaunchAgent. It runs `catalyst codex-env --apply` when you log in, and again whenever Codex signs in to another account in its default home, so apps you start from the Dock or Finder, the Codex desktop app included, carry the same attributes. An app that is already open keeps the environment it started with. It picks up a change the next time it starts. On Linux, setup adds the shell function only. Without Codex, setup adds nothing and says it skipped this. Run `catalyst setup` again after you install Codex to add it.
146
+
147
+ `catalyst status` ends with one line that says whether this is installed, which coding account the Codex home resolves to, and how old the cached account list is. `catalyst setup --no-shell-hook` skips all of it. `catalyst setup --remove-codex-attribution` removes it and runs nothing else. Each file goes back to exactly what it was before the block was added, the LaunchAgent is unloaded and deleted, and the two attributes it set (the coding account and the machine name) are taken back out of the launch environment. Attributes you set there yourself are kept. A fake HOME gets none of this.
148
+
141
149
  ### Headless setup (CI, image builds, agent VMs)
142
150
 
143
151
  `catalyst onboard --headless` (or `CATALYST_ONBOARD_HEADLESS=1`) is for a machine with no person at it. It assumes every browser approval already happened: the person's key exists, and Linear and GitHub are connected for the workspace and the person. It never prompts, never opens a browser and never starts the device sign-in. Every input comes from a flag, an environment variable or a file:
@@ -298,6 +306,7 @@ The package pins the contract range `1.x || 2.x`, recorded in `package.json` und
298
306
  - `~/.config/catalyst-cloud/customer.json`, written with mode `0600`, holding your personal key, who you are, and the CLI path.
299
307
  - `~/.config/catalyst-cloud/contract.json`, your account's cached contract.
300
308
  - `~/.config/catalyst-cloud/published.json`, the cached answer to "what is the newest release" — no credential in it.
309
+ - The Codex launch hook: one marked block in `~/.zshrc` and `~/.bashrc`, and on a Mac `~/Library/LaunchAgents/dev.catalystcloud.codex-attribution.plist` with its log at `~/.config/catalyst-cloud/codex-attribution.log` (see "Codex launches name their coding account").
301
310
  - After `catalyst update` moves the CLI: `~/.config/catalyst-cloud/cli-runtimes/`, one directory per CLI version it installed and `active.json` naming the one your `catalyst` command runs.
302
311
  - Only if you start them: `~/.config/catalyst-cloud/replica.db` with its `.pid`, `.writer.lock` and `.writer.state` sidecars, `$XDG_STATE_HOME/catalyst/events/<tenant>/backbone/` (or the home-directory fallback) with bounded daily event segments, and `~/.config/catalyst-cloud/watch-cursor.json`.
303
312
 
@@ -305,7 +314,15 @@ The skill files themselves are written by whichever install command you ran, in
305
314
 
306
315
  ## Updating
307
316
 
308
- `catalyst update --check` lists each installed component beside the version your workspace approved, says which one is behind and why, and changes nothing. Add `--project <absolute path>` to include a project's SDK; the report gives the one command that moves it, `catalyst update sdk --project <path>`. A project's files change only when you run that command, or after you turn its SDK updates on with `catalyst update policy sdk on --project <path>`. `catalyst update` moves the runner images, the CLI, the Cloud skills (copied on this machine or installed as the Claude plugin), and a running local replica to the approved versions. The new CLI is installed beside the old one, and your `catalyst` command runs it from then on, at the same path. A running replica restarts on it and keeps its synced data; a stopped one stays stopped. The Claude plugin moves through Claude Code, and only when its marketplace serves the approved version; run `/reload-plugins` or restart Claude Code afterwards. When the release check cannot find the `claude` command, it changes nothing and prints the one command that moves the plugin. The development skills are not on the approved release yet: the report lists their version as unknown and gives no command for them, so update them the way you installed them. On a machine whose runner checks for releases every 15 minutes (`catalyst runner auto-update on`), that check moves the same components by itself. Otherwise they move when you run `catalyst update`. That check moves the runner images to a newer release only at set times of day, 03:00, 11:00, 16:00 and 22:00 in the machine's time zone, so a busy machine is not restarted every time a release is approved. `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to these, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on. Add `--json` for a machine-readable report.
317
+ `catalyst update --check` lists each installed component beside the version your workspace approved and changes nothing. For skills packs and the Cloud plugin, the report also compares `installedCommit` with `approvedCommit`. Matching versions alone do not establish that the installed skills match approval. Add `--json` for the machine-readable report, or `--project <absolute path>` to include a project's SDK. A project's files change only when you run `catalyst update sdk --project <path>`, or after you turn its SDK updates on with `catalyst update policy sdk on --project <path>`.
318
+
319
+ `catalyst update` moves enabled components to the approved release. Owned skills packs move to the exact approved repository commits, including correction of a pack ahead of approval. It verifies existing contents before replacing them, preserves independent skills and local edits, and adds no missing skill names. The Cloud plugin uses the same verified Cloud checkout as a local user-scope marketplace. Run `/reload-plugins` or restart Claude Code after it updates. A missing `claude` executable, unsupported plugin scope or uncertain ownership leaves the plugin in place with a reason. Dev skills move only when the approved release includes a Dev pack; `pack_not_approved` means the installed pack was kept.
320
+
321
+ The new CLI is installed beside the old one, and your `catalyst` command runs it at the same path. A running local replica restarts on it and keeps its synced data; a stopped one stays stopped. CLI, SDK and image updates keep their existing policy against moving to an older version. Ordinary CLI startup never replaces skills with its bundled copy.
322
+
323
+ On a machine checking for releases every 15 minutes (`catalyst runner auto-update on`), that check applies the same component policies. Otherwise components move when you run `catalyst update`. That check moves the runner images to a newer release only at set times of day, 03:00, 11:00, 16:00 and 22:00 in the machine's time zone, so a busy machine is not restarted every time a release is approved. `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to these, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on.
324
+
325
+ The installer's daily job runs the same release check, so it moves the CLI and the Cloud skills only to the approved release and keeps your holds. Until the development skills join the release, it refreshes them from their repository.
309
326
 
310
327
  A CLI older than `catalyst update` cannot move itself: install it once with `npm install -g @catalyst-cloud/cli@latest && catalyst login`. The re-login rewrites the CLI path the skills spawn, so they stop running the old bundle. To run one command against the latest publish without installing, use `npx -p @catalyst-cloud/cli@latest catalyst login`. The next `catalyst` run prints a one-line notice:
311
328
 
@@ -325,10 +342,12 @@ Remove the skills the way you installed them: `/plugin uninstall catalyst@cataly
325
342
 
326
343
  ```sh
327
344
  catalyst replica stop
345
+ catalyst setup --remove-codex-attribution
328
346
  rm -f ~/.config/catalyst-cloud/customer.json ~/.config/catalyst-cloud/contract.json ~/.config/catalyst-cloud/published.json ~/.config/catalyst-cloud/watch-cursor.json
329
347
  rm -f ~/.config/catalyst-cloud/replica.db ~/.config/catalyst-cloud/replica.db.pid ~/.config/catalyst-cloud/replica.db.writer.lock ~/.config/catalyst-cloud/replica.db.writer.state
330
348
  rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/catalyst/events"
331
349
  rm -rf ~/.config/catalyst-cloud/cli-runtimes
350
+ rm -f ~/.config/catalyst-cloud/codex-accounts.json ~/.config/catalyst-cloud/codex-attribution.log
332
351
  npm uninstall -g @catalyst-cloud/cli
333
352
  ```
334
353
 
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // npm's postinstall for @catalyst-cloud/cli (CTC-6975): on a global install, point an older runner
3
+ // update agent at this copy (src/agent-repoint.ts). A local or workspace install does nothing, and
4
+ // nothing here ever fails the install. CATALYST_NO_AGENT_REPOINT=1 turns it off.
5
+ if (process.env.npm_config_global === "true" && process.env.CATALYST_NO_AGENT_REPOINT !== "1") {
6
+ try {
7
+ const { postinstallRepoint } = await import("../dist/agent-repoint.js");
8
+ const line = postinstallRepoint();
9
+ if (line) console.error(line);
10
+ } catch (err) {
11
+ console.error(`catalyst: could not check the runner update agent (${err instanceof Error ? err.message : String(err)}). Run: catalyst runner auto-update on`);
12
+ }
13
+ }
@@ -0,0 +1,241 @@
1
+ // agent-repoint.ts — CTC-6975. A machine set up before 0.16.33 runs its runner update agent from a CLI
2
+ // that can never move itself: those CLIs read no approved component release on a self-hosted machine,
3
+ // so the agent's own copy stays where it was installed, while the daily job installs the newest CLI
4
+ // into its own global prefix. npm runs this from that install (bin/postinstall.js) and points an older
5
+ // agent at the copy updates do move.
6
+ //
7
+ // It only ever moves the agent forward, touches only this CLI's own agent, keeps the agent's runner
8
+ // directory and PATH, and never fails the install. The agent file it replaces is kept beside it, named
9
+ // `<file>.before-<version>`, which neither launchd nor systemd loads: that copy is the rollback.
10
+ // Removing the stale CLI copy is the installer's job.
11
+ import { accessSync, appendFileSync, constants, copyFileSync, existsSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { compareVersions, exactVersion } from "./approved-release.js";
15
+ import { LEGACY_PACKAGE_NAME, readManifest } from "./config.js";
16
+ import { AUTO_UPDATE_LABEL, defaultRun, enableAutoUpdate, launchAgentPath, scheduledPath, systemdUnitPaths, writeFileAtomic, } from "./runner-auto-update.js";
17
+ import { CLAUDE_PATH_ENV, resolveClaude } from "./update-plugin.js";
18
+ /** Long enough for an in-place update (under a minute on a Mac measured 2026-10-24), well inside the
19
+ * installer's 600-second budget for the whole npm install. */
20
+ export const BUSY_WAIT_MS = 240_000;
21
+ const BUSY_POLL_MS = 5_000;
22
+ const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
23
+ const unxml = (value) => value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
24
+ /** The agent's command, from a plist this CLI wrote (launchAgentPlist). Anything else is unrecognized. */
25
+ function plistProgram(text) {
26
+ const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1];
27
+ if (array === undefined)
28
+ return null;
29
+ const args = [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1] ?? ""));
30
+ const env = (key) => {
31
+ const m = new RegExp(`<key>${key}</key>\\s*<string>([\\s\\S]*?)</string>`).exec(text);
32
+ return m ? unxml(m[1] ?? "") : null;
33
+ };
34
+ return programOf(args, env("PATH"), env(CLAUDE_PATH_ENV));
35
+ }
36
+ /** systemd's quoted words, as systemdUnits writes them. */
37
+ function unitWords(line) {
38
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? "").replace(/\\(.)/g, "$1").replace(/%%/g, "%"));
39
+ }
40
+ function unitProgram(text) {
41
+ const lines = text.split("\n");
42
+ const exec = lines.find((l) => l.startsWith("ExecStart="));
43
+ if (exec === undefined)
44
+ return null;
45
+ const envs = lines.filter((l) => l.startsWith("Environment=")).flatMap((l) => unitWords(l));
46
+ const env = (key) => envs.find((e) => e.startsWith(`${key}=`))?.slice(key.length + 1) ?? null;
47
+ return programOf(unitWords(exec), env("PATH"), env(CLAUDE_PATH_ENV));
48
+ }
49
+ function programOf(args, path, claude) {
50
+ const [cli, ...rest] = args;
51
+ if (cli === undefined || !cli.startsWith("/") || rest.length !== 5)
52
+ return null;
53
+ const [a, b, c, d, dir] = rest;
54
+ if (a !== "runner" || b !== "update" || c !== "--auto" || d !== "--dir" || dir === undefined || !dir.startsWith("/"))
55
+ return null;
56
+ return { cli, dir, path, claude };
57
+ }
58
+ const real = (path) => {
59
+ try {
60
+ return realpathSync(path);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ };
66
+ const writable = (path) => {
67
+ try {
68
+ accessSync(path, constants.W_OK);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ };
75
+ /** The version of the catalyst package a command belongs to, or null when it is not one. */
76
+ export function cliVersionAt(command) {
77
+ const resolved = real(command);
78
+ if (resolved === null)
79
+ return null;
80
+ let dir = dirname(resolved);
81
+ for (let i = 0; i < 4; i++) {
82
+ const manifest = join(dir, "package.json");
83
+ if (existsSync(manifest)) {
84
+ try {
85
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
86
+ if (pkg.name === "@catalyst-cloud/cli" || pkg.name === LEGACY_PACKAGE_NAME)
87
+ return exactVersion(pkg.version);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ return null;
93
+ }
94
+ dir = dirname(dir);
95
+ }
96
+ return null;
97
+ }
98
+ /** Whether the macOS agent is idle, waiting up to the budget for a running update to finish. */
99
+ function waitIdle(deps) {
100
+ const run = deps.run ?? defaultRun;
101
+ const sleep = deps.sleep ?? blockingSleep;
102
+ const target = `gui/${deps.uid ?? process.getuid?.() ?? 0}/${AUTO_UPDATE_LABEL}`;
103
+ for (let waited = 0;; waited += BUSY_POLL_MS) {
104
+ if (!/^\s*state = running\s*$/m.test(run("launchctl", ["print", target]).stdout))
105
+ return true;
106
+ if (waited >= (deps.busyWaitMs ?? BUSY_WAIT_MS))
107
+ return false;
108
+ sleep(BUSY_POLL_MS);
109
+ }
110
+ }
111
+ /** Put the agent file back and load it again, best effort, then drop the backup. */
112
+ function restore(deps, platform, agent, original, backup) {
113
+ if (!existsSync(agent) || !readFileSync(agent).equals(original)) {
114
+ writeFileAtomic(agent, original.toString("utf8"));
115
+ const run = deps.run ?? defaultRun;
116
+ try {
117
+ if (platform === "darwin")
118
+ run("launchctl", ["bootstrap", `gui/${deps.uid ?? process.getuid?.() ?? 0}`, agent]);
119
+ else
120
+ run("systemctl", ["--user", "daemon-reload"]);
121
+ }
122
+ catch {
123
+ // the file is back; the scheduler picks it up at the next login or reload
124
+ }
125
+ }
126
+ unlinkSync(backup);
127
+ }
128
+ /** Point an older runner update agent at this install. Never throws. Every outcome for an agent this
129
+ * CLI recognizes is also appended to the agent's own update.log: npm hides a postinstall's output on
130
+ * a global install, so that log is where a person, or a later run, reads what happened. */
131
+ export function repointAgent(deps) {
132
+ const seen = {};
133
+ const result = decide(deps, seen);
134
+ if (seen.dir !== undefined) {
135
+ try {
136
+ appendFileSync(join(seen.dir, "update.log"), `${new Date().toISOString()} ${logLine(result, deps.version)}\n`);
137
+ }
138
+ catch {
139
+ // the agent's log is a courtesy; the outcome is already decided
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+ function decide(deps, seen) {
145
+ try {
146
+ const platform = deps.platform ?? process.platform;
147
+ let program;
148
+ let agent;
149
+ if (platform === "darwin") {
150
+ agent = launchAgentPath(deps.home);
151
+ if (!existsSync(agent))
152
+ return { action: "skipped", reason: "no_agent" };
153
+ program = plistProgram(readFileSync(agent, "utf8"));
154
+ }
155
+ else if (platform === "linux") {
156
+ agent = systemdUnitPaths(deps.home).service;
157
+ if (!existsSync(agent))
158
+ return { action: "skipped", reason: "no_agent" };
159
+ program = unitProgram(readFileSync(agent, "utf8"));
160
+ }
161
+ else {
162
+ return { action: "skipped", reason: "unsupported_platform" };
163
+ }
164
+ if (program === null)
165
+ return { action: "skipped", reason: "agent_unrecognized" };
166
+ seen.dir = program.dir;
167
+ const from = real(program.cli);
168
+ if (from !== null && from === real(deps.self))
169
+ return { action: "skipped", reason: "already_current" };
170
+ // A program that no longer exists runs nothing, so any working copy is better. One that exists
171
+ // moves only when it is a catalyst CLI older than this one.
172
+ let fromVersion = null;
173
+ if (from !== null) {
174
+ fromVersion = cliVersionAt(program.cli);
175
+ if (fromVersion === null)
176
+ return { action: "skipped", reason: "agent_cli_unrecognized" };
177
+ if (compareVersions(fromVersion, deps.version) >= 0)
178
+ return { action: "skipped", reason: "agent_cli_not_older" };
179
+ }
180
+ const canWrite = deps.canWrite ?? writable;
181
+ const files = platform === "linux" ? [agent, systemdUnitPaths(deps.home).timer].filter((f) => existsSync(f)) : [agent];
182
+ if (![...files, dirname(agent)].every(canWrite))
183
+ return { action: "skipped", reason: "agent_not_writable", agent };
184
+ // launchctl bootout stops a running job, and the daily job starts on the same quarter hour as the
185
+ // agent does: wait for an update in progress to finish rather than cut it off.
186
+ if (platform === "darwin" && !waitIdle(deps))
187
+ return { action: "skipped", reason: "agent_busy" };
188
+ const original = readFileSync(agent);
189
+ const backup = `${agent}.before-${deps.version}`;
190
+ copyFileSync(agent, backup);
191
+ let status;
192
+ try {
193
+ status = enableAutoUpdate({
194
+ platform,
195
+ home: deps.home,
196
+ uid: deps.uid,
197
+ cli: deps.self,
198
+ path: scheduledPath(deps.self, deps.nodeExecutable, program.path ?? deps.env.PATH),
199
+ claude: program.claude ?? resolveClaude(deps.home, deps.env),
200
+ run: deps.run,
201
+ }, program.dir);
202
+ }
203
+ finally {
204
+ // A repoint that did not take puts the agent back exactly as it was, and leaves no backup.
205
+ if (status?.state !== "on")
206
+ restore(deps, platform, agent, original, backup);
207
+ }
208
+ if (status.state !== "on")
209
+ return { action: "failed", detail: status.detail };
210
+ return { action: "repointed", from: program.cli, fromVersion, to: deps.self, backup };
211
+ }
212
+ catch (err) {
213
+ return { action: "failed", detail: (err instanceof Error ? err.message : String(err)).slice(0, 160) };
214
+ }
215
+ }
216
+ /** One line for the person, or null when there is nothing to say. */
217
+ export function repointLine(result, version) {
218
+ if (result.action === "repointed")
219
+ return `catalyst: the runner update agent now runs ${result.to} (${version}) instead of ${result.from} (${result.fromVersion ?? "missing"}). The previous agent file is kept at ${result.backup}.`;
220
+ if (result.action === "skipped" && result.reason === "agent_not_writable")
221
+ return `catalyst: the runner update agent at ${result.agent} runs an older catalyst, and this install cannot change it. Ask whoever installed it to run: catalyst runner auto-update on`;
222
+ if (result.action === "skipped" && result.reason === "agent_busy")
223
+ return "catalyst: the runner update agent was busy updating, so it keeps its current catalyst until the next install. Or run: catalyst runner auto-update on";
224
+ if (result.action === "failed")
225
+ return `catalyst: could not point the runner update agent at this install (${result.detail}). Run: catalyst runner auto-update on`;
226
+ return null;
227
+ }
228
+ /** The update.log line for any outcome: the person's line, or for a quiet skip, its reason. */
229
+ export function logLine(result, version) {
230
+ return repointLine(result, version) ?? `catalyst ${version}: the runner update agent was left as it is (${result.action === "skipped" ? result.reason : result.action}).`;
231
+ }
232
+ /** npm's postinstall, for a global install: this install's own command, version and node. */
233
+ export function postinstallRepoint(env = process.env) {
234
+ const version = readManifest().version;
235
+ const linked = env.npm_config_prefix ? join(env.npm_config_prefix, "bin", "catalyst") : null;
236
+ const self = linked !== null && existsSync(linked) ? linked : fileURLToPath(new URL("../bin/catalyst.js", import.meta.url));
237
+ const home = env.HOME;
238
+ if (!home)
239
+ return null;
240
+ return repointLine(repointAgent({ home, self, version, nodeExecutable: process.execPath, env }), version);
241
+ }
package/dist/args.js CHANGED
@@ -91,6 +91,14 @@ export const FLAG_TABLES = {
91
91
  value: false,
92
92
  help: "print setup in plain text, without colour or the Catalyst Cloud mark",
93
93
  },
94
+ "no-shell-hook": {
95
+ value: false,
96
+ help: "do not add the Codex launch hooks: the codex function in ~/.zshrc and ~/.bashrc, and on macOS the dev.catalystcloud.codex-attribution LaunchAgent",
97
+ },
98
+ "remove-codex-attribution": {
99
+ value: false,
100
+ help: "remove the Codex launch hooks setup added (each rc file goes back to what it was), unload and delete the LaunchAgent, and take its attributes out of the launch environment; runs nothing else",
101
+ },
94
102
  "resume-from": {
95
103
  value: true,
96
104
  help: "resume at a verified onboarding step",
@@ -527,6 +535,21 @@ export const FLAG_TABLES = {
527
535
  work: {
528
536
  team: { value: true, help: "available: one team's key (default: every team in the workspace contract)" },
529
537
  },
538
+ "codex-env": {
539
+ print: {
540
+ value: false,
541
+ help: "print the OTEL_RESOURCE_ATTRIBUTES value a Codex launch should carry: the attributes already set, plus the coding account's slot and this machine's short name",
542
+ },
543
+ apply: {
544
+ value: false,
545
+ help: "macOS: set that value with launchctl for apps started from now on, the Codex desktop app included, for the default Codex home",
546
+ },
547
+ refresh: {
548
+ value: false,
549
+ help: "fetch this workspace's coding accounts now and update the cache --print and --apply read",
550
+ },
551
+ "codex-home": { value: true, help: "print: the Codex home to read (default: $CODEX_HOME, else ~/.codex)" },
552
+ },
530
553
  repo: {
531
554
  reason: { value: true, help: "pause: required; resume: optional; 1 to 500 characters after trimming (owner or admin)" },
532
555
  write: {
@@ -584,7 +607,7 @@ export const VERB_USAGE = {
584
607
  update: "update [--check] [--project <absolute path>] [--dir <runner directory>] | update sdk --project <absolute path> | update policy [<component> on|off] | update policy sdk on|off --project <absolute path> | update policy windows <HH:MM,...>|default [--time-zone <zone>] [--json] (components: runner, supervisor, watchdog, cli, skills-cloud, skills-dev, replica)",
585
608
  setup: "setup [onboard options] (sets up Catalyst step by step; same as catalyst onboard) | setup --engine <file> --engine-sha256 <hex> [-- <install options>] (run by install.sh; install.sh --help lists the options)",
586
609
  login: "login [--base-url <url>] [--start-replica] (keyless; or --key <personal-key> / CATALYST_CLOUD_TOKEN)",
587
- onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--remove-local-data] [--runner|--no-runner] [--no-color] [--yes] [--dry-run] [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
610
+ onboard: "onboard [--team <ID|key>] [--repo <owner/name>]... [--coding-account <slot>] [--resume-from <step>] [--only <step>] [--local-sync] [--remove-local-data] [--runner|--no-runner] [--no-shell-hook] [--no-color] [--yes] [--dry-run] [--json] | onboard --remove-codex-attribution [--json] | onboard --headless [--key-file <path>] [--team ...] [--repo ...]... [--coding-account ...] [--runner yes|no] [--remove-local-data] [--json]",
588
611
  install: "install [--skills-dir <dir>] [--force]",
589
612
  status: "status",
590
613
  notice: "notice",
@@ -615,6 +638,7 @@ export const VERB_USAGE = {
615
638
  capabilities: "capabilities [--json]",
616
639
  project: "project <list [--json] | wip-limit <get|set <n>|set default> [--team K] [--json]>",
617
640
  lease: "lease <take <ticket> --agent <seat> [--ttl <minutes>] | renew <ticket> (--commit <sha>|--artifact <path or id>|--finding <id>|--pr <number>) [--note <text>] [--agent <seat>] [--generation <n>] | release <ticket> [--agent <seat>] [--generation <n>] | show <ticket>> [--json] (a seat's ticket lease: take, renew with new progress, release, read; needs login)",
641
+ "codex-env": "codex-env --print [--codex-home <dir>] | --apply | --refresh [--json] (the telemetry attributes naming the coding account and machine a Codex launch runs as; only --refresh waits on the network)",
618
642
  work: "work available [--team K] [--json] (per team: tickets nobody holds, with the last holder and progress, and open pull requests no live lease covers, drafts included)",
619
643
  repo: "repo <status|pause <owner/name> --reason <text>|resume <owner/name> [--reason <text>]|agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> [--json] (status: visible workspace repositories; agents-block/agent-setup: local checkout)",
620
644
  legacy: "legacy [--remove [--data] [--yes]] [--json]",
@@ -48,6 +48,8 @@ export const CAPABILITIES = [
48
48
  { verb: "var set|import", does: "enter plain environment variable values from this terminal, never echoed", needs: "admin", routes: [], since: "0.13.2" },
49
49
  { verb: "env import", does: "store this computer's values for the names .catalyst/catalyst.toml declares, never printed", needs: "admin", routes: [], since: "0.16.20" },
50
50
  { verb: "accounts", does: "read the workspace's coding accounts and which can take work", needs: "member", routes: [], since: "0.5.0" },
51
+ { verb: "codex-env", does: "name the coding account and the machine on every Codex launch's telemetry: print the attributes, set them for apps on macOS, or refresh the account cache", needs: "member", routes: [], since: "0.16.33" },
52
+ { verb: "setup --remove-codex-attribution", does: "take out the Codex launch hooks setup adds: the codex function in the shell rc files, and on macOS the login agent and the attributes it set", needs: "member", routes: [], since: "0.16.34" },
51
53
  { verb: "explain|running|queue|history", does: "read why a ticket runs or waits, what runs now, and what is queued", needs: "member", routes: [], since: "0.1.0" },
52
54
  { verb: "write comment|state|label|create|reaction|attachment|session", does: "act on a ticket as the person, through the agent proxy", needs: "member", routes: [agent("POST", "issue-comment"), agent("POST", "issue-state"), agent("POST", "issue-label"), agent("POST", "issue-create")], since: "0.1.0" },
53
55
  { verb: "ask raise|accept|list", does: "raise a decision to a human, or answer one", needs: "member", routes: [agent("POST", "ask"), agent("POST", "ask-accept")], since: "0.1.0" },
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@ import { createSetupRenderer, setupBanner, } from "./setup-render.js";
8
8
  import { existsSync, openSync, closeSync } from "node:fs";
9
9
  import { ReadStream as TerminalInput } from "node:tty";
10
10
  import { Writable } from "node:stream";
11
- import { parseArgs, positionals, verbHelp } from "./args.js";
11
+ import { flagBool, parseArgs, positionals, verbHelp } from "./args.js";
12
12
  import { reportCliVersion } from "./cli-version.js";
13
- import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
13
+ import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
14
14
  import { loadContract, pickPath } from "./contract.js";
15
15
  import { CliError, MeError, UsageError } from "./errors.js";
16
16
  import { fetchMe } from "./transport.js";
@@ -50,6 +50,8 @@ import { cmdProject } from "./project.js";
50
50
  import { cmdRepo } from "./repo.js";
51
51
  import { cmdLease, cmdWork } from "./lease.js";
52
52
  import { cmdLegacy } from "./legacy.js";
53
+ import { cmdCodexEnv } from "./codex-env.js";
54
+ import { codexAttributionAfterSetup, codexAttributionStatusLine, removeCodexAttribution, } from "./codex-attribution.js";
53
55
  import { cmdIdentity } from "./identity.js";
54
56
  import { cmdMessages } from "./messages.js";
55
57
  import { cmdConnections } from "./connections.js";
@@ -85,7 +87,8 @@ export function usageText() {
85
87
  " catalyst login --key <personal-key> [--base-url <url>] (or CATALYST_CLOUD_TOKEN, for a key)",
86
88
  " catalyst join ... (deprecated alias of login; removed in the next minor version)",
87
89
  " catalyst install [--skills-dir <dir>] [--force] (repair path; your agent's own command installs the skills)",
88
- " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
90
+ " catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--no-shell-hook] [--yes] [--dry-run] [--json]",
91
+ " catalyst setup --remove-codex-attribution (take out the Codex launch hook and login agent setup added)",
89
92
  ` catalyst ${SETUP_USAGE}`,
90
93
  " catalyst status | notice | me | ready | accounts",
91
94
  " catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
@@ -119,6 +122,7 @@ export function usageText() {
119
122
  " catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
120
123
  " catalyst lease <take <ticket> --agent <seat> [--ttl <minutes>]|renew <ticket> --commit|--artifact|--finding|--pr <ref> [--note <text>]|release <ticket>|show <ticket>> [--json] (a seat's ticket lease; needs login)",
121
124
  " catalyst work available [--team K] [--json] (tickets nobody holds and open pull requests no lease covers)",
125
+ " catalyst codex-env --print [--codex-home <dir>] | --apply | --refresh (the telemetry attributes naming the coding account and machine a Codex launch runs as)",
122
126
  " catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
123
127
  " catalyst identity linear <status|options|set> [<linearUserId>] [--json]",
124
128
  " catalyst messages read --ticket CTC-N [--since SEQUENCE] [--json] (messages sent to a ticket on your teams)",
@@ -183,10 +187,7 @@ function migrateLegacyCliPath(ctx) {
183
187
  // Best effort: the old launcher still works, it only prints the notice.
184
188
  }
185
189
  }
186
- /**
187
- * The Tier-2 update path: when the bundle on disk is newer than the version the config last
188
- * recorded, refresh the copied skills FIRST and record the new version only once that succeeded.
189
- */
190
+ /** Announce a new CLI bundle without changing approved skills. */
190
191
  function maybePrintUpdateNotice(args, ctx) {
191
192
  const say = HUMAN_VERBS.has(args.command ?? "") ? ctx.stdout : ctx.stderr;
192
193
  let cfg;
@@ -199,46 +200,16 @@ function maybePrintUpdateNotice(args, ctx) {
199
200
  if (!cfg)
200
201
  return;
201
202
  const manifest = readManifest();
202
- if (cfg.lastSkillBundleVersion === manifest.version)
203
- return;
204
- // CTC-6741: a newer CLI selected by `catalyst update` records its own bundle here, and the older
205
- // npm-installed one still runs for `catalyst runtime`. Copied skills only ever move forward.
206
- if (cfg.lastSkillBundleVersion && semverOlder(manifest.version, cfg.lastSkillBundleVersion))
207
- return;
208
203
  const previous = cfg.lastSkillBundleVersion;
209
- if (previous) {
210
- const entry = parseChangelogEntry(readChangelog(), manifest.version);
211
- say(updateNoticeLine(previous, manifest.version, entry));
212
- }
213
- if (installerOwnsSkills(ctx.home, ctx.env)) {
214
- cfg.lastSkillBundleVersion = manifest.version;
215
- try {
216
- saveConfig(ctx.home, cfg);
217
- }
218
- catch {
219
- // Best effort: the notice prints again next time, and nothing else depends on it.
220
- }
204
+ if (previous === manifest.version || previous && semverOlder(manifest.version, previous))
221
205
  return;
222
- }
223
- const skillsDir = resolveSkillsDir(args, ctx, cfg);
224
- let refreshed;
206
+ if (previous)
207
+ say(updateNoticeLine(previous, manifest.version, parseChangelogEntry(readChangelog(), manifest.version)));
208
+ cfg.lastSkillBundleVersion = manifest.version;
225
209
  try {
226
- refreshed = installSkills(skillsDir, { force: false, onlyExisting: true });
227
- }
228
- catch (err) {
229
- ctx.stderr(`[catalyst] could not refresh the skills at ${skillsDir} (${err instanceof Error ? err.message : String(err)}) — the ${manifest.version} skills are not installed yet; re-run the install command: ${INSTALL_COMMAND}`);
230
- return;
231
- }
232
- if (refreshed.installed.length > 0)
233
- say(`[catalyst] refreshed ${refreshed.installed.join(", ")} at ${skillsDir} to ${manifest.version}`);
234
- if (refreshed.removed.length > 0)
235
- say(`[catalyst] removed retired skills ${refreshed.removed.join(", ")} from ${skillsDir}; their work moved into the skills this bundle ships`);
236
- for (const s of refreshed.skipped) {
237
- say(`[catalyst] left "${s.name}" alone: ${skillsDir}/${s.name} was not installed by this package (catalyst install --force to replace)`);
210
+ saveConfig(ctx.home, cfg);
238
211
  }
239
- cfg.lastSkillBundleVersion = manifest.version;
240
- cfg.skillsDir = skillsDir;
241
- saveConfig(ctx.home, cfg);
212
+ catch { /* The notice can repeat on the next ordinary command. */ }
242
213
  }
243
214
  export async function main(argv, ctx = defaultCtx(), deps = {}) {
244
215
  // CTC-4625: setup passes the install options through to its engine untouched, so they never meet
@@ -305,7 +276,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
305
276
  }),
306
277
  };
307
278
  try {
308
- if (args.command !== "onboard") {
279
+ if (args.command !== "onboard" && !(flagBool(args, "check") && (args.command === "update" || args.command === "runner"))) {
309
280
  migrateLegacyCliPath(ctx);
310
281
  maybePrintUpdateNotice(args, ctx);
311
282
  }
@@ -323,6 +294,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
323
294
  throw new UsageError(`--${flag} needs --headless`);
324
295
  if (typeof args.flags.runner === "string" && !onboardHeadlessRequested(args, ctx.env))
325
296
  throw new UsageError("--runner yes|no needs --headless; use --runner or --no-runner interactively");
297
+ // The Codex launch hooks' removal runs on its own, with no onboarding around it.
298
+ if (args.flags["remove-codex-attribution"] === true) {
299
+ if (args.flags["dry-run"] === true)
300
+ throw new UsageError("--remove-codex-attribution changes files; run it without --dry-run");
301
+ deps.setupUi?.dispose();
302
+ const removal = removeCodexAttribution(ctx, deps.codexAttribution);
303
+ if (args.json)
304
+ ctx.stdout(JSON.stringify({ removed: removal.ok, files: removal.files, agent: removal.agent }));
305
+ else
306
+ for (const line of removal.lines)
307
+ ctx.stdout(line);
308
+ return removal.ok ? 0 : 1;
309
+ }
326
310
  // Headless runs drop the terminal UI, browser sign-in and every browser action (CTC-4633).
327
311
  const runOnboard = async (args, ctx, headless) => {
328
312
  let ui = headless ? undefined : deps.setupUi;
@@ -485,6 +469,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
485
469
  }
486
470
  for (const line of jsonLines)
487
471
  ctx.stdout(line);
472
+ // The Codex launch hooks follow every full run, done or waiting, and never change its exit code.
473
+ codexAttributionAfterSetup(args, ctx, deps.codexAttribution);
488
474
  return code;
489
475
  };
490
476
  if (!onboardHeadlessRequested(args, ctx.env))
@@ -497,7 +483,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
497
483
  case "notice":
498
484
  return 0;
499
485
  case "status":
500
- return cmdStatus(ctx);
486
+ return cmdStatus(ctx, deps);
501
487
  case "me":
502
488
  return await cmdMe(args, ctx);
503
489
  case "contract":
@@ -564,6 +550,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
564
550
  return await cmdWork(args, ctx);
565
551
  case "legacy":
566
552
  return await cmdLegacy(args, ctx, { localSyncSelected: savedLocalSync(ctx), ...deps.legacy });
553
+ case "codex-env":
554
+ return await cmdCodexEnv(args, ctx, deps.codexEnv ?? {});
567
555
  case "identity":
568
556
  return await cmdIdentity(args, ctx, deps.identity ?? {});
569
557
  case "messages":
@@ -643,6 +631,7 @@ const VERB_HELP_KNOWN = Object.fromEntries([
643
631
  "lease",
644
632
  "work",
645
633
  "legacy",
634
+ "codex-env",
646
635
  "onboard",
647
636
  "update",
648
637
  ].map((v) => [v, true]));
@@ -852,7 +841,7 @@ function cmdInstall(args, ctx) {
852
841
  ctx.stdout(`Removed retired skills from ${skillsDir}: ${result.removed.join(", ")}`);
853
842
  return 0;
854
843
  }
855
- function cmdStatus(ctx) {
844
+ function cmdStatus(ctx, deps = {}) {
856
845
  const manifest = readManifest();
857
846
  const cfg = loadConfig(ctx.home);
858
847
  if (!cfg) {
@@ -871,6 +860,7 @@ function cmdStatus(ctx) {
871
860
  if (cfg.cliPath)
872
861
  ctx.stdout(`CLI: ${cfg.cliPath}${existsSync(cfg.cliPath) ? "" : " (missing — re-run login)"}`);
873
862
  ctx.stdout(`Contract: ${existsSync(contractPathFor(ctx.home)) ? contractPathFor(ctx.home) : "not cached (run: catalyst contract --refresh)"}`);
863
+ ctx.stdout(codexAttributionStatusLine(ctx, deps.codexAttribution));
874
864
  return 0;
875
865
  }
876
866
  /** A short human relative time for an ISO expiry, e.g. "in 14m", "in 2h", "in 6d", or "now" once past. */