@uluops/setup 0.10.0 → 0.12.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +772 -0
  2. package/README.md +81 -32
  3. package/dist/cli.js +7 -1
  4. package/dist/commands/helpers.js +70 -7
  5. package/dist/commands/per-harness.d.ts +5 -0
  6. package/dist/commands/per-harness.js +5 -0
  7. package/dist/commands/setup.d.ts +7 -0
  8. package/dist/commands/setup.js +100 -35
  9. package/dist/commands/uninstall.d.ts +7 -0
  10. package/dist/commands/uninstall.js +36 -9
  11. package/dist/commands/verify.d.ts +5 -0
  12. package/dist/commands/verify.js +5 -0
  13. package/dist/harnesses/claude-code.js +15 -7
  14. package/dist/harnesses/codex.js +21 -4
  15. package/dist/harnesses/gemini-cli.js +13 -6
  16. package/dist/harnesses/index.d.ts +8 -0
  17. package/dist/harnesses/index.js +10 -0
  18. package/dist/harnesses/opencode.js +25 -7
  19. package/dist/lib/asset-catalog.js +15 -2
  20. package/dist/lib/atomic-write.d.ts +6 -0
  21. package/dist/lib/atomic-write.js +10 -0
  22. package/dist/lib/config-merger.js +27 -8
  23. package/dist/lib/display.d.ts +8 -0
  24. package/dist/lib/display.js +27 -1
  25. package/dist/lib/file-ops.d.ts +13 -4
  26. package/dist/lib/file-ops.js +69 -18
  27. package/dist/lib/install-lock.js +45 -13
  28. package/dist/lib/json-guards.d.ts +15 -0
  29. package/dist/lib/json-guards.js +30 -0
  30. package/dist/lib/manifest.d.ts +9 -2
  31. package/dist/lib/manifest.js +66 -8
  32. package/dist/lib/mcp-packages.d.ts +17 -15
  33. package/dist/lib/mcp-packages.js +15 -13
  34. package/dist/lib/settings-merger.js +53 -9
  35. package/dist/lib/version.js +19 -2
  36. package/dist/lib/write-coordinator.d.ts +50 -0
  37. package/dist/lib/write-coordinator.js +89 -0
  38. package/dist/steps/agent-metrics-cli.d.ts +6 -0
  39. package/dist/steps/agent-metrics-cli.js +19 -1
  40. package/dist/steps/agents.js +22 -4
  41. package/dist/steps/auth.js +53 -13
  42. package/dist/steps/cli.js +14 -1
  43. package/dist/steps/commands.js +28 -10
  44. package/dist/steps/mcp.js +18 -9
  45. package/dist/steps/metrics.js +77 -7
  46. package/dist/steps/shell.d.ts +4 -1
  47. package/dist/steps/shell.js +44 -6
  48. package/dist/steps/signup.d.ts +4 -0
  49. package/dist/steps/signup.js +18 -2
  50. package/dist/steps/skills.d.ts +11 -0
  51. package/dist/steps/skills.js +35 -6
  52. package/dist/steps/username.js +10 -2
  53. package/dist/steps/verify.js +55 -6
  54. package/package.json +8 -5
  55. package/dist/lib/agent-transform.d.ts +0 -12
  56. package/dist/lib/agent-transform.js +0 -129
@@ -10,4 +10,11 @@ export interface RunUninstallOpts {
10
10
  /** Whether --all-detected was passed. */
11
11
  allDetected?: boolean;
12
12
  }
13
+ /**
14
+ * The `--uninstall` flow: removes UluOps-managed artifacts for every harness
15
+ * in the manifest, or — when a harness filter is passed — only the named
16
+ * subset, preserving shared infrastructure (global CLI packages, shell
17
+ * export) that remaining harnesses still need. Fails fast if a named harness
18
+ * is not in the manifest.
19
+ */
13
20
  export declare function runUninstall(opts: RunUninstallOpts): Promise<void>;
@@ -13,6 +13,13 @@ import { ok, warn, fail, info } from "../lib/display.js";
13
13
  import { getVersion } from "../lib/version.js";
14
14
  import { getProfile } from "../harnesses/index.js";
15
15
  import { resolveUninstallFilter, UninstallFilterError, } from "./uninstall-filter.js";
