@uluops/setup 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +91 -9
  2. package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
  3. package/dist/cli/select-harnesses.d.ts +91 -0
  4. package/dist/cli/select-harnesses.js +108 -0
  5. package/dist/cli.js +78 -37
  6. package/dist/commands/errors.d.ts +24 -0
  7. package/dist/commands/errors.js +28 -0
  8. package/dist/commands/helpers.d.ts +7 -0
  9. package/dist/commands/helpers.js +80 -3
  10. package/dist/commands/per-harness.d.ts +64 -0
  11. package/dist/commands/per-harness.js +37 -0
  12. package/dist/commands/setup.d.ts +5 -3
  13. package/dist/commands/setup.js +174 -48
  14. package/dist/commands/uninstall-filter.d.ts +36 -0
  15. package/dist/commands/uninstall-filter.js +69 -0
  16. package/dist/commands/uninstall.d.ts +12 -2
  17. package/dist/commands/uninstall.js +121 -45
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +89 -21
  20. package/dist/harnesses/index.js +6 -1
  21. package/dist/harnesses/opencode.d.ts +8 -0
  22. package/dist/harnesses/opencode.js +24 -1
  23. package/dist/harnesses/types.d.ts +2 -0
  24. package/dist/harnesses/types.js +5 -1
  25. package/dist/lib/atomic-write.js +10 -2
  26. package/dist/lib/config-merger.d.ts +6 -3
  27. package/dist/lib/config-merger.js +50 -7
  28. package/dist/lib/display.d.ts +21 -5
  29. package/dist/lib/display.js +118 -13
  30. package/dist/lib/file-ops.d.ts +13 -5
  31. package/dist/lib/file-ops.js +34 -34
  32. package/dist/lib/install-lock.js +11 -1
  33. package/dist/lib/json-guards.d.ts +22 -0
  34. package/dist/lib/json-guards.js +33 -0
  35. package/dist/lib/manifest.d.ts +20 -0
  36. package/dist/lib/manifest.js +61 -12
  37. package/dist/lib/paths.d.ts +0 -17
  38. package/dist/lib/paths.js +0 -19
  39. package/dist/lib/settings-merger.js +3 -1
  40. package/dist/steps/agent-metrics-cli.d.ts +9 -1
  41. package/dist/steps/agent-metrics-cli.js +66 -20
  42. package/dist/steps/agents.d.ts +11 -0
  43. package/dist/steps/agents.js +30 -25
  44. package/dist/steps/auth.d.ts +13 -0
  45. package/dist/steps/auth.js +61 -5
  46. package/dist/steps/cli.d.ts +6 -0
  47. package/dist/steps/cli.js +29 -10
  48. package/dist/steps/commands.d.ts +10 -0
  49. package/dist/steps/commands.js +31 -30
  50. package/dist/steps/detect.js +15 -1
  51. package/dist/steps/mcp.js +1 -8
  52. package/dist/steps/metrics.js +10 -3
  53. package/dist/steps/shell.js +3 -13
  54. package/dist/steps/signup.js +14 -1
  55. package/dist/steps/skills.d.ts +14 -0
  56. package/dist/steps/skills.js +95 -0
  57. package/dist/steps/verify.js +195 -91
  58. package/package.json +3 -2
@@ -1,34 +1,101 @@
1
1
  /**
2
- * Codex Harness Profile (Scaffold)
2
+ * Codex Harness Profile
3
3
  *
4
- * Paths and metadata are verified from vendor docs.
5
- * Codex uses TOML config with `mcp_servers` key (nested tables).
4
+ * Codex uses TOML config with `mcp_servers` nested tables.
6
5
  * Agent definitions are TOML, not markdown.
7
- * Skills use a different path ($HOME/.agents/skills/) than agents (~/.codex/agents/).
8
- *
9
- * NOT YET TESTED with UluOps agents. McpConfigStrategy throws
10
- * until integration testing is complete.
11
- *
12
- * Will require `smol-toml` dependency when fully implemented.
6
+ * Skills live under ~/.codex/skills and are the preferred Codex-native
7
+ * surface for UluOps operator workflows.
13
8
  */
