@uluops/setup 0.11.0 → 0.13.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 +805 -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 +35 -8
  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 +7 -4
  55. package/dist/lib/agent-transform.d.ts +0 -12
  56. package/dist/lib/agent-transform.js +0 -129
package/README.md CHANGED
@@ -6,12 +6,16 @@
6
6
 
7
7
  Zero-friction installer for [UluOps](https://uluops.ai) agentic harnesses. One command sets up MCP servers, agents, and slash commands for Claude Code, OpenCode, and more.
8
8
 
9
- ```
9
+ ```bash
10
10
  npx @uluops/setup
11
11
  ```
12
12
 
13
+ > Requires **Node.js >= 20** ([full requirements](#requirements)).
14
+
13
15
  > **⚠️ Windows Users:** Native Windows is not yet supported. Please use **WSL2 (Ubuntu)** and run the setup inside your WSL environment.
14
16
 
17
+ **Contents:** [Supported harnesses](#supported-harnesses) · [What it does](#what-it-does) · [Usage](#usage) · [Options](#options) · [Advanced commands](#advanced-commands) · [Examples](#examples) · [How updates work](#how-updates-work) · [Troubleshooting](#troubleshooting) · [Uninstall](#uninstall) · [Requirements](#requirements)
18
+
15
19
  ## Supported harnesses
16
20
 
17
21
  | Harness | Status | Alias | Config |
@@ -43,7 +47,14 @@ npx @uluops/setup --harness claude-code,gemini-cli
43
47
  If you don't pass `--harness` or `--all-detected`, setup probes your home directory for known harness install markers and picks a target:
44
48
 
45
49
  - **One harness detected** — that harness is used as the target. A dimmed `Detected <Name>` line confirms the choice (suppressed when the detected harness is the default `claude-code`).
46
- - **Multiple harnesses detected (interactive)** — you get a multi-select checkbox listing every detected harness, with **every option checked by default** so the "install everywhere" case is a single Enter press. Use space to toggle entries off.
50
+ - **Multiple harnesses detected (interactive)** — you get a multi-select checkbox listing every detected harness, with **every option checked by default** so the "install everywhere" case is a single Enter press. Use space to toggle entries off:
51
+
52
+ ```text
53
+ ? Multiple harnesses detected. Which would you like to install into?
54
+ ❯ ◉ Claude Code
55
+ ◉ OpenCode
56
+ ◉ Gemini CLI
57
+ ```
47
58
  - **Multiple harnesses detected (non-interactive — `--yes`, `--api-key`, piped stdin)** — to keep CI scripts predictable, this preserves earlier behavior: the first detected harness installs and a dimmed notice lists the others. CI users who want multi-install opt in explicitly with `--all-detected`.
48
59
  - **No harnesses detected** — falls back to the default (`claude-code`) so `npx @uluops/setup` always does something useful on a fresh machine.
49
60
 
@@ -69,7 +80,7 @@ npx @uluops/setup --harness claude,oc
69
80
 
70
81
  Each harness gets its own per-section block in the summary:
71
82
 
72
- ```
83
+ ```text
73
84
  Setup complete: 3 installed of 3 harnesses
74
85
 
75
86
  ✓ [Claude Code] installed (23 agents · 28 commands · metrics)
@@ -99,17 +110,18 @@ Each harness gets its own per-section block in the summary:
99
110
  The installer runs these steps in sequence:
100
111
 
101
112
  1. **Authenticate** — Asks whether you're creating a new account. New users sign up with email + password; returning users paste an API key. Skip the question with `--api-key`, `--signup`, `--yes`, or `ULUOPS_API_KEY`.
102
- 2. **MCP config** — Writes tracker and registry server entries to the harness config
103
- 3. **Definitions** — Copies pre-rendered agent definition files
104
- 4. **Metrics hook** — Configures a post-agent hook for automatic run capture (Claude Code and Gemini CLI)
105
- 5. **`ulu` CLI** *(optional)* — Offers to install `@uluops/cli` globally. Interactive runs are prompted (default Y); non-interactive runs skip unless `--with-cli` is passed. `--no-cli` always skips. The install is best-effort: if `npm install -g` fails (permissions, nvm prefix, etc.) the rest of setup still completes and a manual install command is printed.
106
- 6. **Health check** — Verifies both API endpoints are reachable
113
+ 2. **Registry username** *(optional)* — Offers to set your registry username, the one-time prerequisite for creating or publishing definitions. Never forced: consumers who only run definitions don't need one, and non-interactive runs skip it silently unless `--username <name>` is supplied.
114
+ 3. **MCP config** — Writes tracker and registry server entries to the harness config
115
+ 4. **Definitions** — Copies pre-rendered agent definition files
116
+ 5. **Metrics hook** — Configures a post-agent hook for automatic run capture (Claude Code and Gemini CLI)
117
+ 6. **`ulu` CLI** *(optional)* — Offers to install `@uluops/cli` globally. Interactive runs are prompted (default Y); non-interactive runs skip unless `--with-cli` is passed. `--no-cli` always skips. The install is best-effort: if `npm install -g` fails (permissions, nvm prefix, etc.) the rest of setup still completes and a manual install command is printed.
118
+ 7. **Health check** — Verifies both API endpoints are reachable
107
119
 
108
120
  > When this setup installs the CLI, the install is recorded in the manifest so `--uninstall` removes it symmetrically. If the CLI was already on your PATH before running setup, it is left alone on uninstall.
109
121
 
110
122
  ## Usage
111
123
 
112
- ```
124
+ ```bash
113
125
  npx @uluops/setup
114
126
  ```
115
127
 
@@ -121,7 +133,7 @@ Setup will first ask whether you're creating a new UluOps account. Pick **Y** to
121
133
 
122
134
  ### Options
123
135
 
124
- ```
136
+ ```text
125
137
  npx @uluops/setup [options]
126
138
 
127
139
  --api-key <key> API key (skip prompt)
@@ -136,6 +148,11 @@ npx @uluops/setup [options]
136
148
  synonym for --harness all. Cannot be combined with
137
149
  --harness <single-name> (fail-fast conflict error).
138
150
  --signup Create account from terminal (email + password)
151
+ --username <name> Set your registry username without prompting (lowercase
152
+ slug, e.g. ulu-labs). One-time prerequisite for
153
+ creating/publishing definitions — optional if you only
154
+ run them. Non-interactive runs skip the username step
155
+ entirely unless this flag is passed.
139
156
  --scope <mode> MCP config scope: "global" or "local" (default: global)
140
157
  --local-defs Install definitions into ./uluops/ (project-scoped)
141
158
  instead of the harness's home directory
@@ -167,36 +184,43 @@ npx @uluops/setup [options]
167
184
  Displays all agents and workflows included in the current version of the setup tool.
168
185
 
169
186
  ```text
170
- ⟨u⟩ ulu·ops v0.9.5 — available agents and workflows
187
+ ⟨u⟩ ulu·ops v0.12.0 — available agents and workflows
171
188
 
172
189
  WORKFLOWS
173
- /workflows:post-implementation Iterative validation after coding
174
- /workflows:pre-implementation Design validation before implementation
175
- /workflows:prompt-audit Strategic prompt quality audit
176
-
177
- AGENTS (run individually) MODEL
178
- /agents:code-validator Validate cod... sonnet
179
- /agents:type-safety Deep TypeScr... sonnet
180
- /agents:security-analyst Comprehensiv... sonnet
181
- /agents:test-architect Validate tes... sonnet
182
- ...
190
+ /workflows:post-implementation Iterative validation workflow. Run af...
191
+ /workflows:pre-implementation Validates proposed design and archite...
192
+ /workflows:prompt-audit Comprehensive prompt audit with ecosy...
193
+
194
+ AGENTS (run individually) MODEL
195
+ /agents:anxiety-reader Reads from the pos... sonnet
196
+ /agents:architect Run Pre-Implementa... sonnet
197
+ /agents:assumption-excavator Surfaces implicit ... sonnet
198
+ /agents:audit Deep runtime corre... sonnet
199
+ /agents:docs-validate Validates comprehe... sonnet
200
+ /agents:security Comprehensive secu... sonnet
201
+ ...
183
202
  ```
184
203
 
185
204
  #### Check installation health (`--verify`)
186
205
  Validates your current installation against the local manifest and checks API connectivity.
187
206
 
188
207
  ```text
189
- ⟨u⟩ ulu·ops Installation Check v0.9.5
208
+ ⟨u⟩ ulu·ops Installation Check v0.12.0
190
209
 
191
- ✓ Manifest found (~/.uluops/manifest.json)
192
- ✓ All 23 agents present in ~/.claude/agents/
193
- ✓ MCP servers configured in ~/.claude.json
194
- ✓ API connectivity: Tracker (Online)
195
- ✓ API connectivity: Registry (Online)
210
+ ✓ Manifest found (v0.12.0, installed 2026-08-21)
211
+ ✓ [Claude Code] Readiness
212
+ ✓ [Claude Code] MCP config present in ~/.claude.json (2 servers)
213
+ ✓ [Claude Code] 23/23 agents in ~/.claude/agents
214
+ ✓ [Claude Code] 28/28 commands
215
+ ✓ [Claude Code] Agent metrics hook configured
216
+ ✓ API key valid
217
+ ✓ MCP packages resolvable on npm
196
218
 
197
219
  All checks passed.
198
220
  ```
199
221
 
222
+ > With multiple harnesses installed, `--verify` prints one `[<Harness>]` block per manifest entry.
223
+
200
224
  ### Examples
201
225
 
202
226
  ```bash
@@ -207,6 +231,9 @@ npx @uluops/setup
207
231
  # Skip the account question and go straight to signup
208
232
  npx @uluops/setup --signup
209
233
 
234
+ # Set your registry username during setup (required to publish definitions)
235
+ npx @uluops/setup --username ulu-labs
236
+
210
237
  # Install for OpenCode
211
238
  npx @uluops/setup --harness opencode
212
239
 
@@ -246,12 +273,17 @@ npx @uluops/setup --no-agent-metrics-cli
246
273
 
247
274
  ## How updates work
248
275
 
249
- Re-running `npx @uluops/setup` is safe and idempotent:
276
+ Re-running `npx @uluops/setup` is designed to be safe to repeat:
250
277
 
251
278
  - Unchanged files are skipped (content hash comparison)
252
279
  - Updated files are overwritten
253
- - Removed definitions are cleaned up
254
- - Your custom agents and non-UluOps MCP servers are never touched
280
+ - Definitions no longer shipped are cleaned up
281
+ - Custom agents and non-UluOps MCP servers are left alone, with two known
282
+ edges: a hook whose command *contains* the UluOps ownership marker (e.g. a
283
+ hand-forked copy of our agent-metrics hook) is treated as ours and replaced
284
+ on re-run, and switching `--local-defs` between runs starts a fresh tree —
285
+ the previous scope's files stay on disk untracked (setup warns when this
286
+ happens)
255
287
 
256
288
  Setup manages four surfaces: agent files, command files, MCP config entries, and the metrics hook. A manifest at `~/.uluops/manifest.json` tracks what was installed so `--uninstall` can cleanly reverse all changes. The manifest supports multiple harnesses — each gets its own installation state.
257
289
 
@@ -259,13 +291,13 @@ Setup manages four surfaces: agent files, command files, MCP config entries, and
259
291
 
260
292
  - **Agents not appearing:** Ensure you have restarted your harness (Claude Code, etc.) after running setup. For Claude Code, simply exit and restart the CLI.
261
293
  - **MCP errors:** If the harness fails to start the MCP servers, ensure `npx` is available in your PATH. You can check your config at `~/.claude.json` or `~/.config/opencode/opencode.json`.
262
- - **API key rejected:** Verify your key at [app.uluops.ai](https://app.uluops.ai). If you are behind a corporate proxy, you may need to set `HTTPS_PROXY`.
294
+ - **API key rejected:** Verify your key at [app.uluops.ai](https://app.uluops.ai). Behind a corporate proxy, note that setup's own API calls do **not** honor `HTTPS_PROXY` (Node's fetch ignores proxy env vars) — use `--skip-validation` to complete setup offline and verify the key later from a network that can reach `api.uluops.ai`.
263
295
  - **`@uluops/cli` install warning:** If setup warns it could not install the CLI globally (EACCES, nvm prefix mismatch, network), the rest of setup still completes. Run `npm install -g @uluops/cli` yourself when convenient — once it's on your PATH, every subsequent `npx @uluops/setup` will see it and skip the install step.
264
296
  - **Windows issues:** Remember that native Windows is not supported; you must run the installer and your harness within **WSL2**.
265
297
 
266
298
  ## Uninstall
267
299
 
268
- ```
300
+ ```bash
269
301
  # Uninstall everything (every harness in the manifest + globals + shell export)
270
302
  npx @uluops/setup --uninstall
271
303
 
@@ -283,6 +315,23 @@ Removes only UluOps-managed files: agents, commands, MCP config entries, shell p
283
315
 
284
316
  **Subset uninstall** (`--uninstall --harness <name>`) removes only the named harness(es) from the manifest and disk. Shared infrastructure (the global `@uluops/cli`, `@uluops/agent-metrics`, and the shell-profile export) is left in place because remaining harnesses still need it. The manifest is updated rather than deleted. A subset uninstall that names a harness not in the manifest fails fast with an error listing what IS in the manifest — no silent no-op.
285
317
 
318
+ ## Data & privacy
319
+
320
+ The metrics hook (step 5) captures **agent execution metadata only** — token
321
+ counts, durations, model and agent names — into a **local buffer** on your
322
+ machine. The hook itself sends nothing anywhere: data reaches the UluOps
323
+ tracker only when a run is explicitly saved (by you, or by tooling you run).
324
+ Artifact content being validated is never stored — only validation results.
325
+
326
+ Validation run and issue data saved to the tracker is **retained
327
+ indefinitely by design** (the immutable forensics model); account and data
328
+ deletion is available on request. Full details:
329
+ [uluops.ai/privacy](https://uluops.ai/privacy).
330
+
331
+ Opt-outs: `--no-metrics` skips the hook install entirely; `--uninstall`
332
+ removes it later. If you're installing inside an organization, check your
333
+ org's telemetry policy before enabling the hook on shared projects.
334
+
286
335
  ## Requirements
287
336
 
288
337
  - **Node.js:** >= 20.0.0
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import { info, printAgentList } from "./lib/display.js";
5
5
  import { getVersion } from "./lib/version.js";
6
- import { listHarnesses, detectHarnesses, getProfile, HarnessNotTestedError, } from "./harnesses/index.js";
6
+ import { listHarnesses, detectHarnesses, detectExcludedExperimental, getProfile, HarnessNotTestedError, } from "./harnesses/index.js";
7
7
  import { InstallLockHeldError } from "./lib/install-lock.js";
8
8
  import { runSetup } from "./commands/setup.js";
9
9
  import { runUninstall } from "./commands/uninstall.js";
@@ -73,6 +73,12 @@ async function main() {
73
73
  // matrix of (--harness, --all-detected, detection count, TTY) lives in
74
74
  // one tested place. cli.ts only wires the prompt and emit-info callbacks.
75
75
  const detected = detectHarnesses();
76
+ // Name the exclusion: auto-detection only returns stable profiles, and a
77
+ // user who can see an experimental harness installed reads silence as a
78
+ // detection bug rather than a policy.
79
+ for (const p of detectExcludedExperimental()) {
80
+ console.log(chalk.dim(` Detected ${p.displayName} (experimental) — excluded from auto-detection; opt in with --harness ${p.name}`));
81
+ }
76
82
  const isInteractive = !opts.yes &&
77
83
  !opts.apiKey &&
78
84
  !process.env["ULUOPS_API_KEY"] &&
@@ -14,6 +14,7 @@ import { installAgentMetricsCli, AGENT_METRICS_PACKAGE, AGENT_METRICS_BIN, } fro
14
14
  import { writeShellExport } from "../steps/shell.js";
15
15
  import { probeHookSupport } from "../lib/settings-merger.js";
16
16
  import { findProjectRoot, ASSETS_DIR } from "../lib/paths.js";
17
+ import { isEnoent } from "../lib/file-ops.js";
17
18
  import { getHealthTimeout } from "../lib/health.js";
18
19
  import { ok, warn, fail, info } from "../lib/display.js";
19
20
  import { ConflictRejectedError } from "./errors.js";
@@ -74,7 +75,14 @@ export async function initContext(opts) {
74
75
  ok(`API key generated`);
75
76
  }
76
77
  else {
77
- const interactive = !opts.yes && !opts.apiKey && !process.env["ULUOPS_API_KEY"];
78
+ // Non-TTY must fall through to resolveApiKey's no-key error (which
79
+ // names --api-key / ULUOPS_API_KEY) — prompting against a closed stdin
80
+ // dies on inquirer's raw cancellation instead. Mirrors the isTTY guard
81
+ // in shouldPromptForAccount.
82
+ const interactive = !opts.yes &&
83
+ !opts.apiKey &&
84
+ !process.env["ULUOPS_API_KEY"] &&
85
+ Boolean(process.stdin.isTTY);
78
86
  const auth = await resolveApiKey({
79
87
  apiKeyFlag: opts.apiKey,
80
88
  skipValidation: opts.skipValidation,
@@ -216,7 +224,14 @@ export async function configureMetricsStep(profile, opts) {
216
224
  }
217
225
  if (!profile.hooks) {
218
226
  info(chalk.dim(`Metrics hooks not supported for ${profile.displayName}`));
219
- return { toolFilesCopied: 0, hookConfigured: false, hooksInstalledVersion: null };
227
+ // skippedReason for shape parity with installMetrics' own hookless
228
+ // branch — metricsObserved must read this run as non-observing.
229
+ return {
230
+ toolFilesCopied: 0,
231
+ hookConfigured: false,
232
+ hooksInstalledVersion: null,
233
+ skippedReason: "no-hook-support",
234
+ };
220
235
  }
221
236
  const probe = probeHookSupport();
222
237
  if (probe.warning)
@@ -229,6 +244,15 @@ export async function configureMetricsStep(profile, opts) {
229
244
  parts.push("hook configured");
230
245
  const toolPath = profile.paths.toolsDir?.replace(process.env["HOME"] ?? "", "~");
231
246
  ok(`Agent metrics → ${toolPath}/ (${parts.join(", ")})`);
247
+ // Disclosure, not decoration: the hook captures execution metadata to a
248
+ // LOCAL buffer and sends nothing itself — say so where it's installed.
249
+ info(chalk.dim(" Captures agent token/duration metadata to a local buffer (nothing is sent).\n" +
250
+ " Skip with --no-metrics · uluops.ai/privacy"));
251
+ }
252
+ else if (res.skippedReason === "hook-state-unknown") {
253
+ // installMetrics already warned with the settings path and the
254
+ // keeping-prior-record note — do not follow it with a message that
255
+ // misnames the cause (tool files may well have copied).
232
256
  }
233
257
  else {
234
258
  warn("Agent metrics hook not configured (tool files not found)");
@@ -369,10 +393,18 @@ export async function runHealthCheck(opts) {
369
393
  checkEndpoint("https://api.uluops.ai/api/v1/health"),
370
394
  checkEndpoint("https://api.uluops.ai/api/v1/registry/health"),
371
395
  ]);
372
- if (trackerOk && registryOk)
396
+ if (trackerOk && registryOk) {
373
397
  ok("Health check passed — both APIs reachable");
374
- else
375
- warn("Some APIs unreachable (MCP tools may have limited functionality)");
398
+ }
399
+ else {
400
+ // Name the failing endpoint — "some APIs" gives the user nothing to
401
+ // report or retry against.
402
+ const down = [
403
+ !trackerOk && "Tracker",
404
+ !registryOk && "Registry",
405
+ ].filter(Boolean);
406
+ warn(`${down.join(" and ")} API unreachable (MCP tools may have limited functionality)`);
407
+ }
376
408
  }
377
409
  catch {
378
410
  warn("Health check skipped (network issue)");
@@ -418,12 +450,43 @@ export async function checkConflicts(profile, localDefs) {
418
450
  : profile.paths.agentsDir;
419
451
  const srcDir = join(ASSETS_DIR, profile.name, "agents");
420
452
  let existingFiles;
421
- let assetFiles;
422
453
  try {
423
454
  existingFiles = await readdir(destDir);
455
+ }
456
+ catch (err) {
457
+ if (isEnoent(err)) {
458
+ return; // No destination dir yet — fresh install, nothing to conflict.
459
+ }
460
+ // Unreadable destination = conflicts UNKNOWN, never "no conflicts":
461
+ // proceeding silently overwrites files we could not enumerate. Ask.
462
+ warn(`Could not read ${destDir} (${err instanceof Error ? err.message : String(err)}) — cannot check for existing agents that would be overwritten.`);
463
+ if (!process.stdin.isTTY) {
464
+ // Non-TTY can't answer the prompt; fail-safe is refusal, not a hang
465
+ // and not a silent overwrite. This is an OPERATIONAL failure (EACCES
466
+ // class), not a user policy choice — it must exit 1 for CI, so a
467
+ // plain Error (failed path), not ConflictRejectedError (exit 0).
468
+ // (--yes skips checkConflicts entirely.)
469
+ throw new Error(`Cannot verify conflicts in ${destDir} and no TTY to ask — refusing to risk overwriting existing files. Fix the directory permissions or pass --yes to proceed without the check.`);
470
+ }
471
+ const { confirm } = await import("@inquirer/prompts");
472
+ const proceed = await confirm({
473
+ message: "Continue anyway (existing files may be overwritten)?",
474
+ default: false,
475
+ });
476
+ if (!proceed) {
477
+ throw new ConflictRejectedError(profile.name);
478
+ }
479
+ return;
480
+ }
481
+ let assetFiles;
482
+ try {
424
483
  assetFiles = await readdir(srcDir);
425
484
  }
426
- catch {
485
+ catch (err) {
486
+ // The BUNDLED assets being unreadable is not a fresh-install condition —
487
+ // it means the package itself is broken. Don't silently skip the
488
+ // conflict check; say so (the copy step will surface the hard failure).
489
+ warn(`Could not read bundled agent assets (${err instanceof Error ? err.message : String(err)}) — conflict check skipped`);
427
490
  return;
428
491
  }
429
492
  const conflicts = assetFiles.filter((f) => existingFiles.includes(f));
@@ -50,6 +50,11 @@ export interface PerHarnessResult {
50
50
  * | Any declined AND zero failed | 0 |
51
51
  * | Empty (user unchecked all, or no harnesses to run) | 0 |
52
52
  *
53
+ * The implementation is deliberately a single `anyFailed` check, not four
54
+ * branches: rows 1, 3, and 4 all share exit 0, so the table collapses to
55
+ * "any operational failure → 1, everything else → 0". The table is the
56
+ * spec; the code is its minimal form.
57
+ *
53
58
  * Rationale: CI wrapping `--harness all` should not be poisoned by
54
59
  * user-policy choices (declines, no-op outcomes) but MUST fail on
55
60
  * operational errors (EACCES, ENOSPC, parse-error) so deploy pipelines
@@ -18,6 +18,11 @@
18
18
  * | Any declined AND zero failed | 0 |
19
19
  * | Empty (user unchecked all, or no harnesses to run) | 0 |
20
20
  *
21
+ * The implementation is deliberately a single `anyFailed` check, not four
22
+ * branches: rows 1, 3, and 4 all share exit 0, so the table collapses to
23
+ * "any operational failure → 1, everything else → 0". The table is the
24
+ * spec; the code is its minimal form.
25
+ *
21
26
  * Rationale: CI wrapping `--harness all` should not be poisoned by
22
27
  * user-policy choices (declines, no-op outcomes) but MUST fail on
23
28
  * operational errors (EACCES, ENOSPC, parse-error) so deploy pipelines
@@ -22,4 +22,11 @@ interface RunSetupOpts {
22
22
  /** Explicit registry username (slug). Set + confirmed non-interactively when provided. */
23
23
  username?: string;
24
24
  }
25
+ /**
26
+ * The main install flow: resolves every target harness up front (fail-fast on
27
+ * typos), runs the once-per-run steps (auth, username, CLI prompts) a single
28
+ * time, then installs MCP config, definitions, and the metrics hook per
29
+ * harness with failure isolation — one harness failing does not abort the
30
+ * others. Exits 1 if any harness failed operationally.
31
+ */
25
32
  export declare function runSetup(opts: RunSetupOpts): Promise<void>;
@@ -2,7 +2,7 @@ import chalk from "chalk";
2
2
  import { join } from "node:path";
3
3
  import { loadManifest, saveManifest, } from "../lib/manifest.js";
4
4
  import { findProjectRoot } from "../lib/paths.js";
5
- import { info, printSetupSummary, warn } from "../lib/display.js";
5
+ import { info, warn, blank, printSetupBanner, printHarnessHeader, printSetupSummary, } from "../lib/display.js";
6
6
  import { getVersion } from "../lib/version.js";
7
7
  import { getProfile } from "../harnesses/index.js";
8
8
  import { acquireInstallLock, } from "../lib/install-lock.js";
@@ -10,6 +10,13 @@ import { initContext, checkConflicts, configureMcpStep, installAgentsDefs, insta
10
10
  import { ConflictRejectedError } from "./errors.js";
11
11
  import { classifyExit, } from "./per-harness.js";
12
12
  import { maybeSetUsername } from "../steps/username.js";
13
+ /**
14
+ * The main install flow: resolves every target harness up front (fail-fast on
15
+ * typos), runs the once-per-run steps (auth, username, CLI prompts) a single
16
+ * time, then installs MCP config, definitions, and the metrics hook per
17
+ * harness with failure isolation — one harness failing does not abort the
18
+ * others. Exits 1 if any harness failed operationally.
19
+ */
13
20
  export async function runSetup(opts) {
14
21
  if (opts.harnesses.length === 0) {
15
22
  info(chalk.dim("Nothing to install — re-run with at least one harness selected.\n"));
@@ -20,21 +27,16 @@ export async function runSetup(opts) {
20
27
  // is touched. getProfile throws HarnessNotTestedError or a friendly
21
28
  // unknown-name error; the top-level catch in cli.ts surfaces them.
22
29
  const profiles = opts.harnesses.map((name) => getProfile(name));
23
- console.log();
24
- console.log(` ${chalk.dim("⟨u⟩")} ${chalk.cyan.bold("ulu")}${chalk.bold("·ops")}`);
25
- console.log(` ${chalk.dim("operating intelligence as infrastructure")}`);
26
- console.log();
27
30
  const targetSummary = profiles.length === 1
28
31
  ? profiles[0].displayName
29
32
  : `${profiles.length} harnesses (${profiles.map((p) => p.displayName).join(", ")})`;
30
- console.log(` Setup v${version} — ${chalk.bold(targetSummary)}`);
31
- console.log();
33
+ printSetupBanner(version, targetSummary);
32
34
  if (opts.dryRun) {
33
35
  info(chalk.dim("(dry run — no changes will be made)\n"));
34
36
  }
35
37
  // === Once-per-run: BEFORE the per-harness loop ===
36
38
  const { env, apiKey } = await initContext(opts);
37
- console.log();
39
+ blank();
38
40
  // Optional, never-forced: offer to set a registry username (the one-time
39
41
  // prerequisite for creating/publishing definitions). Skipped silently in
40
42
  // non-interactive runs unless --username is supplied.
@@ -45,7 +47,7 @@ export async function runSetup(opts) {
45
47
  dryRun: opts.dryRun,
46
48
  emit: (msg) => info(msg),
47
49
  });
48
- console.log();
50
+ blank();
49
51
  // Acquire the install lock before touching any shared state. Skipped on
50
52
  // dry-run (read-only). The lock excludes a second concurrent uluops-setup
51
53
  // from racing the manifest / MCP config / shell-profile / settings.json
@@ -53,6 +55,7 @@ export async function runSetup(opts) {
53
55
  // concurrent multi-harness installs from separate processes serialize
54
56
  // (spec §10.6).
55
57
  let lock = null;
58
+ let exitCode = 0;
56
59
  if (!opts.dryRun) {
57
60
  lock = await acquireInstallLock();
58
61
  }
@@ -60,7 +63,7 @@ export async function runSetup(opts) {
60
63
  const existingManifest = await loadManifest();
61
64
  if (existingManifest && existingManifest.version !== version) {
62
65
  info(`Updating ${chalk.dim(existingManifest.version)} → ${chalk.green(version)}`);
63
- console.log();
66
+ blank();
64
67
  }
65
68
  // === Per-harness loop ===
66
69
  const perHarnessResults = [];
@@ -72,7 +75,7 @@ export async function runSetup(opts) {
72
75
  // detection; using the wrong harness's prev list silently orphans
73
76
  // files (spec §7.6.1 per-iteration state isolation).
74
77
  const existingHarness = existingManifest?.harnesses[harnessName];
75
- console.log(chalk.dim(`▸ ${profile.displayName}`));
78
+ printHarnessHeader(profile.displayName);
76
79
  if (existingHarness && !existingHarness.partial) {
77
80
  info(chalk.dim(` Already installed at v${version} — checking for changes`));
78
81
  }
@@ -95,10 +98,23 @@ export async function runSetup(opts) {
95
98
  error: err.message,
96
99
  });
97
100
  warn(`[${harnessName}] skipped (user declined conflict) — continuing with remaining harnesses`);
98
- console.log();
101
+ blank();
99
102
  continue;
100
103
  }
101
- throw err;
104
+ // Operational failure (e.g. unreadable dest dir, non-TTY refusal):
105
+ // classify-and-continue like the MCP branch below — rethrowing
106
+ // escaped the per-harness loop, leaving installed siblings with NO
107
+ // manifest record and skipping later harnesses (audit pass 6,
108
+ // PROBE D). classifyExit yields 1 for a failed result.
109
+ perHarnessResults.push({
110
+ harnessName,
111
+ profile,
112
+ status: "failed",
113
+ error: err instanceof Error ? err.message : String(err),
114
+ });
115
+ warn(`[${harnessName}] conflict check failed — continuing with remaining harnesses`);
116
+ blank();
117
+ continue;
102
118
  }
103
119
  }
104
120
  // MCP must succeed for a manifest entry to exist (the entry depends
@@ -116,7 +132,7 @@ export async function runSetup(opts) {
116
132
  error: err instanceof Error ? err.message : String(err),
117
133
  });
118
134
  warn(`[${harnessName}] MCP configuration failed — continuing with remaining harnesses`);
119
- console.log();
135
+ blank();
120
136
  continue;
121
137
  }
122
138
  // Subsequent steps may throw on pre-loop work (mkdir EACCES, etc.).
@@ -158,7 +174,7 @@ export async function runSetup(opts) {
158
174
  metricsResult,
159
175
  partial: failedStep,
160
176
  });
161
- console.log();
177
+ blank();
162
178
  }
163
179
  // === Once-per-run: AFTER the per-harness loop ===
164
180
  // Global @uluops/cli install — single prompt, single install across the
@@ -192,32 +208,70 @@ export async function runSetup(opts) {
192
208
  // harness entry. Declined harnesses and pre-MCP failures land no entry.
193
209
  if (!opts.dryRun) {
194
210
  const now = new Date().toISOString();
195
- const manifest = existingManifest ?? {
196
- version,
197
- installedAt: now,
198
- shellModified: false,
199
- harnesses: {},
200
- };
211
+ // Clone rather than alias: mutating the loaded object would silently
212
+ // change what any later `existingManifest` read sees. Today all reads
213
+ // precede this block — the clone keeps that a non-condition instead of
214
+ // an ordering invariant someone has to remember.
215
+ const manifest = existingManifest
216
+ ? structuredClone(existingManifest)
217
+ : {
218
+ version,
219
+ installedAt: now,
220
+ shellModified: false,
221
+ harnesses: {},
222
+ };
201
223
  manifest.version = version;
202
224
  manifest.installedAt = now;
203
225
  manifest.shellModified = shellModified || manifest.shellModified;
204
226
  for (const r of perHarnessResults) {
205
227
  if (!r.mcpResult)
206
228
  continue; // no MCP success → no entry
229
+ // A step that THREW produced no result — falling back to [] here
230
+ // would replace a populated prior entry with an empty record,
231
+ // orphaning every previously-installed file the moment a re-run
232
+ // fails (uninstall trusts these lists). Undefined result = keep the
233
+ // prior record; the `partial` marker names what didn't complete.
234
+ const prevEntry = existingManifest?.harnesses[r.harnessName];
235
+ const newDefsScope = opts.localDefs ? "local" : "global";
236
+ // TWO gates, deliberately: the FILE LISTS live under defsPath and
237
+ // may only be inherited within the same scope (a global list against
238
+ // a local path points uninstall at the wrong tree). The HOOK fields
239
+ // live in settings.json under profile.paths — scope-independent —
240
+ // and gating them on defsScope falsified hooksInstalled on a scope
241
+ // flip (audit pass 6, PROBE C).
242
+ const prevLists = prevEntry && prevEntry.defsScope === newDefsScope
243
+ ? prevEntry
244
+ : undefined;
245
+ const prevHooks = prevEntry;
246
+ if (prevEntry && !prevLists) {
247
+ // Scope flip: the prior tree at the old defsPath is no longer
248
+ // tracked by this manifest — say so rather than dropping it
249
+ // silently (cross-scope cleanup is not implemented).
250
+ warn(`[${r.harnessName}] defs scope changed (${prevEntry.defsScope} → ${newDefsScope}): previously installed files remain untracked at ${prevEntry.defsPath}`);
251
+ }
252
+ // A metrics result whose skippedReason is set NEVER OBSERVED the
253
+ // hook state ("--no-metrics" means don't touch metrics; unsupported
254
+ // harnesses too) — `??` alone can't express that because false is a
255
+ // value. Only an observing run may change the recorded hook state.
256
+ const metricsObserved = r.metricsResult !== undefined && !r.metricsResult.skippedReason;
207
257
  const harnessEntry = {
208
258
  installedAt: now,
209
259
  setupVersion: version,
210
260
  mcpScope: opts.scope,
211
261
  mcpConfigPath: r.mcpResult.configPath,
212
- defsScope: opts.localDefs ? "local" : "global",
262
+ defsScope: newDefsScope,
213
263
  defsPath: opts.localDefs
214
264
  ? join(await findProjectRoot(), "uluops")
215
265
  : r.profile.paths.home,
216
- agents: r.agentsResult?.files ?? [],
217
- commands: r.commandsResult?.files ?? [],
218
- skills: r.skillsResult?.files ?? [],
219
- hooksInstalled: r.metricsResult?.hookConfigured ?? false,
220
- hooksInstalledVersion: r.metricsResult?.hooksInstalledVersion ?? null,
266
+ agents: r.agentsResult?.files ?? prevLists?.agents ?? [],
267
+ commands: r.commandsResult?.files ?? prevLists?.commands ?? [],
268
+ skills: r.skillsResult?.files ?? prevLists?.skills ?? [],
269
+ hooksInstalled: metricsObserved
270
+ ? (r.metricsResult?.hookConfigured ?? false)
271
+ : (prevHooks?.hooksInstalled ?? false),
272
+ hooksInstalledVersion: metricsObserved
273
+ ? (r.metricsResult?.hooksInstalledVersion ?? null)
274
+ : (prevHooks?.hooksInstalledVersion ?? null),
221
275
  partial: r.partial ?? null,
222
276
  };
223
277
  manifest.harnesses[r.harnessName] = harnessEntry;
@@ -247,20 +301,31 @@ export async function runSetup(opts) {
247
301
  // per-harness status icons, partial markers, re-run hints, and the
248
302
  // aggregate counts in the header. Single-harness path preserves
249
303
  // today's Setup-complete banner format inside the same function.
250
- await printSetupSummary({
251
- results: perHarnessResults,
252
- apiKey,
253
- });
304
+ try {
305
+ await printSetupSummary({
306
+ results: perHarnessResults,
307
+ apiKey,
308
+ });
309
+ }
310
+ catch (err) {
311
+ // A render failure must not invert the run outcome: the install and
312
+ // manifest write already happened — classifyExit below is the
313
+ // authority, not the pretty-printer.
314
+ warn(`Could not render the setup summary: ${err instanceof Error ? err.message : String(err)}`);
315
+ }
254
316
  // Exit-code classifier (spec §7.5 4-tier table). One call, one place.
255
317
  // Empty perHarnessResults already short-circuited above with the
256
318
  // "nothing to install" message; classifyExit handles defense-in-depth.
257
- const exitCode = classifyExit(perHarnessResults);
258
- if (exitCode !== 0) {
259
- process.exit(exitCode);
260
- }
319
+ exitCode = classifyExit(perHarnessResults);
261
320
  }
262
321
  finally {
263
322
  if (lock)
264
323
  await lock.release();
265
324
  }
325
+ // process.exit inside the try would skip the finally and leave the lock
326
+ // held (the signal handlers are a backstop, not the contract) — classify
327
+ // inside, exit only after cleanup has run.
328
+ if (exitCode !== 0) {
329
+ process.exit(exitCode);
330
+ }
266
331
  }