16
+ /**
17
+ * The `--uninstall` flow: removes UluOps-managed artifacts for every harness
18
+ * in the manifest, or — when a harness filter is passed — only the named
19
+ * subset, preserving shared infrastructure (global CLI packages, shell
20
+ * export) that remaining harnesses still need. Fails fast if a named harness
21
+ * is not in the manifest.
22
+ */
16
23
  export async function runUninstall(opts) {
17
24
  const version = await getVersion();
18
25
  console.log();
@@ -64,7 +71,12 @@ export async function runUninstall(opts) {
64
71
  if (err instanceof UninstallFilterError) {
65
72
  fail(err.message);
66
73
  console.log();
67
- process.exit(1);
74
+ // Same rule as runSetup: never process.exit inside the lock-guarded
75
+ // try. A plain `return` here makes any statement after the finally
76
+ // unreachable, so the code must ride process.exitCode — safe at this
77
+ // point because no prompt has run (no lingering stdin handle).
78
+ process.exitCode = 1;
79
+ return;
68
80
  }
69
81
  throw err;
70
82
  }
@@ -120,8 +132,8 @@ export async function runUninstall(opts) {
120
132
  await uninstallMcp(profile, hm.mcpConfigPath);
121
133
  ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
122
134
  }
123
- catch {
124
- warn(`Could not remove MCP servers from ${hm.mcpConfigPath}`);
135
+ catch (err) {
136
+ warn(`Could not remove MCP servers from ${hm.mcpConfigPath}: ${err instanceof Error ? err.message : String(err)}`);
125
137
  }
126
138
  }
127
139
  else {
@@ -180,8 +192,13 @@ export async function runUninstall(opts) {
180
192
  const { getShellProfile } = await import("../lib/paths.js");
181
193
  const shellProfile = getShellProfile();
182
194
  if (shellProfile && !opts.dryRun) {
183
- await removeShellExport(shellProfile.path);
184
- ok(`Removed export from ${shellProfile.path}`);
195
+ const shellRes = await removeShellExport(shellProfile.path);
196
+ if (shellRes.removed) {
197
+ ok(`Removed export from ${shellProfile.path}`);
198
+ }
199
+ else {
200
+ warn(`Could not remove export from ${shellProfile.path}${shellRes.reason ? ` — ${shellRes.reason}` : ""}. The plaintext API key may still be in the file; remove the UluOps block manually.`);
201
+ }
185
202
  }
186
203
  else if (shellProfile) {
187
204
  ok(`Would remove export from ${shellProfile.path}`);
@@ -199,8 +216,13 @@ export async function runUninstall(opts) {
199
216
  // would reject an empty-harnesses file)
200
217
  if (!opts.dryRun) {
201
218
  if (isFullUninstall) {
202
- await deleteManifest();
203
- ok("Manifest deleted");
219
+ const delRes = await deleteManifest();
220
+ if (delRes.failed.length === 0) {
221
+ ok("Manifest deleted");
222
+ }
223
+ else {
224
+ warn(`Could not delete manifest: ${delRes.failed.join("; ")} — it still records this install; remove it manually.`);
225
+ }
204
226
  }
205
227
  else {
206
228
  for (const name of toUninstall) {
@@ -212,8 +234,13 @@ export async function runUninstall(opts) {
212
234
  ok(`Manifest updated — ${remaining} harness(es) remain: ${Object.keys(manifest.harnesses).join(", ")}`);
213
235
  }
214
236
  else {
215
- await deleteManifest();
216
- ok("Manifest deleted (no harnesses remain)");
237
+ const delRes2 = await deleteManifest();
238
+ if (delRes2.failed.length === 0) {
239
+ ok("Manifest deleted (no harnesses remain)");
240
+ }
241
+ else {
242
+ warn(`Could not delete manifest: ${delRes2.failed.join("; ")} — remove it manually.`);
243
+ }
217
244
  }
218
245
  }
219
246
  }
@@ -1 +1,6 @@
1
+ /**
2
+ * The `--verify` flow: prints one check line per verify-step result
3
+ * (manifest, per-harness readiness/MCP/files/hook, API key) and exits 1
4
+ * when any check failed. Read-only — makes no changes.
5
+ */
1
6
  export declare function runVerify(): Promise<void>;
@@ -2,6 +2,11 @@ import chalk from "chalk";
2
2
  import { verify } from "../steps/verify.js";
3
3
  import { ok, fail, info } from "../lib/display.js";
4
4
  import { getVersion } from "../lib/version.js";
5
+ /**
6
+ * The `--verify` flow: prints one check line per verify-step result
7
+ * (manifest, per-harness readiness/MCP/files/hook, API key) and exits 1
8
+ * when any check failed. Read-only — makes no changes.
9
+ */
5
10
  export async function runVerify() {
6
11
  const version = await getVersion();
7
12
  console.log();
@@ -9,6 +9,7 @@ import { ULUOPS_SERVERS, } from "./types.js";
9
9
  import { readConfig, mergeUluopsMcp, removeUluopsMcp, writeConfig, } from "../lib/config-merger.js";
10
10
  import { readSettings, writeSettings, mergeUluopsHook, removeUluopsHook, hasUluopsHook, } from "../lib/settings-merger.js";
11
11
  import { getClaudeHome, getClaudeJsonPath } from "../lib/paths.js";
12
+ import { serialize } from "../lib/write-coordinator.js";
12
13
  class ClaudeCodeMcpConfig {
13
14
  async read(path) {
14
15
  return readConfig(path);
@@ -33,18 +34,25 @@ class ClaudeCodeHooks {
33
34
  async install(settingsPath, hookCommand, dryRun) {
34
35
  if (dryRun)
35
36
  return true;
36
- const settings = await readSettings(settingsPath);
37
- // Claude Code uses SubagentStop as the default event for auto-save
38
- const merged = mergeUluopsHook(settings, hookCommand);
39
- await writeSettings(settingsPath, merged);
37
+ // Serialized for uniformity with the MCP cycle — the invariant is
38
+ // "every settings RMW is exclusive per path", not "only where files
39
+ // collide today".
40
+ await serialize(settingsPath, async () => {
41
+ const settings = await readSettings(settingsPath);
42
+ // Claude Code uses SubagentStop as the default event for auto-save
43
+ const merged = mergeUluopsHook(settings, hookCommand);
44
+ await writeSettings(settingsPath, merged);
45
+ });
40
46
  return true;
41
47
  }
42
48
  async remove(settingsPath, dryRun) {
43
49
  if (dryRun)
44
50
  return;
45
- const settings = await readSettings(settingsPath);
46
- const cleaned = removeUluopsHook(settings);
47
- await writeSettings(settingsPath, cleaned);
51
+ await serialize(settingsPath, async () => {
52
+ const settings = await readSettings(settingsPath);
53
+ const cleaned = removeUluopsHook(settings);
54
+ await writeSettings(settingsPath, cleaned);
55
+ });
48
56
  }
49
57
  async check(settingsPath) {
50
58
  const settings = await readSettings(settingsPath);
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { ULUOPS_SERVERS, } from "./types.js";
13
13
  import { atomicWrite } from "../lib/atomic-write.js";
14
+ import { isEnoent } from "../lib/file-ops.js";
14
15
  import { OPS_MCP_SPEC, REGISTRY_MCP_SPEC } from "../lib/mcp-packages.js";
15
16
  const RAW_TOML = "__rawToml";
16
17
  /**
@@ -146,10 +147,17 @@ function removeServerConfigBlocks(raw, name) {
146
147
  const kept = [];
147
148
  let skipping = false;
148
149
  for (const line of lines) {
149
- const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
150
+ const trimmed = line.trim();
151
+ const table = trimmed.match(/^\[([^\]]+)\]$/)?.[1];
150
152
  if (table) {
151
153
  skipping = isServerTableFor(name, table) || isServerEnvTableFor(name, table);
152
154
  }
155
+ else if (trimmed.startsWith("[")) {
156
+ // Any header the regex can't parse (array-of-tables [[x]], quoted ']')
157
+ // still STARTS a new table — the skip must end, or the user's next
158
+ // block is dropped along with ours.
159
+ skipping = false;
160
+ }
153
161
  if (!skipping)
154
162
  kept.push(line);
155
163
  }
@@ -160,10 +168,15 @@ function removeServerSubtree(raw, name) {
160
168
  const kept = [];
161
169
  let skipping = false;
162
170
  for (const line of lines) {
163
- const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
171
+ const trimmed = line.trim();
172
+ const table = trimmed.match(/^\[([^\]]+)\]$/)?.[1];
164
173
  if (table) {
165
174
  skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
166
175
  }
176
+ else if (trimmed.startsWith("[")) {
177
+ // See removeServerConfigBlocks: an unparseable header still ends the skip.
178
+ skipping = false;
179
+ }
167
180
  if (!skipping)
168
181
  kept.push(line);
169
182
  }
@@ -182,8 +195,12 @@ class CodexMcpConfig {
182
195
  try {
183
196
  return { [RAW_TOML]: await readFile(path, "utf-8") };
184
197
  }
185
- catch {
186
- return { [RAW_TOML]: "" };
198
+ catch (err) {
199
+ if (isEnoent(err))
200
+ return { [RAW_TOML]: "" };
201
+ // Unreadable-but-PRESENT must never read as fresh: write() replaces
202
+ // the whole file, so an EACCES here would erase the user's config.
203
+ throw new Error(`Could not read Codex config at ${path} (${err instanceof Error ? err.message : String(err)}) — refusing to continue rather than overwrite a file that exists but could not be read. Nothing was modified.`);
187
204
  }
188
205
  }
189
206
  merge(config, apiKey) {
@@ -13,6 +13,7 @@ import { join } from "node:path";
13
13
  import { ULUOPS_SERVERS, } from "./types.js";
14
14
  import { readConfig, mergeUluopsMcp, removeUluopsMcp, writeConfig, } from "../lib/config-merger.js";
15
15
  import { readSettings, writeSettings, mergeUluopsHook, removeUluopsHook, hasUluopsHook, } from "../lib/settings-merger.js";
16
+ import { serialize } from "../lib/write-coordinator.js";
16
17
  class GeminiMcpConfig {
17
18
  async read(path) {
18
19
  return readConfig(path);
@@ -40,17 +41,23 @@ class GeminiHooks {
40
41
  async install(settingsPath, hookCommand, dryRun) {
41
42
  if (dryRun)
42
43
  return true;
43
- const settings = await readSettings(settingsPath);
44
- const merged = mergeUluopsHook(settings, hookCommand, GeminiHooks.HOOK_TYPE, GeminiHooks.MATCHER);
45
- await writeSettings(settingsPath, merged);
44
+ // Serialized: settings.json is ALSO this profile's MCP config file —
45
+ // the whole read-merge-write must be exclusive against that cycle.
46
+ await serialize(settingsPath, async () => {
47
+ const settings = await readSettings(settingsPath);
48
+ const merged = mergeUluopsHook(settings, hookCommand, GeminiHooks.HOOK_TYPE, GeminiHooks.MATCHER);
49
+ await writeSettings(settingsPath, merged);
50
+ });
46
51
  return true;
47
52
  }
48
53
  async remove(settingsPath, dryRun) {
49
54
  if (dryRun)
50
55
  return;
51
- const settings = await readSettings(settingsPath);
52
- const cleaned = removeUluopsHook(settings, GeminiHooks.HOOK_TYPE);
53
- await writeSettings(settingsPath, cleaned);
56
+ await serialize(settingsPath, async () => {
57
+ const settings = await readSettings(settingsPath);
58
+ const cleaned = removeUluopsHook(settings, GeminiHooks.HOOK_TYPE);
59
+ await writeSettings(settingsPath, cleaned);
60
+ });
54
61
  }
55
62
  async check(settingsPath) {
56
63
  const settings = await readSettings(settingsPath);
@@ -23,5 +23,13 @@ export declare function getProfile(name: string): HarnessProfile;
23
23
  * list = safe to install" is preserved.
24
24
  */
25
25
  export declare function detectHarnesses(): HarnessProfile[];
26
+ /**
27
+ * Experimental profiles whose home dir is present — the set detectHarnesses
28
+ * deliberately excludes. Exposed so the CLI can SAY it excluded them
29
+ * (silent exclusion reads as a detection bug to a user who can see the
30
+ * harness installed) while keeping the "detected = safe to install"
31
+ * promise intact.
32
+ */
33
+ export declare function detectExcludedExperimental(): HarnessProfile[];
26
34
  /** List all available harness names (not aliases). */
27
35
  export declare function listHarnesses(): string[];
@@ -53,6 +53,16 @@ export function getProfile(name) {
53
53
  export function detectHarnesses() {
54
54
  return ALL_PROFILES.filter((p) => p.status === "stable" && existsSync(p.paths.home));
55
55
  }
56
+ /**
57
+ * Experimental profiles whose home dir is present — the set detectHarnesses
58
+ * deliberately excludes. Exposed so the CLI can SAY it excluded them
59
+ * (silent exclusion reads as a detection bug to a user who can see the
60
+ * harness installed) while keeping the "detected = safe to install"
61
+ * promise intact.
62
+ */
63
+ export function detectExcludedExperimental() {
64
+ return ALL_PROFILES.filter((p) => p.status !== "stable" && existsSync(p.paths.home));
65
+ }
56
66
  /** List all available harness names (not aliases). */
57
67
  export function listHarnesses() {
58
68
  return ALL_PROFILES.map((p) => p.name);
@@ -13,9 +13,10 @@
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { homedir } from "node:os";
15
15
  import { join, isAbsolute } from "node:path";
16
- import { parse as parseJsonc } from "jsonc-parser";
16
+ import { parse as parseJsonc, } from "jsonc-parser";
17
17
  import { ULUOPS_SERVERS, ConfigParseError } from "./types.js";
18
18
  import { atomicWrite } from "../lib/atomic-write.js";
19
+ import { isEnoent } from "../lib/file-ops.js";
19
20
  import { OPS_MCP_SPEC, REGISTRY_MCP_SPEC } from "../lib/mcp-packages.js";
20
21
  class OpenCodeMcpConfig {
21
22
  /** Maps requested path → actual resolved path (for .jsonc fallback). */
@@ -30,20 +31,37 @@ class OpenCodeMcpConfig {
30
31
  this.resolvedPaths.set(path, p);
31
32
  break;
32
33
  }
33
- catch {
34
- // Try next
34
+ catch (err) {
35
+ if (isEnoent(err))
36
+ continue; // Try next candidate
37
+ // Unreadable-but-PRESENT must never fall through to "no file →
38
+ // fresh config" — the write would replace the file we couldn't read.
39
+ throw new Error(`Could not read OpenCode config at ${p} (${err instanceof Error ? err.message : String(err)}) — refusing to continue rather than overwrite a file that exists but could not be read. Nothing was modified.`);
35
40
  }
36
41
  }
37
42
  if (raw === null) {
38
43
  this.resolvedPaths.set(path, path);
39
44
  return {};
40
45
  }
41
- try {
42
- return parseJsonc(raw);
46
+ // jsonc-parser's parse() is error-RECOVERING, never throwing: on a
47
+ // syntax error it silently returns whatever it salvaged, and everything
48
+ // after the error point would be dropped, merged, and written back over
49
+ // the user's file. The errors out-param is the only honest signal.
50
+ const parseErrors = [];
51
+ const parsed = parseJsonc(raw, parseErrors, {
52
+ allowTrailingComma: true,
53
+ });
54
+ if (parseErrors.length > 0) {
55
+ const first = parseErrors[0];
56
+ throw new ConfigParseError(path, new Error(`invalid JSONC (error code ${first.error} at offset ${first.offset}) — fix or remove the file and re-run; nothing was modified`));
43
57
  }
44
- catch (err) {
45
- throw new ConfigParseError(path, err);
58
+ // Same top-level gate as config-merger/settings-merger: valid JSONC that
59
+ // isn't an object cannot be merged into — writing it back mangled with
60
+ // no error is worse than refusing.
61
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
62
+ throw new ConfigParseError(path, new Error("expected a JSON object at the top level"));
46
63
  }
64
+ return parsed;
47
65
  }
48
66
  merge(config, apiKey) {
49
67
  const raw = config["mcp"];
@@ -38,12 +38,25 @@ async function scanCommandDir(dir) {
38
38
  try {
39
39
  files = await readdir(dir);
40
40
  }
41
- catch {
41
+ catch (err) {
42
+ if (err?.code !== "ENOENT") {
43
+ // Unreadable bundled dir is a broken build, not "ships no commands".
44
+ console.warn(` ⚠ Could not read bundled commands dir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
45
+ }
42
46
  return [];
43
47
  }
44
48
  const entries = [];
45
49
  for (const file of files.filter((f) => f.endsWith(".md"))) {
46
- const content = await readFile(join(dir, file), "utf-8");
50
+ let content;
51
+ try {
52
+ content = await readFile(join(dir, file), "utf-8");
53
+ }
54
+ catch (err) {
55
+ // Display/catalog path: an unreadable bundled file (truncated npx
56
+ // cache) must not throw out of the summary — name it and move on.
57
+ console.warn(` ⚠ Could not read bundled ${file}: ${err instanceof Error ? err.message : String(err)}`);
58
+ continue;
59
+ }
47
60
  const fm = parseFrontmatter(content);
48
61
  if (fm["name"]) {
49
62
  entries.push({
@@ -8,4 +8,10 @@ export interface AtomicWriteOptions {
8
8
  /** File mode (permissions). Defaults to Node's default (0o666 before umask). */
9
9
  mode?: number;
10
10
  }
11
+ /**
12
+ * Write `content` to `path` atomically: write to a random-suffixed temp file
13
+ * in the same directory, then rename over the target. Readers see either the
14
+ * old file or the new one, never a partial write. The temp file is unlinked
15
+ * on failure.
16
+ */
11
17
  export declare function atomicWrite(path: string, content: string, options?: AtomicWriteOptions): Promise<void>;
@@ -6,6 +6,13 @@
6
6
  */
7
7
  import { writeFile, rename, unlink, chmod } from "node:fs/promises";
8
8
  import { randomBytes } from "node:crypto";
9
+ import { recordWrite } from "./write-coordinator.js";
10
+ /**
11
+ * Write `content` to `path` atomically: write to a random-suffixed temp file
12
+ * in the same directory, then rename over the target. Readers see either the
13
+ * old file or the new one, never a partial write. The temp file is unlinked
14
+ * on failure.
15
+ */
9
16
  export async function atomicWrite(path, content, options) {
10
17
  // Random suffix + 'wx' flag (O_CREAT|O_EXCL) prevents symlink races:
11
18
  // an attacker cannot pre-position a symlink at an unpredictable path,
@@ -22,6 +29,9 @@ export async function atomicWrite(path, content, options) {
22
29
  await chmod(tmp, options.mode);
23
30
  }
24
31
  await rename(tmp, path);
32
+ // Attest the write for the coordinator's restore guard — only content
33
+ // this process provably wrote may be rolled back over.
34
+ recordWrite(path, content);
25
35
  }
26
36
  catch (err) {
27
37
  // Clean up temp file on failure
@@ -1,6 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { atomicWrite } from "./atomic-write.js";
3
- import { MCP_PACKAGES, OPS_MCP_SPEC, REGISTRY_MCP_SPEC, } from "./mcp-packages.js";
3
+ import { stripDangerousKeys } from "./json-guards.js";
4
+ import { isEnoent } from "./file-ops.js";
5
+ import { MCP_PROBE_TARGETS, OPS_MCP_SPEC, REGISTRY_MCP_SPEC, } from "./mcp-packages.js";
4
6
  /**
5
7
  * In-process memoization for the npm availability probe.
6
8
  *
@@ -36,18 +38,21 @@ export function checkMcpPackageAvailability() {
36
38
  async function probeAvailability() {
37
39
  const available = [];
38
40
  const missing = [];
39
- const results = await Promise.allSettled(MCP_PACKAGES.map((pkg) => fetch(`https://registry.npmjs.org/${pkg}`, {
41
+ // Probe the PINNED version endpoints — the harness runs `npx -y <spec>`,
42
+ // so pin resolvability is the question, not name existence.
43
+ const results = await Promise.allSettled(MCP_PROBE_TARGETS.map(({ pkg, version }) => fetch(`https://registry.npmjs.org/${pkg}/${version}`, {
40
44
  method: "HEAD",
41
45
  signal: AbortSignal.timeout(5000),
42
46
  redirect: "follow",
43
47
  }).then((res) => ({ pkg, ok: res.ok }))));
44
- // Per-index correspondence: results[i] corresponds to MCP_PACKAGES[i] by
48
+ // Per-index correspondence: results[i] corresponds to MCP_PROBE_TARGETS[i] by
45
49
  // Promise.allSettled's stable ordering. The previous `?? "unknown"` fallback
46
50
  // could emit a literal "unknown" string into `missing`, hiding the real
47
51
  // failure reason (DNS error, timeout, 404) under an undiagnosable label.
48
52
  for (let i = 0; i < results.length; i++) {
49
53
  const result = results[i];
50
- const pkg = MCP_PACKAGES[i];
54
+ const target = MCP_PROBE_TARGETS[i];
55
+ const pkg = `${target.pkg}@${target.version}`;
51
56
  if (result.status === "fulfilled") {
52
57
  if (result.value.ok) {
53
58
  available.push(pkg);
@@ -76,15 +81,29 @@ export async function readConfig(path) {
76
81
  try {
77
82
  raw = await readFile(path, "utf-8");
78
83
  }
79
- catch {
80
- return {}; // File doesn't exist — fresh config
84
+ catch (err) {
85
+ if (isEnoent(err))
86
+ return {}; // File doesn't exist — fresh config
87
+ // Unreadable-but-PRESENT (EACCES/EISDIR/EIO) must never read as fresh:
88
+ // the {} would be merged and renamed over the file we couldn't read.
89
+ throw new Error(`Could not read config at ${path} (${err instanceof Error ? err.message : String(err)}) — refusing to continue rather than overwrite a file that exists but could not be read. Nothing was modified.`);
81
90
  }
91
+ let parsed;
82
92
  try {
83
- return JSON.parse(raw);
93
+ parsed = JSON.parse(raw);
84
94
  }
85
95
  catch {
86
- throw new Error(`Failed to parse config at ${path} — file contains invalid JSON`);
96
+ // The read happens BEFORE any write — say so, or a pre-existing broken
97
+ // file reads as UluOps-caused corruption.
98
+ throw new Error(`Failed to parse config at ${path} — file contains invalid JSON. ` +
99
+ `(Detected before any UluOps change; nothing was modified. Fix or remove the file and re-run.)`);
100
+ }
101
+ // Same rationale as the JSON throw: valid JSON that isn't an object cannot
102
+ // be merged into — spreading it would corrupt the file we then write back.
103
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
104
+ throw new Error(`Failed to parse config at ${path} — expected a JSON object at the top level`);
87
105
  }
106
+ return stripDangerousKeys(parsed);
88
107
  }
89
108
  /**
90
109
  * Merge UluOps MCP server entries into a config, preserving all other keys.
@@ -4,6 +4,12 @@ declare const warn: (msg: string) => void;
4
4
  declare const fail: (msg: string) => void;
5
5
  declare const info: (msg: string) => void;
6
6
  export { ok, warn, fail, info };
7
+ /** Blank spacer line — keeps command modules free of raw console calls. */
8
+ export declare function blank(): void;
9
+ /** Per-harness section header in the multi-harness install loop. */
10
+ export declare function printHarnessHeader(displayName: string): void;
11
+ /** The branded setup banner: logo, tagline, version + target line. */
12
+ export declare function printSetupBanner(version: string, targetSummary: string): void;
7
13
  /**
8
14
  * Render the final post-run summary.
9
15
  *
@@ -26,5 +32,7 @@ export declare function printSetupSummary(input: {
26
32
  results: PerHarnessResult[];
27
33
  apiKey: string;
28
34
  }): Promise<void>;
35
+ /** Mask an API key for display: all but the last 4 chars become `*` (minimum 4 stars). */
29
36
  export declare function maskKey(key: string): string;
37
+ /** Render the `--list` output: workflows then agents from the asset catalog, descriptions truncated to fit. */
30
38
  export declare function printAgentList(): Promise<void>;
@@ -5,6 +5,23 @@ const warn = (msg) => console.log(` ${chalk.yellow("⚠")} ${msg}`);
5
5
  const fail = (msg) => console.log(` ${chalk.red("✗")} ${msg}`);
6
6
  const info = (msg) => console.log(` ${msg}`);
7
7
  export { ok, warn, fail, info };
8
+ /** Blank spacer line — keeps command modules free of raw console calls. */
9
+ export function blank() {
10
+ console.log();
11
+ }
12
+ /** Per-harness section header in the multi-harness install loop. */
13
+ export function printHarnessHeader(displayName) {
14
+ console.log(chalk.dim(`▸ ${displayName}`));
15
+ }
16
+ /** The branded setup banner: logo, tagline, version + target line. */
17
+ export function printSetupBanner(version, targetSummary) {
18
+ console.log();
19
+ console.log(` ${chalk.dim("⟨u⟩")} ${chalk.cyan.bold("ulu")}${chalk.bold("·ops")}`);
20
+ console.log(` ${chalk.dim("operating intelligence as infrastructure")}`);
21
+ console.log();
22
+ console.log(` Setup v${version} — ${chalk.bold(targetSummary)}`);
23
+ console.log();
24
+ }
8
25
  const DIVIDER = ` ${chalk.dim("━".repeat(46))}`;
9
26
  /**
10
27
  * Render the final post-run summary.
@@ -74,7 +91,14 @@ export async function printSetupSummary(input) {
74
91
  if (total === 1 &&
75
92
  results[0].status === "ok" &&
76
93
  results[0].profile.name === "claude-code") {
77
- await printAgentList();
94
+ try {
95
+ await printAgentList();
96
+ }
97
+ catch (err) {
98
+ // The catalog is decorative; the export line and restart instruction
99
+ // below are the actionable tail — never lose them to a listing error.
100
+ warn(`Could not list bundled agents: ${err instanceof Error ? err.message : String(err)}`);
101
+ }
78
102
  }
79
103
  // API-key reminder — once per run regardless of harness count.
80
104
  const masked = maskKey(apiKey);
@@ -135,12 +159,14 @@ function renderCounts(r) {
135
159
  parts.push("metrics");
136
160
  return parts.length > 0 ? `(${parts.join(" · ")})` : "";
137
161
  }
162
+ /** Mask an API key for display: all but the last 4 chars become `*` (minimum 4 stars). */
138
163
  export function maskKey(key) {
139
164
  if (!key || key.length <= 4)
140
165
  return "****";
141
166
  const last4 = key.slice(-4);
142
167
  return `${"*".repeat(Math.max(4, key.length - 4))}${last4}`;
143
168
  }
169
+ /** Render the `--list` output: workflows then agents from the asset catalog, descriptions truncated to fit. */
144
170
  export async function printAgentList() {
145
171
  const workflows = await getWorkflowCommands();
146
172
  const agents = await getAgentCommands();
@@ -1,3 +1,13 @@
1
+ /**
2
+ * True when `err` is fs ENOENT — the ONLY read error that means "absent".
3
+ * Every read-then-overwrite path must use this before treating a file as
4
+ * fresh: EACCES/EISDIR/EIO also land in a catch, and inferring "absent" from
5
+ * them turns an unreadable-but-present config into a fresh-file overwrite
6
+ * that destroys the user's content. (This class was fixed once at
7
+ * steps/mcp.ts's gitignore path and recurred at five other sites — hence a
8
+ * shared predicate rather than five inline checks.)
9
+ */
10
+ export declare function isEnoent(err: unknown): boolean;
1
11
  /**
2
12
  * Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
3
13
  */
@@ -18,10 +28,9 @@ export declare function unlinkFiles(dir: string, files: string[]): Promise<numbe
18
28
  * (whether or not the unlink actually ran in dry-run mode).
19
29
  *
20
30
  * Extracted from three near-identical blocks in syncAssets, installAgents,
21
- * and installCommands. Errors from unlink are swallowed silently — the
22
- * "already gone" case is the dominant one (idempotent re-run, manual user
23
- * deletion, prior failed install), and there's no recovery the caller
24
- * can usefully perform mid-loop.
31
+ * and installCommands. ENOENT unlink failures are tolerated silently (the
32
+ * dominant, idempotent case); any other failure is warned by name and
33
+ * excluded from the removed count.
25
34
  */
26
35
  export declare function removeStaleFiles(destDir: string, oldManifestFiles: string[] | undefined, currentFiles: string[], dryRun: boolean): Promise<number>;
27
36
  /**