@uluops/setup 0.7.0 → 0.9.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 (58) hide show
  1. package/README.md +94 -10
  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 +212 -22
  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 +8 -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,41 +1,230 @@
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
+ /**
16
+ * Tools to seed with `approval_mode = "approve"` (Codex's auto-allow opt-in).
17
+ *
18
+ * Seeded ONLY when the user has no prior `[mcp_servers.NAME.tools.*]` blocks
19
+ * for the server — a re-install over a hand-tuned config preserves the user's
20
+ * choices verbatim (the merge bails on its own seeds the moment it sees user
21
+ * intent).
22
+ *
23
+ * Only read-side tools are seeded. Writes still prompt — the user retains a
24
+ * choice point on every state-changing operation. Lists mirror the
25
+ * `sideEffects: 'read'` declarations in the respective server's tool-registry
26
+ * (sources of truth: ops-uluops-mcp/src/config/tool-registry.ts and
27
+ * uluops-registry-mcp/src/config/tool-registry.ts). If a new read tool ships
28
+ * there, add it here in the same PR — Codex users will silently get a prompt
29
+ * on first use otherwise.
30
+ */
31
+ const TRACKER_READ_TOOLS = [
32
+ "diff_runs",
33
+ "get_agent_lifecycle",
34
+ "get_agent_matrix",
35
+ "get_agent_reliability",
36
+ "get_agent_runs_analysis",
37
+ "get_analytics",
38
+ "get_burndown",
39
+ "get_discovery",
40
+ "get_full_taxonomy_analytics",
41
+ "get_issue_by_fingerprint",
42
+ "get_issue_details",
43
+ "get_issue_history",
44
+ "get_latest_run",
45
+ "get_project",
46
+ "get_project_analysis",
47
+ "get_project_summary",
48
+ "get_project_trends",
49
+ "get_run",
50
+ "get_run_analysis",
51
+ "get_run_details",
52
+ "get_taxonomy",
53
+ "get_velocity",
54
+ "list_agents",
55
+ "list_projects",
56
+ "list_runs",
57
+ "query_analysis_records",
58
+ "query_issues",
59
+ "search_issues",
60
+ "validate_run",
61
+ ];
62
+ const REGISTRY_READ_TOOLS = [
63
+ "batch_users",
64
+ "compare_effectiveness",
65
+ "diff_versions",
66
+ "get_definition",
67
+ "get_dependencies",
68
+ "get_dependents",
69
+ "get_diff_impact",
70
+ "get_ecosystem_overview",
71
+ "get_effectiveness",
72
+ "get_evolution",
73
+ "get_execution_stats",
74
+ "get_fork_lineage",
75
+ "get_health",
76
+ "get_language",
77
+ "get_lineage",
78
+ "get_model",
79
+ "get_translation_analytics",
80
+ "get_translator_version",
81
+ "get_user",
82
+ "is_forkable",
83
+ "list_aliases",
84
+ "list_definitions",
85
+ "list_forks",
86
+ "list_languages",
87
+ "list_models",
88
+ "list_providers",
89
+ "list_versions",
90
+ "render_definition",
91
+ "resolve_alias",
92
+ "search_definitions",
93
+ "set_default_type",
94
+ "validate_definition",
95
+ ];
96
+ function tomlString(value) {
97
+ return JSON.stringify(value);
98
+ }
99
+ function toolBlock(serverName, toolName) {
100
+ return [
101
+ `[mcp_servers.${serverName}.tools.${toolName}]`,
102
+ `approval_mode = "approve"`,
103
+ ].join("\n");
104
+ }
105
+ function serverBlock(name, pkg, apiKey, seedTools) {
106
+ const lines = [
107
+ `[mcp_servers.${name}]`,
108
+ `command = "npx"`,
109
+ `args = ["-y", ${tomlString(pkg)}]`,
110
+ ``,
111
+ `[mcp_servers.${name}.env]`,
112
+ `ULUOPS_API_KEY = ${tomlString(apiKey)}`,
113
+ ];
114
+ for (const tool of seedTools) {
115
+ lines.push(``, toolBlock(name, tool));
116
+ }
117
+ return lines.join("\n");
118
+ }
119
+ function isServerTableFor(name, table) {
120
+ return table === `mcp_servers.${name}` || table === `mcp_servers."${name}"`;
121
+ }
122
+ function isServerEnvTableFor(name, table) {
123
+ return (table === `mcp_servers.${name}.env` ||
124
+ table === `mcp_servers."${name}".env`);
125
+ }
126
+ function isServerSubtableFor(name, table) {
127
+ const unquotedPrefix = `mcp_servers.${name}.`;
128
+ const quotedPrefix = `mcp_servers."${name}".`;
129
+ return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
130
+ }
131
+ function isServerToolTableFor(name, table) {
132
+ const unquotedPrefix = `mcp_servers.${name}.tools.`;
133
+ const quotedPrefix = `mcp_servers."${name}".tools.`;
134
+ return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
135
+ }
136
+ /**
137
+ * Strip the main `[mcp_servers.NAME]` and `[mcp_servers.NAME.env]` blocks
138
+ * but preserve `[mcp_servers.NAME.tools.*]` blocks. Tool overrides may be
139
+ * user customizations — destroying them on every re-install would punish
140
+ * anyone who hand-edited their Codex config to deny a specific tool or to
141
+ * approve one we don't seed.
142
+ */
143
+ function removeServerConfigBlocks(raw, name) {
144
+ const lines = raw.split("\n");
145
+ const kept = [];
146
+ let skipping = false;
147
+ for (const line of lines) {
148
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
149
+ if (table) {
150
+ skipping = isServerTableFor(name, table) || isServerEnvTableFor(name, table);
151
+ }
152
+ if (!skipping)
153
+ kept.push(line);
154
+ }
155
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
156
+ }
157
+ function removeServerSubtree(raw, name) {
158
+ const lines = raw.split("\n");
159
+ const kept = [];
160
+ let skipping = false;
161
+ for (const line of lines) {
162
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
163
+ if (table) {
164
+ skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
165
+ }
166
+ if (!skipping)
167
+ kept.push(line);
168
+ }
169
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
170
+ }
171
+ function hasUserToolEntries(raw, name) {
172
+ for (const line of raw.split("\n")) {
173
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
174
+ if (table && isServerToolTableFor(name, table))
175
+ return true;
176
+ }
177
+ return false;
178
+ }
17
179
  class CodexMcpConfig {
18
- async read() {
19
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
180
+ async read(path) {
181
+ try {
182
+ return { [RAW_TOML]: await readFile(path, "utf-8") };
183
+ }
184
+ catch {
185
+ return { [RAW_TOML]: "" };
186
+ }
20
187
  }
21
- merge() {
22
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
188
+ merge(config, apiKey) {
189
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
190
+ // Detect prior user customization BEFORE we strip anything. A hand-tuned
191
+ // config (any `[mcp_servers.NAME.tools.*]` block present) signals user
192
+ // intent — we replay only main + env, leaving the user's per-tool
193
+ // approval choices untouched. A fresh config gets the seeded read-tool
194
+ // approvals so first-launch UX skips the prompt cascade.
195
+ const trackerHasUserTools = hasUserToolEntries(raw, "uluops-tracker");
196
+ const registryHasUserTools = hasUserToolEntries(raw, "uluops-registry");
197
+ raw = removeServerConfigBlocks(removeServerConfigBlocks(raw, "uluops-tracker"), "uluops-registry");
198
+ const blocks = [
199
+ serverBlock("uluops-tracker", "@uluops/ops-mcp", apiKey, trackerHasUserTools ? [] : TRACKER_READ_TOOLS),
200
+ serverBlock("uluops-registry", "@uluops/registry-mcp", apiKey, registryHasUserTools ? [] : REGISTRY_READ_TOOLS),
201
+ ].join("\n\n");
202
+ return { [RAW_TOML]: [raw.trimEnd(), blocks].filter(Boolean).join("\n\n") + "\n" };
23
203
  }
24
- remove() {
25
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
204
+ remove(config) {
205
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
206
+ for (const name of ULUOPS_SERVERS) {
207
+ raw = removeServerSubtree(raw, name);
208
+ }
209
+ return { [RAW_TOML]: raw.trimEnd() ? `${raw.trimEnd()}\n` : "" };
26
210
  }
27
- async write() {
28
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
211
+ async write(path, config) {
212
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
213
+ await atomicWrite(path, raw, { mode: 0o600 });
29
214
  }
30
- check() {
31
- throw new HarnessNotTestedError("Codex (MCP config check not supported)");
215
+ check(config) {
216
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
217
+ return ULUOPS_SERVERS.every((name) => {
218
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
219
+ return new RegExp(String.raw `\[mcp_servers\.(?:"${escaped}"|${escaped})\]`).test(raw);
220
+ });
32
221
  }
33
222
  }
34
223
  const home = join(homedir(), ".codex");
35
224
  export const codexProfile = {
36
225
  name: "codex",
37
226
  displayName: "Codex",
38
- status: "experimental",
227
+ status: "stable",
39
228
  homeDir: home,
40
229
  agentFormat: "toml",
41
230
  factoryTarget: "codex",
@@ -45,7 +234,8 @@ export const codexProfile = {
45
234
  globalMcpConfig: join(home, "config.toml"),
46
235
  localMcpConfig: ".codex/config.toml",
47
236
  agentsDir: join(home, "agents"),
48
- commandsDir: join(homedir(), ".agents", "skills"),
237
+ commandsDir: join(home, "commands"),
238
+ skillsDir: join(home, "skills"),
49
239
  settingsPath: null,
50
240
  toolsDir: null,
51
241
  },
@@ -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,14 @@ 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, codex. When a new stable
26
+ // profile lands, add it here so the error stays actionable. (As of
27
+ // 0.9.0 every shipped profile is stable, so this constructor is
28
+ // currently unreachable — kept as a structural slot for the next
29
+ // experimental harness scaffold.)
30
+ `${harnessName} harness is not yet tested. Use --harness claude-code, --harness opencode, --harness gemini-cli, or --harness codex.`);
24
31
  this.name = "HarnessNotTestedError";
25
32
  }
26
33
  }
@@ -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)