9
+ import { readFile } from "node:fs/promises";
14
10
  import { homedir } from "node:os";
15
11
  import { join } from "node:path";
16
- import { HarnessNotTestedError } from "./types.js";
12
+ import { ULUOPS_SERVERS, } from "./types.js";
13
+ import { atomicWrite } from "../lib/atomic-write.js";
14
+ const RAW_TOML = "__rawToml";
15
+ function tomlString(value) {
16
+ return JSON.stringify(value);
17
+ }
18
+ function serverBlock(name, pkg, apiKey) {
19
+ return [
20
+ `[mcp_servers.${tomlString(name)}]`,
21
+ `command = "npx"`,
22
+ `args = ["-y", ${tomlString(pkg)}]`,
23
+ ``,
24
+ `[mcp_servers.${tomlString(name)}.env]`,
25
+ `ULUOPS_API_KEY = ${tomlString(apiKey)}`,
26
+ ].join("\n");
27
+ }
28
+ function isServerTableFor(name, table) {
29
+ return table === `mcp_servers.${name}` || table === `mcp_servers."${name}"`;
30
+ }
31
+ function isServerSubtableFor(name, table) {
32
+ const unquotedPrefix = `mcp_servers.${name}.`;
33
+ const quotedPrefix = `mcp_servers."${name}".`;
34
+ return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
35
+ }
36
+ function removeServerConfigBlocks(raw, name) {
37
+ const lines = raw.split("\n");
38
+ const kept = [];
39
+ let skipping = false;
40
+ for (const line of lines) {
41
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
42
+ if (table) {
43
+ skipping = isServerTableFor(name, table) || table === `mcp_servers.${name}.env` || table === `mcp_servers."${name}".env`;
44
+ }
45
+ if (!skipping)
46
+ kept.push(line);
47
+ }
48
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
49
+ }
50
+ function removeServerSubtree(raw, name) {
51
+ const lines = raw.split("\n");
52
+ const kept = [];
53
+ let skipping = false;
54
+ for (const line of lines) {
55
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
56
+ if (table) {
57
+ skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
58
+ }
59
+ if (!skipping)
60
+ kept.push(line);
61
+ }
62
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
63
+ }
17
64
  class CodexMcpConfig {
18
- async read() {
19
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
65
+ async read(path) {
66
+ try {
67
+ return { [RAW_TOML]: await readFile(path, "utf-8") };
68
+ }
69
+ catch {
70
+ return { [RAW_TOML]: "" };
71
+ }
20
72
  }
21
- merge() {
22
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
73
+ merge(config, apiKey) {
74
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
75
+ raw = removeServerConfigBlocks(removeServerConfigBlocks(raw, "uluops-tracker"), "uluops-registry");
76
+ const blocks = [
77
+ serverBlock("uluops-tracker", "@uluops/ops-mcp", apiKey),
78
+ serverBlock("uluops-registry", "@uluops/registry-mcp", apiKey),
79
+ ].join("\n\n");
80
+ return { [RAW_TOML]: [raw.trimEnd(), blocks].filter(Boolean).join("\n\n") + "\n" };
23
81
  }
24
- remove() {
25
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
82
+ remove(config) {
83
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
84
+ for (const name of ULUOPS_SERVERS) {
85
+ raw = removeServerSubtree(raw, name);
86
+ }
87
+ return { [RAW_TOML]: raw.trimEnd() ? `${raw.trimEnd()}\n` : "" };
26
88
  }
27
- async write() {
28
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
89
+ async write(path, config) {
90
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
91
+ await atomicWrite(path, raw, { mode: 0o600 });
29
92
  }
30
- check() {
31
- throw new HarnessNotTestedError("Codex (MCP config check not supported)");
93
+ check(config) {
94
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
95
+ return ULUOPS_SERVERS.every((name) => {
96
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
97
+ return new RegExp(String.raw `\[mcp_servers\.(?:"${escaped}"|${escaped})\]`).test(raw);
98
+ });
32
99
  }
33
100
  }
34
101
  const home = join(homedir(), ".codex");
@@ -45,7 +112,8 @@ export const codexProfile = {
45
112
  globalMcpConfig: join(home, "config.toml"),
46
113
  localMcpConfig: ".codex/config.toml",
47
114
  agentsDir: join(home, "agents"),
48
- commandsDir: join(homedir(), ".agents", "skills"),
115
+ commandsDir: join(home, "commands"),
116
+ skillsDir: join(home, "skills"),
49
117
  settingsPath: null,
50
118
  toolsDir: null,
51
119
  },
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync } from "node:fs";
8
8
  import { claudeCodeProfile } from "./claude-code.js";
9
- import { opencodeProfile } from "./opencode.js";
9
+ import { opencodeProfile, assertOpencodeEnvironment } from "./opencode.js";
10
10
  import { geminiCliProfile } from "./gemini-cli.js";
11
11
  import { codexProfile } from "./codex.js";
12
12
  export { ConfigParseError, HarnessNotTestedError, } from "./types.js";
@@ -33,6 +33,11 @@ export function getProfile(name) {
33
33
  const available = ALL_PROFILES.map((p) => p.name).join(", ");
34
34
  throw new Error(`Unknown harness "${name}". Available: ${available}`);
35
35
  }
36
+ // Harness-specific environment validation deferred from module-load to
37
+ // selection time. Keeps `--help` and `--uninstall` usable even when an
38
+ // unrelated harness's env is misconfigured.
39
+ if (resolved === "opencode")
40
+ assertOpencodeEnvironment();
36
41
  return profile;
37
42
  }
38
43
  /**
@@ -11,4 +11,12 @@
11
11
  * Verified working shape from ~/opencode.jsonc (2026-04-30).
12
12
  */
13
13
  import { type HarnessProfile } from "./types.js";
14
+ /**
15
+ * Throws the deferred XDG_CONFIG_HOME validation error, if any. Called by
16
+ * the harness registry when the user actually selects opencode (via
17
+ * `--harness opencode` or auto-detection picking it). Other entry points
18
+ * (`--help`, `--uninstall` of an unrelated harness, `--list`) never trigger
19
+ * this and remain usable.
20
+ */
21
+ export declare function assertOpencodeEnvironment(): void;
14
22
  export declare const opencodeProfile: HarnessProfile;
@@ -106,17 +106,40 @@ class OpenCodeMcpConfig {
106
106
  return ULUOPS_SERVERS.every((name) => name in mcp);
107
107
  }
108
108
  }
109
+ /**
110
+ * XDG_CONFIG_HOME validation is deferred from module-load to first opencode
111
+ * use. The previous design threw at import, which crashed `uluops-setup
112
+ * --help` and `--uninstall` for any user with an invalid XDG_CONFIG_HOME —
113
+ * blocking them from running the very commands they need to recover. Now
114
+ * the IIFE caches any validation error and the harness registry calls
115
+ * `assertOpencodeEnvironment()` only when the opencode profile is actually
116
+ * selected. Unselected, the module loads cleanly and the fallback path is
117
+ * used for shape-only computations.
118
+ */
119
+ let deferredXdgConfigError = null;
109
120
  const xdgConfig = (() => {
110
121
  const env = process.env["XDG_CONFIG_HOME"];
111
122
  if (env) {
112
123
  if (!isAbsolute(env) || env.includes("..")) {
113
- throw new Error(`XDG_CONFIG_HOME must be an absolute path without traversal: ${env}`);
124
+ deferredXdgConfigError = new Error(`XDG_CONFIG_HOME must be an absolute path without traversal: ${env}`);
125
+ return join(homedir(), ".config");
114
126
  }
115
127
  return env;
116
128
  }
117
129
  return join(homedir(), ".config");
118
130
  })();
119
131
  const home = join(xdgConfig, "opencode");
132
+ /**
133
+ * Throws the deferred XDG_CONFIG_HOME validation error, if any. Called by
134
+ * the harness registry when the user actually selects opencode (via
135
+ * `--harness opencode` or auto-detection picking it). Other entry points
136
+ * (`--help`, `--uninstall` of an unrelated harness, `--list`) never trigger
137
+ * this and remain usable.
138
+ */
139
+ export function assertOpencodeEnvironment() {
140
+ if (deferredXdgConfigError)
141
+ throw deferredXdgConfigError;
142
+ }
120
143
  export const opencodeProfile = {
121
144
  name: "opencode",
122
145
  displayName: "OpenCode",
@@ -54,6 +54,8 @@ export interface HarnessPaths {
54
54
  readonly agentsDir: string;
55
55
  /** Global commands/skills dir */
56
56
  readonly commandsDir: string;
57
+ /** Global Codex-style skills dir, when the harness supports skills as first-class install assets */
58
+ readonly skillsDir?: string | null;
57
59
  /** Settings file path, or null if harness has no settings file */
58
60
  readonly settingsPath: string | null;
59
61
  /** Tool installation dir, or null if harness has no tool installation */
@@ -20,7 +20,11 @@ export class ConfigParseError extends Error {
20
20
  /** Thrown when a harness is scaffolded but not yet tested/implemented. */
21
21
  export class HarnessNotTestedError extends Error {
22
22
  constructor(harnessName) {
23
- super(`${harnessName} harness is not yet tested. Use --harness claude-code or --harness opencode.`);
23
+ super(
24
+ // Keep this list in sync with profiles whose `status === "stable"`.
25
+ // Today: claude-code, opencode, gemini-cli. When a new stable profile
26
+ // lands, add it here so the error stays actionable.
27
+ `${harnessName} harness is not yet tested. Use --harness claude-code, --harness opencode, or --harness gemini-cli.`);
24
28
  this.name = "HarnessNotTestedError";
25
29
  }
26
30
  }
@@ -5,10 +5,18 @@
5
5
  * from corrupting user config files.
6
6
  */
7
7
  import { writeFile, rename, unlink, chmod } from "node:fs/promises";
8
+ import { randomBytes } from "node:crypto";
8
9
  export async function atomicWrite(path, content, options) {
9
- const tmp = `${path}.uluops-tmp`;
10
+ // Random suffix + 'wx' flag (O_CREAT|O_EXCL) prevents symlink races:
11
+ // an attacker cannot pre-position a symlink at an unpredictable path,
12
+ // and 'wx' fails atomically rather than following one that exists.
13
+ const tmp = `${path}.uluops-tmp.${randomBytes(8).toString("hex")}`;
10
14
  try {
11
- await writeFile(tmp, content, { encoding: "utf-8", mode: options?.mode });
15
+ await writeFile(tmp, content, {
16
+ encoding: "utf-8",
17
+ mode: options?.mode,
18
+ flag: "wx",
19
+ });
12
20
  if (options?.mode) {
13
21
  // Ensure mode is applied even if umask is permissive
14
22
  await chmod(tmp, options.mode);
@@ -8,11 +8,14 @@ export interface ClaudeConfig {
8
8
  mcpServers?: Record<string, McpServerConfig>;
9
9
  [key: string]: unknown;
10
10
  }
11
- /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
12
- export declare function checkMcpPackageAvailability(): Promise<{
11
+ interface AvailabilityResult {
13
12
  available: string[];
14
13
  missing: string[];
15
- }>;
14
+ }
15
+ /** Test-only: drop the memoized availability promise so the next call re-probes. */
16
+ export declare function __resetAvailabilityCacheForTesting(): void;
17
+ /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
18
+ export declare function checkMcpPackageAvailability(): Promise<AvailabilityResult>;
16
19
  /**
17
20
  * Read an existing config file, or return empty object if it doesn't exist.
18
21
  * Throws on malformed JSON to prevent silent data loss during merge+write.
@@ -1,8 +1,39 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { atomicWrite } from "./atomic-write.js";
3
3
  const MCP_PACKAGES = ["@uluops/ops-mcp", "@uluops/registry-mcp"];
4
+ /**
5
+ * In-process memoization for the npm availability probe.
6
+ *
7
+ * Setup runs once per process; with multi-harness installs (or any future
8
+ * code path that calls installMcp more than once), the probe was firing
9
+ * redundantly against the npm registry. We cache the in-flight promise so
10
+ * concurrent callers share a single round-trip and subsequent callers get
11
+ * the resolved value instantly. Cache lifetime is the process — setup is
12
+ * one-shot, so a TTL adds state without buying anything.
13
+ *
14
+ * `__resetAvailabilityCacheForTesting` exists ONLY for tests that stub
15
+ * `fetch` per-case. Without a reset, the first test in a file would lock
16
+ * the cached result for every subsequent test in the same process.
17
+ */
18
+ let _availabilityCache = null;
19
+ /** Test-only: drop the memoized availability promise so the next call re-probes. */
20
+ export function __resetAvailabilityCacheForTesting() {
21
+ _availabilityCache = null;
22
+ }
4
23
  /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
5
- export async function checkMcpPackageAvailability() {
24
+ export function checkMcpPackageAvailability() {
25
+ if (_availabilityCache)
26
+ return _availabilityCache;
27
+ _availabilityCache = probeAvailability().catch((err) => {
28
+ // If the probe itself throws unexpectedly (not an individual fetch — those
29
+ // are caught by Promise.allSettled), drop the cache so retries don't
30
+ // permanently inherit a poisoned promise.
31
+ _availabilityCache = null;
32
+ throw err;
33
+ });
34
+ return _availabilityCache;
35
+ }
36
+ async function probeAvailability() {
6
37
  const available = [];
7
38
  const missing = [];
8
39
  const results = await Promise.allSettled(MCP_PACKAGES.map((pkg) => fetch(`https://registry.npmjs.org/${pkg}`, {
@@ -10,16 +41,28 @@ export async function checkMcpPackageAvailability() {
10
41
  signal: AbortSignal.timeout(5000),
11
42
  redirect: "follow",
12
43
  }).then((res) => ({ pkg, ok: res.ok }))));
44
+ // Per-index correspondence: results[i] corresponds to MCP_PACKAGES[i] by
45
+ // Promise.allSettled's stable ordering. The previous `?? "unknown"` fallback
46
+ // could emit a literal "unknown" string into `missing`, hiding the real
47
+ // failure reason (DNS error, timeout, 404) under an undiagnosable label.
13
48
  for (let i = 0; i < results.length; i++) {
14
49
  const result = results[i];
15
- if (result.status === "fulfilled" && result.value.ok) {
16
- available.push(result.value.pkg);
50
+ const pkg = MCP_PACKAGES[i];
51
+ if (result.status === "fulfilled") {
52
+ if (result.value.ok) {
53
+ available.push(pkg);
54
+ }
55
+ else {
56
+ // Registry returned non-2xx — package likely missing or unpublished.
57
+ missing.push(pkg);
58
+ }
17
59
  }
18
60
  else {
19
- const pkg = result.status === "fulfilled"
20
- ? result.value.pkg
21
- : MCP_PACKAGES[i] ?? "unknown";
22
- missing.push(pkg);
61
+ // Network failure: AbortError (timeout), DNS, TLS, EAI_AGAIN, etc.
62
+ const reason = result.reason instanceof Error
63
+ ? result.reason.message
64
+ : String(result.reason);
65
+ missing.push(`${pkg} (network: ${reason})`);
23
66
  }
24
67
  }
25
68
  return { available, missing };
@@ -1,13 +1,29 @@
1
- import type { HarnessProfile } from "../harnesses/index.js";
1
+ import type { PerHarnessResult } from "../commands/per-harness.js";
2
2
  declare const ok: (msg: string) => void;
3
3
  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
- export declare function printSetupSummary(opts: {
8
- profile: HarnessProfile;
9
- agentCount: number;
10
- commandCount: number;
7
+ /**
8
+ * Render the final post-run summary.
9
+ *
10
+ * Single-harness: preserves today's banner format (Setup complete!
11
+ * + agent list + restart instruction) — regression baseline.
12
+ *
13
+ * Multi-harness: aggregate header line, per-harness section block with
14
+ * status icons + counts + re-run hints, single API-key reminder, single
15
+ * restart instruction naming each successfully-installed harness.
16
+ *
17
+ * Status rendering:
18
+ * ok — ✓ green installed (counts)
19
+ * ok+files-failed — same line, the per-step warn()s already surfaced
20
+ * the failed files during install (not re-printed)
21
+ * failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
22
+ * failed (pre-MCP)— ✗ red failed — <error>; re-run hint
23
+ * declined — ⊘ dim skipped — user declined conflict prompt
24
+ */
25
+ export declare function printSetupSummary(input: {
26
+ results: PerHarnessResult[];
11
27
  apiKey: string;
12
28
  }): Promise<void>;
13
29
  export declare function maskKey(key: string): string;
@@ -5,30 +5,135 @@ 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
- export async function printSetupSummary(opts) {
8
+ const DIVIDER = ` ${chalk.dim("━".repeat(46))}`;
9
+ /**
10
+ * Render the final post-run summary.
11
+ *
12
+ * Single-harness: preserves today's banner format (Setup complete!
13
+ * + agent list + restart instruction) — regression baseline.
14
+ *
15
+ * Multi-harness: aggregate header line, per-harness section block with
16
+ * status icons + counts + re-run hints, single API-key reminder, single
17
+ * restart instruction naming each successfully-installed harness.
18
+ *
19
+ * Status rendering:
20
+ * ok — ✓ green installed (counts)
21
+ * ok+files-failed — same line, the per-step warn()s already surfaced
22
+ * the failed files during install (not re-printed)
23
+ * failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
24
+ * failed (pre-MCP)— ✗ red failed — <error>; re-run hint
25
+ * declined — ⊘ dim skipped — user declined conflict prompt
26
+ */
27
+ export async function printSetupSummary(input) {
28
+ const { results, apiKey } = input;
29
+ if (results.length === 0) {
30
+ // runSetup's empty-list branch already printed "nothing to install"
31
+ // and returned; this is defense-in-depth so the summary never crashes
32
+ // on an empty input.
33
+ return;
34
+ }
9
35
  console.log();
10
- console.log(` ${chalk.dim("━".repeat(46))}`);
36
+ console.log(DIVIDER);
11
37
  console.log();
12
- const parts = [`${opts.agentCount} agents`];
13
- if (opts.commandCount > 0)
14
- parts.push(`${opts.commandCount} slash commands`);
15
- if (opts.profile.hooks)
16
- parts.push("metrics");
17
- console.log(` ${chalk.bold("Setup complete!")} ${chalk.dim(`(${opts.profile.displayName})`)} ${parts.join(" · ")}`);
38
+ const installed = results.filter((r) => r.status === "ok").length;
39
+ const failed = results.filter((r) => r.status === "failed").length;
40
+ const declined = results.filter((r) => r.status === "declined").length;
41
+ const total = results.length;
42
+ // Header
43
+ if (total === 1) {
44
+ const only = results[0];
45
+ if (only.status === "ok") {
46
+ console.log(` ${chalk.bold("Setup complete!")} ${chalk.dim(`(${only.profile.displayName})`)} ${renderCounts(only)}`);
47
+ }
48
+ else if (only.status === "declined") {
49
+ console.log(` ${chalk.bold("Setup skipped")} ${chalk.dim(`(${only.profile.displayName})`)} — you declined the conflict prompt`);
50
+ }
51
+ else {
52
+ console.log(` ${chalk.red.bold("Setup failed")} ${chalk.dim(`(${only.profile.displayName})`)} — ${only.error ?? "see output above"}`);
53
+ }
54
+ }
55
+ else {
56
+ const summaryParts = [`${installed} installed`];
57
+ if (failed > 0)
58
+ summaryParts.push(`${failed} failed`);
59
+ if (declined > 0)
60
+ summaryParts.push(`${declined} declined`);
61
+ const allOk = failed === 0 && declined === 0;
62
+ const headerLabel = allOk ? "Setup complete:" : "Setup finished:";
63
+ console.log(` ${chalk.bold(headerLabel)} ${summaryParts.join(", ")} of ${total} harnesses`);
64
+ console.log();
65
+ for (const r of results) {
66
+ printHarnessLine(r);
67
+ }
68
+ }
18
69
  console.log();
19
- if (opts.profile.name === "claude-code") {
70
+ // Agent list — only for single-harness claude-code success (the bulk of
71
+ // single-harness installs). Multi-harness summaries omit it: it's long
72
+ // and per-claude-code, and the multi-harness reader is more interested
73
+ // in the per-harness status block than the agent catalog.
74
+ if (total === 1 &&
75
+ results[0].status === "ok" &&
76
+ results[0].profile.name === "claude-code") {
20
77
  await printAgentList();
21
78
  }
22
- const masked = maskKey(opts.apiKey);
79
+ // API-key reminder — once per run regardless of harness count.
80
+ const masked = maskKey(apiKey);
23
81
  info("For SDK/CLI usage, add to your shell profile:");
24
82
  info(` ${chalk.cyan(`export ULUOPS_API_KEY="${masked}"`)}`);
25
83
  console.log();
26
84
  info(`Run again to update: ${chalk.cyan("npx @uluops/setup")}`);
27
85
  console.log();
28
- console.log(` ${chalk.dim("━".repeat(46))}`);
29
- console.log();
30
- console.log(` ${chalk.yellow.bold(`Restart ${opts.profile.displayName} to load agents.`)}`);
86
+ console.log(DIVIDER);
31
87
  console.log();
88
+ // Restart instruction — names each successfully-installed harness so
89
+ // the user knows what to restart. Suppressed entirely when nothing
90
+ // installed (all declined / all failed pre-MCP) — there's nothing to
91
+ // restart.
92
+ const restartTargets = results.filter((r) => r.status === "ok");
93
+ if (restartTargets.length > 0) {
94
+ const names = restartTargets.map((r) => r.profile.displayName).join(", ");
95
+ const verb = restartTargets.length === 1 ? "Restart" : "Restart each of";
96
+ console.log(` ${chalk.yellow.bold(`${verb} ${names} to load agents.`)}`);
97
+ console.log();
98
+ }
99
+ }
100
+ function printHarnessLine(r) {
101
+ const label = chalk.bold(`[${r.profile.displayName}]`);
102
+ switch (r.status) {
103
+ case "ok": {
104
+ const counts = renderCounts(r);
105
+ console.log(` ${chalk.green("✓")} ${label} installed ${counts}`);
106
+ return;
107
+ }
108
+ case "failed": {
109
+ if (r.partial) {
110
+ console.log(` ${chalk.yellow("⚠")} ${label} partial — failed at "${r.partial}"${r.error ? `: ${r.error}` : ""}`);
111
+ }
112
+ else {
113
+ console.log(` ${chalk.red("✗")} ${label} failed — ${r.error ?? "see output above"}`);
114
+ }
115
+ console.log(` ${chalk.dim(`Re-run: npx @uluops/setup --harness ${r.harnessName}`)}`);
116
+ return;
117
+ }
118
+ case "declined":
119
+ console.log(` ${chalk.dim("⊘")} ${label} skipped — user declined conflict prompt`);
120
+ return;
121
+ }
122
+ }
123
+ function renderCounts(r) {
124
+ const parts = [];
125
+ const agents = r.agentsResult?.files.length ?? 0;
126
+ const commands = r.commandsResult?.files.length ?? 0;
127
+ const skills = r.skillsResult?.files.length ?? 0;
128
+ if (agents > 0)
129
+ parts.push(`${agents} agents`);
130
+ if (commands > 0)
131
+ parts.push(`${commands} commands`);
132
+ if (skills > 0)
133
+ parts.push(`${skills} skills`);
134
+ if (r.metricsResult?.hookConfigured)
135
+ parts.push("metrics");
136
+ return parts.length > 0 ? `(${parts.join(" · ")})` : "";
32
137
  }
33
138
  export function maskKey(key) {
34
139
  if (!key || key.length <= 4)
@@ -11,6 +11,19 @@ export declare function writeIfChanged(destPath: string, content: string, dryRun
11
11
  * Remove files from a directory. Returns count of successfully removed files.
12
12
  */
13
13
  export declare function unlinkFiles(dir: string, files: string[]): Promise<number>;
14
+ /**
15
+ * Reconcile a manifest's old file list against the current source set,
16
+ * unlinking the files that were installed previously but are no longer in
17
+ * the source. Returns the count of files that would have been removed
18
+ * (whether or not the unlink actually ran in dry-run mode).
19
+ *
20
+ * 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.
25
+ */
26
+ export declare function removeStaleFiles(destDir: string, oldManifestFiles: string[] | undefined, currentFiles: string[], dryRun: boolean): Promise<number>;
14
27
  /**
15
28
  * Ensure a directory exists, then copy matching .md files using hash comparison.
16
29
  * Returns list of copied files, skipped count, and removed count (for old manifest entries).
@@ -27,8 +40,3 @@ export declare function syncAssets(opts: {
27
40
  removed: number;
28
41
  files: string[];
29
42
  }>;
30
- /**
31
- * Back up a file to the UluOps backup directory before modifying it.
32
- * No-op if the source file doesn't exist.
33
- */
34
- export declare function backupFile(srcPath: string, backupDir: string): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { readFile, writeFile, mkdir, unlink, access, copyFile, readdir } from "node:fs/promises";
2
- import { join, basename } from "node:path";
1
+ import { readFile, writeFile, mkdir, unlink, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import { fileHash } from "./hash.js";
4
4
  /**
5
5
  * Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
@@ -57,6 +57,37 @@ export async function unlinkFiles(dir, files) {
57
57
  }
58
58
  return removed;
59
59
  }
60
+ /**
61
+ * Reconcile a manifest's old file list against the current source set,
62
+ * unlinking the files that were installed previously but are no longer in
63
+ * the source. Returns the count of files that would have been removed
64
+ * (whether or not the unlink actually ran in dry-run mode).
65
+ *
66
+ * Extracted from three near-identical blocks in syncAssets, installAgents,
67
+ * and installCommands. Errors from unlink are swallowed silently — the
68
+ * "already gone" case is the dominant one (idempotent re-run, manual user
69
+ * deletion, prior failed install), and there's no recovery the caller
70
+ * can usefully perform mid-loop.
71
+ */
72
+ export async function removeStaleFiles(destDir, oldManifestFiles, currentFiles, dryRun) {
73
+ if (!oldManifestFiles)
74
+ return 0;
75
+ let removed = 0;
76
+ for (const oldFile of oldManifestFiles) {
77
+ if (!currentFiles.includes(oldFile)) {
78
+ if (!dryRun) {
79
+ try {
80
+ await unlink(join(destDir, oldFile));
81
+ }
82
+ catch {
83
+ // Already gone
84
+ }
85
+ }
86
+ removed++;
87
+ }
88
+ }
89
+ return removed;
90
+ }
60
91
  /**
61
92
  * Ensure a directory exists, then copy matching .md files using hash comparison.
62
93
  * Returns list of copied files, skipped count, and removed count (for old manifest entries).
@@ -83,40 +114,9 @@ export async function syncAssets(opts) {
83
114
  }
84
115
  }
85
116
  // Remove files that were in the old manifest but no longer in the package
86
- let removed = 0;
87
- if (opts.oldManifestFiles) {
88
- for (const oldFile of opts.oldManifestFiles) {
89
- if (!assetFiles.includes(oldFile)) {
90
- if (!opts.dryRun) {
91
- try {
92
- await unlink(join(opts.destDir, oldFile));
93
- }
94
- catch {
95
- // Already gone
96
- }
97
- }
98
- removed++;
99
- }
100
- }
101
- }
117
+ const removed = await removeStaleFiles(opts.destDir, opts.oldManifestFiles, assetFiles, opts.dryRun);
102
118
  if (errors.length > 0) {
103
119
  throw new Error(`Failed to copy ${errors.length} file(s):\n ${errors.join("\n ")}`);
104
120
  }
105
121
  return { copied, skipped, removed, files: assetFiles };
106
122
  }
107
- /**
108
- * Back up a file to the UluOps backup directory before modifying it.
109
- * No-op if the source file doesn't exist.
110
- */
111
- export async function backupFile(srcPath, backupDir) {
112
- try {
113
- await access(srcPath);
114
- }
115
- catch {
116
- return; // Nothing to back up
117
- }
118
- await mkdir(backupDir, { recursive: true });
119
- const filename = basename(srcPath);
120
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
121
- await copyFile(srcPath, join(backupDir, `${filename}.${timestamp}.bak`));
122
- }