@uluops/setup 0.6.5 → 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 (59) 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 +90 -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 +32 -0
  9. package/dist/commands/helpers.js +146 -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 +7 -3
  13. package/dist/commands/setup.js +232 -71
  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 +190 -82
  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.d.ts +47 -0
  33. package/dist/lib/install-lock.js +251 -0
  34. package/dist/lib/json-guards.d.ts +22 -0
  35. package/dist/lib/json-guards.js +33 -0
  36. package/dist/lib/manifest.d.ts +28 -0
  37. package/dist/lib/manifest.js +61 -12
  38. package/dist/lib/paths.d.ts +2 -17
  39. package/dist/lib/paths.js +4 -19
  40. package/dist/lib/settings-merger.js +3 -1
  41. package/dist/steps/agent-metrics-cli.d.ts +72 -0
  42. package/dist/steps/agent-metrics-cli.js +147 -0
  43. package/dist/steps/agents.d.ts +11 -0
  44. package/dist/steps/agents.js +30 -25
  45. package/dist/steps/auth.d.ts +13 -0
  46. package/dist/steps/auth.js +61 -5
  47. package/dist/steps/cli.d.ts +6 -0
  48. package/dist/steps/cli.js +29 -10
  49. package/dist/steps/commands.d.ts +10 -0
  50. package/dist/steps/commands.js +31 -30
  51. package/dist/steps/detect.js +15 -1
  52. package/dist/steps/mcp.js +1 -8
  53. package/dist/steps/metrics.js +10 -3
  54. package/dist/steps/shell.js +3 -13
  55. package/dist/steps/signup.js +14 -1
  56. package/dist/steps/skills.d.ts +14 -0
  57. package/dist/steps/skills.js +95 -0
  58. package/dist/steps/verify.js +195 -91
  59. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -3,10 +3,13 @@ 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 { resolveHarnessName, listHarnesses, detectHarnesses, HarnessNotTestedError, } from "./harnesses/index.js";
6
+ import { listHarnesses, detectHarnesses, getProfile, HarnessNotTestedError, } from "./harnesses/index.js";
7
+ import { InstallLockHeldError } from "./lib/install-lock.js";
7
8
  import { runSetup } from "./commands/setup.js";
8
9
  import { runUninstall } from "./commands/uninstall.js";
9
10
  import { runVerify } from "./commands/verify.js";
11
+ import { ConflictRejectedError } from "./commands/errors.js";
12
+ import { selectHarnesses, HarnessSelectionError, } from "./cli/select-harnesses.js";
10
13
  async function main() {
11
14
  const version = await getVersion();
12
15
  const program = new Command()
@@ -15,12 +18,15 @@ async function main() {
15
18
  .version(version)
16
19
  .option("--api-key <key>", "API key (skip prompt)")
17
20
  .option("--signup", "Create a new account (email + password, no browser)")
18
- .option("--harness <name>", `Target harness: ${listHarnesses().join(", ")} (aliases: claude, oc, gemini)`, "claude-code")
21
+ .option("--harness <name>", `Target harness: ${listHarnesses().join(", ")} (aliases: claude, oc, gemini). Accepts comma-separated subset ("claude-code,codex") or "all" to install into every detected stable harness.`, "claude-code")
22
+ .option("--all-detected", "Install into every detected stable harness. Canonical form; equivalent to --harness all.", false)
19
23
  .option("--scope <mode>", 'MCP connectivity scope: "global" (~/.claude.json) or "local" (.mcp.json)', "global")
20
24
  .option("--local-defs", "Save agents/commands locally (./uluops/) for project isolation", false)
21
25
  .option("--shell", "Write API key export to shell profile", false)
22
26
  .option("--with-cli", "Install @uluops/cli globally without prompting")
23
27
  .option("--no-cli", "Skip @uluops/cli install without prompting (takes precedence over --with-cli)")
28
+ .option("--with-agent-metrics-cli", "Install @uluops/agent-metrics globally without prompting")
29
+ .option("--no-agent-metrics-cli", "Skip @uluops/agent-metrics install without prompting (takes precedence over --with-agent-metrics-cli)")
24
30
  .option("--skip-validation", "Accept API key without verifying", false)
25
31
  .option("--list", "Show available agents and workflows without installing")
26
32
  .option("--verify", "Check existing installation health")
@@ -43,7 +49,17 @@ async function main() {
43
49
  return;
44
50
  }
45
51
  if (opts.uninstall) {
46
- await runUninstall({ dryRun: opts.dryRun });
52
+ // --harness / --all-detected on uninstall acts as a FILTER over the
53
+ // manifest's recorded harnesses, not a selection over what's detected
54
+ // on disk. Pass through the raw flag values; runUninstall delegates
55
+ // parsing + validation to resolveUninstallFilter (which mirrors the
56
+ // install-side conflict detection).
57
+ await runUninstall({
58
+ dryRun: opts.dryRun,
59
+ harnessArg: opts.harness,
60
+ harnessFromCli: program.getOptionValueSource("harness") === "cli",
61
+ allDetected: opts.allDetected,
62
+ });
47
63
  return;
48
64
  }
49
65
  if (opts.scope && opts.scope !== "local" && opts.scope !== "global") {
@@ -51,42 +67,59 @@ async function main() {
51
67
  process.exit(1);
52
68
  }
53
69
  const scope = opts.scope === "local" ? "local" : "global";
54
- // Resolve harness: if --harness was passed explicitly, honor it as-is.
55
- // Otherwise auto-detect — single match wins silently, multiple matches
56
- // prompt the user, no matches falls back to the default (claude-code)
57
- // to preserve the landing-page "just run npx @uluops/setup" promise.
58
- const harnessExplicit = program.getOptionValueSource("harness") === "cli";
59
- let harnessName;
60
- if (harnessExplicit) {
61
- harnessName = resolveHarnessName(opts.harness);
62
- }
63
- else {
64
- const detected = detectHarnesses();
65
- if (detected.length === 1) {
66
- harnessName = detected[0].name;
67
- if (harnessName !== "claude-code") {
68
- info(chalk.dim(`Detected ${chalk.cyan(detected[0].displayName)} — using as target (pass --harness to override)`));
69
- }
70
- }
71
- else if (detected.length > 1) {
72
- const isInteractive = !opts.yes && !opts.apiKey && !process.env["ULUOPS_API_KEY"] && process.stdin.isTTY;
73
- if (isInteractive) {
74
- const { select } = await import("@inquirer/prompts");
75
- harnessName = await select({
76
- message: "Multiple harnesses detected — which one are you setting up?",
77
- choices: detected.map((p) => ({ name: p.displayName, value: p.name })),
78
- default: detected[0].name,
70
+ // Resolve harness selection through the pure selection module so the
71
+ // matrix of (--harness, --all-detected, detection count, TTY) lives in
72
+ // one tested place. cli.ts only wires the prompt and emit-info callbacks.
73
+ const detected = detectHarnesses();
74
+ const isInteractive = !opts.yes &&
75
+ !opts.apiKey &&
76
+ !process.env["ULUOPS_API_KEY"] &&
77
+ !!process.stdin.isTTY;
78
+ let harnessNames;
79
+ try {
80
+ harnessNames = await selectHarnesses({
81
+ harnessArg: opts.harness,
82
+ harnessFromCli: program.getOptionValueSource("harness") === "cli",
83
+ allDetected: opts.allDetected,
84
+ detected,
85
+ defaultHarness: "claude-code",
86
+ isInteractive,
87
+ emitInfo: (msg) => info(chalk.dim(msg)),
88
+ promptCheckbox: async (profiles) => {
89
+ const { checkbox } = await import("@inquirer/prompts");
90
+ const chosen = await checkbox({
91
+ message: "Multiple harnesses detected. Which would you like to install into?",
92
+ instructions: " (use space to toggle, enter to confirm)",
93
+ choices: profiles.map((p) => ({
94
+ name: p.displayName,
95
+ value: p.name,
96
+ checked: true,
97
+ })),
79
98
  });
80
99
  console.log();
81
- }
82
- else {
83
- harnessName = detected[0].name;
84
- info(chalk.dim(`Multiple harnesses detected (${detected.map((p) => p.displayName).join(", ")}); defaulting to ${chalk.cyan(detected[0].displayName)} — pass --harness to choose`));
85
- }
86
- }
87
- else {
88
- harnessName = resolveHarnessName(opts.harness);
100
+ return chosen;
101
+ },
102
+ });
103
+ }
104
+ catch (err) {
105
+ if (err instanceof HarnessSelectionError) {
106
+ console.error(chalk.red(`\n ${err.message}\n`));
107
+ process.exit(1);
89
108
  }
109
+ throw err;
110
+ }
111
+ // Resolve every name through getProfile up front. This catches typos
112
+ // (e.g. `--harness claude-cod,codex`) before any state is touched and
113
+ // surfaces the canonical "Available: ..." error. Aliases ('claude' →
114
+ // 'claude-code') are normalized here as a side effect.
115
+ let resolvedHarnesses;
116
+ try {
117
+ resolvedHarnesses = harnessNames.map((n) => getProfile(n).name);
118
+ }
119
+ catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ console.error(chalk.red(`\n ${msg}\n`));
122
+ process.exit(1);
90
123
  }
91
124
  await runSetup({
92
125
  apiKey: opts.apiKey,
@@ -96,17 +129,37 @@ async function main() {
96
129
  shell: opts.shell,
97
130
  withCli: opts.withCli,
98
131
  cli: opts.cli,
132
+ withAgentMetricsCli: opts.withAgentMetricsCli,
133
+ agentMetricsCli: opts.agentMetricsCli,
99
134
  skipValidation: opts.skipValidation,
100
135
  dryRun: opts.dryRun,
101
136
  yes: opts.yes,
102
- harness: harnessName,
137
+ harnesses: resolvedHarnesses,
103
138
  });
104
139
  }
105
140
  main().catch((err) => {
141
+ if (err instanceof ConflictRejectedError) {
142
+ // Single-harness path: user declined the conflict prompt. Exit 0
143
+ // (today's UX — no error). The multi-harness orchestrator catches
144
+ // this inside its loop and never lets it bubble to here when there
145
+ // are siblings to install; this handler only fires when the declined
146
+ // harness was the only target (the loop continues, finds nothing
147
+ // installed, and... actually the loop swallows it before propagating,
148
+ // so this handler is defense-in-depth for any future caller of
149
+ // checkConflicts outside the loop). Either way: exit 0, no message.
150
+ process.exit(0);
151
+ }
106
152
  if (err instanceof HarnessNotTestedError) {
107
153
  console.error(chalk.yellow(`\n ${err.message}\n`));
108
154
  process.exit(1);
109
155
  }
156
+ if (err instanceof InstallLockHeldError) {
157
+ console.error(chalk.yellow(`\n ${err.message}\n`));
158
+ console.error(chalk.dim(" Wait for the other process to finish, or — if it crashed —\n" +
159
+ " the lock auto-releases after 30 minutes or when the held\n" +
160
+ " PID is detected as no longer running.\n"));
161
+ process.exit(1);
162
+ }
110
163
  const msg = err instanceof Error ? err.message : String(err);
111
164
  console.error(chalk.red(`\n Error: ${msg}\n`));
112
165
  process.exit(1);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Typed setup errors. Each class corresponds to a distinct failure category
3
+ * the per-harness orchestrator (or the top-level CLI handler) needs to
4
+ * discriminate to choose the right exit code, message, and continue/abort
5
+ * decision.
6
+ *
7
+ * Discrimination at the type level — not on error.message — because messages
8
+ * are user-facing and will drift; constructors are stable.
9
+ */
10
+ /**
11
+ * Raised when the user declines a "Continue?" conflict prompt during a
12
+ * first-install for a harness. The single-harness path catches this in
13
+ * the CLI top-level handler and exits 0 cleanly (today's UX). The
14
+ * multi-harness path catches it in the per-harness loop, marks that
15
+ * harness as `declined`, and continues with siblings.
16
+ *
17
+ * NOT an operational failure — this is a deliberate user choice. The
18
+ * exit-code classifier (spec §7.5) treats `declined` as exit 0 unless
19
+ * combined with an actual `failed` harness in the same run.
20
+ */
21
+ export declare class ConflictRejectedError extends Error {
22
+ readonly harnessName: string;
23
+ constructor(harnessName: string);
24
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Typed setup errors. Each class corresponds to a distinct failure category
3
+ * the per-harness orchestrator (or the top-level CLI handler) needs to
4
+ * discriminate to choose the right exit code, message, and continue/abort
5
+ * decision.
6
+ *
7
+ * Discrimination at the type level — not on error.message — because messages
8
+ * are user-facing and will drift; constructors are stable.
9
+ */
10
+ /**
11
+ * Raised when the user declines a "Continue?" conflict prompt during a
12
+ * first-install for a harness. The single-harness path catches this in
13
+ * the CLI top-level handler and exits 0 cleanly (today's UX). The
14
+ * multi-harness path catches it in the per-harness loop, marks that
15
+ * harness as `declined`, and continues with siblings.
16
+ *
17
+ * NOT an operational failure — this is a deliberate user choice. The
18
+ * exit-code classifier (spec §7.5) treats `declined` as exit 0 unless
19
+ * combined with an actual `failed` harness in the same run.
20
+ */
21
+ export class ConflictRejectedError extends Error {
22
+ harnessName;
23
+ constructor(harnessName) {
24
+ super(`User declined conflict prompt for ${harnessName}`);
25
+ this.harnessName = harnessName;
26
+ this.name = "ConflictRejectedError";
27
+ }
28
+ }
@@ -2,8 +2,10 @@ import { detect } from "../steps/detect.js";
2
2
  import type { McpResult } from "../steps/mcp.js";
3
3
  import type { AgentsResult } from "../steps/agents.js";
4
4
  import type { CommandsResult } from "../steps/commands.js";
5
+ import type { SkillsResult } from "../steps/skills.js";
5
6
  import type { MetricsResult } from "../steps/metrics.js";
6
7
  import type { CliExecutor, CliInstallResult } from "../steps/cli.js";
8
+ import type { AgentMetricsCliExecutor, AgentMetricsCliInstallResult } from "../steps/agent-metrics-cli.js";
7
9
  import type { HarnessProfile } from "../harnesses/index.js";
8
10
  /** Resolve API key via flag, env, file, signup, or interactive prompt. Returns env detection + key. */
9
11
  export declare function initContext(opts: {
@@ -11,6 +13,7 @@ export declare function initContext(opts: {
11
13
  signup: boolean;
12
14
  skipValidation: boolean;
13
15
  yes: boolean;
16
+ dryRun?: boolean;
14
17
  }): Promise<{
15
18
  env: Awaited<ReturnType<typeof detect>>;
16
19
  apiKey: string;
@@ -30,6 +33,11 @@ export declare function installCommandsDefs(profile: HarnessProfile, opts: {
30
33
  localDefs: boolean;
31
34
  dryRun: boolean;
32
35
  }, prev?: string[]): Promise<CommandsResult>;
36
+ /** Copy Codex-style skills from assets to the harness directory. */
37
+ export declare function installSkillsDefs(profile: HarnessProfile, opts: {
38
+ localDefs: boolean;
39
+ dryRun: boolean;
40
+ }, prev?: string[]): Promise<SkillsResult>;
33
41
  /** Install agent-metrics hook and tool files (Claude Code only). */
34
42
  export declare function configureMetricsStep(profile: HarnessProfile, opts: {
35
43
  dryRun: boolean;
@@ -54,6 +62,30 @@ export declare function configureCliStep(opts: {
54
62
  dryRun: boolean;
55
63
  executor?: CliExecutor;
56
64
  }): Promise<CliInstallResult | null>;
65
+ /**
66
+ * Decide whether to install `@uluops/agent-metrics` globally and do it.
67
+ *
68
+ * Only meaningful when the SubagentStop hook actually got configured —
69
+ * otherwise the CLI has no captures to read. Caller gates on
70
+ * `metricsResult.hookConfigured`.
71
+ *
72
+ * Decision matrix mirrors `configureCliStep`:
73
+ * - `--no-agent-metrics-cli` (opts.agentMetricsCli === false) → skip, no prompt
74
+ * - `--with-agent-metrics-cli` (opts.withAgentMetricsCli === true) → install, no prompt
75
+ * - Neither flag + non-interactive (--yes / --api-key / no TTY) → skip
76
+ * - Neither flag + interactive → prompt (default Y)
77
+ *
78
+ * Returns null when the step did not run (skipped). Returns an install result
79
+ * when an attempt was made, for manifest recording.
80
+ */
81
+ export declare function configureAgentMetricsCliStep(opts: {
82
+ withAgentMetricsCli?: boolean;
83
+ agentMetricsCli?: boolean;
84
+ yes: boolean;
85
+ apiKey?: string;
86
+ dryRun: boolean;
87
+ executor?: AgentMetricsCliExecutor;
88
+ }): Promise<AgentMetricsCliInstallResult | null>;
57
89
  /** Ping tracker and registry health endpoints. */
58
90
  export declare function runHealthCheck(opts: {
59
91
  skipValidation: boolean;
@@ -3,17 +3,20 @@ import { join } from "node:path";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import { detect } from "../steps/detect.js";
5
5
  import { signup } from "../steps/signup.js";
6
- import { resolveApiKey, hasCredentialsFile } from "../steps/auth.js";
6
+ import { resolveApiKey, hasCredentialsFile, writeCredentialsFile } from "../steps/auth.js";
7
7
  import { installMcp } from "../steps/mcp.js";
8
8
  import { installAgents } from "../steps/agents.js";
9
9
  import { installCommands } from "../steps/commands.js";
10
+ import { installSkills } from "../steps/skills.js";
10
11
  import { installMetrics } from "../steps/metrics.js";
11
12
  import { installCli, CLI_PACKAGE } from "../steps/cli.js";
13
+ import { installAgentMetricsCli, AGENT_METRICS_PACKAGE, AGENT_METRICS_BIN, } from "../steps/agent-metrics-cli.js";
12
14
  import { writeShellExport } from "../steps/shell.js";
13
15
  import { probeHookSupport } from "../lib/settings-merger.js";
14
16
  import { findProjectRoot, ASSETS_DIR } from "../lib/paths.js";
15
17
  import { getHealthTimeout } from "../lib/health.js";
16
18
  import { ok, warn, fail, info } from "../lib/display.js";
19
+ import { ConflictRejectedError } from "./errors.js";
17
20
  /**
18
21
  * Decide whether to ask the user "Are you creating a new account?".
19
22
  * The prompt is the new-user friction-reducer — it should fire only when
@@ -46,6 +49,10 @@ async function shouldPromptForAccount(opts) {
46
49
  export async function initContext(opts) {
47
50
  const env = await detect();
48
51
  let apiKey;
52
+ let resolvedEmail = null;
53
+ // Capture before auth runs — a successful resolve via the credentials file
54
+ // would otherwise create the appearance of "no prior file" after the fact.
55
+ const credsExistedAtStart = await hasCredentialsFile();
49
56
  let creatingAccount = opts.signup;
50
57
  if (!opts.signup && (await shouldPromptForAccount(opts))) {
51
58
  const { confirm } = await import("@inquirer/prompts");
@@ -55,21 +62,28 @@ export async function initContext(opts) {
55
62
  });
56
63
  console.log();
57
64
  }
65
+ let credsSource = "flag";
58
66
  try {
59
67
  if (creatingAccount) {
60
68
  info("Create your UluOps account\n");
61
69
  const auth = await signup();
62
70
  apiKey = auth.apiKey;
71
+ resolvedEmail = auth.email;
72
+ credsSource = "signup";
63
73
  ok(`Account created (${auth.email})`);
64
74
  ok(`API key generated`);
65
75
  }
66
76
  else {
77
+ const interactive = !opts.yes && !opts.apiKey && !process.env["ULUOPS_API_KEY"];
67
78
  const auth = await resolveApiKey({
68
79
  apiKeyFlag: opts.apiKey,
69
80
  skipValidation: opts.skipValidation,
70
- interactive: !opts.yes && !opts.apiKey && !process.env["ULUOPS_API_KEY"],
81
+ interactive,
71
82
  });
72
83
  apiKey = auth.apiKey;
84
+ resolvedEmail = auth.email;
85
+ credsSource =
86
+ opts.apiKey || process.env["ULUOPS_API_KEY"] ? "flag" : "prompt";
73
87
  if (auth.email)
74
88
  ok(`Key validated (${auth.email})`);
75
89
  else if (opts.skipValidation)
@@ -82,6 +96,34 @@ export async function initContext(opts) {
82
96
  fail(err instanceof Error ? err.message : String(err));
83
97
  process.exit(1);
84
98
  }
99
+ // Persist the key locally so @uluops/cli and the SDK can read it from disk.
100
+ // Always write after signup (key was just minted, nothing else holds it).
101
+ // Otherwise write only if there was no prior file — avoids churning
102
+ // createdAt on every returning-user invocation.
103
+ if (credsSource === "signup" || !credsExistedAtStart) {
104
+ try {
105
+ await writeCredentialsFile(apiKey, {
106
+ email: resolvedEmail,
107
+ source: credsSource,
108
+ dryRun: opts.dryRun,
109
+ });
110
+ if (!opts.dryRun)
111
+ ok("Credentials saved → ~/.uluops/credentials.json");
112
+ }
113
+ catch (err) {
114
+ const msg = err instanceof Error ? err.message : String(err);
115
+ warn(`Could not save credentials to ~/.uluops/credentials.json: ${msg}`);
116
+ // On signup, the warn() above is the ONLY surface that names the failure.
117
+ // The key was just minted; if we don't persist it and don't surface it,
118
+ // the user has no path back to it short of minting another one.
119
+ // Print recovery context so the message is actionable, not just a flag.
120
+ if (credsSource === "signup") {
121
+ warn(" Your key was generated but is not on disk for @uluops/cli to read.");
122
+ warn(" Copy it from the MCP config block written above (look for ULUOPS_API_KEY)");
123
+ warn(" or pass --skip-validation --api-key <key> on the next run.");
124
+ }
125
+ }
126
+ }
85
127
  return { env, apiKey };
86
128
  }
87
129
  /** Write MCP server entries to harness config and report warnings. */
@@ -106,6 +148,11 @@ export async function installAgentsDefs(profile, opts, prev) {
106
148
  ? "./uluops/agents/"
107
149
  : `${profile.paths.agentsDir.replace(process.env["HOME"] ?? "", "~")}/`;
108
150
  ok(`${res.files.length} agents → ${dest}${parts.length ? ` (${parts.join(", ")})` : ""}`);
151
+ // Per-file copy failures are non-fatal — surface them so the user knows
152
+ // re-running setup will retry the unwritten files.
153
+ for (const f of res.failures) {
154
+ warn(`Failed to copy agent ${f.file}: ${f.error} — re-run setup to retry`);
155
+ }
109
156
  return res;
110
157
  }
111
158
  /** Copy slash-command definitions from assets (Claude Code only). */
@@ -127,6 +174,33 @@ export async function installCommandsDefs(profile, opts, prev) {
127
174
  ? "./uluops/commands/"
128
175
  : `${profile.paths.commandsDir.replace(process.env["HOME"] ?? "", "~")}/`;
129
176
  ok(`${res.files.length} commands → ${dest}${parts.length ? ` (${parts.join(", ")})` : ""}`);
177
+ // Per-file copy failures are non-fatal — surface them so the user knows
178
+ // re-running setup will retry the unwritten files.
179
+ for (const f of res.failures) {
180
+ warn(`Failed to copy command ${f.file}: ${f.error} — re-run setup to retry`);
181
+ }
182
+ return res;
183
+ }
184
+ /** Copy Codex-style skills from assets to the harness directory. */
185
+ export async function installSkillsDefs(profile, opts, prev) {
186
+ const res = await installSkills(profile, opts.localDefs, opts.dryRun, prev);
187
+ if (res.skippedReason === "not-supported") {
188
+ return res;
189
+ }
190
+ const parts = [];
191
+ if (res.copied > 0)
192
+ parts.push(`${res.copied} copied`);
193
+ if (res.skipped > 0)
194
+ parts.push(`${res.skipped} unchanged`);
195
+ if (res.removed > 0)
196
+ parts.push(`${res.removed} removed`);
197
+ const dest = opts.localDefs
198
+ ? "./uluops/skills/"
199
+ : `${(profile.paths.skillsDir ?? "").replace(process.env["HOME"] ?? "", "~")}/`;
200
+ ok(`${res.files.length} skills → ${dest}${parts.length ? ` (${parts.join(", ")})` : ""}`);
201
+ for (const f of res.failures) {
202
+ warn(`Failed to copy skill ${f.file}: ${f.error} — re-run setup to retry`);
203
+ }
130
204
  return res;
131
205
  }
132
206
  /** Install agent-metrics hook and tool files (Claude Code only). */
@@ -213,6 +287,71 @@ export async function configureCliStep(opts) {
213
287
  }
214
288
  return res;
215
289
  }
290
+ /**
291
+ * Decide whether to install `@uluops/agent-metrics` globally and do it.
292
+ *
293
+ * Only meaningful when the SubagentStop hook actually got configured —
294
+ * otherwise the CLI has no captures to read. Caller gates on
295
+ * `metricsResult.hookConfigured`.
296
+ *
297
+ * Decision matrix mirrors `configureCliStep`:
298
+ * - `--no-agent-metrics-cli` (opts.agentMetricsCli === false) → skip, no prompt
299
+ * - `--with-agent-metrics-cli` (opts.withAgentMetricsCli === true) → install, no prompt
300
+ * - Neither flag + non-interactive (--yes / --api-key / no TTY) → skip
301
+ * - Neither flag + interactive → prompt (default Y)
302
+ *
303
+ * Returns null when the step did not run (skipped). Returns an install result
304
+ * when an attempt was made, for manifest recording.
305
+ */
306
+ export async function configureAgentMetricsCliStep(opts) {
307
+ if (opts.agentMetricsCli === false) {
308
+ info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install (--no-agent-metrics-cli)`));
309
+ return null;
310
+ }
311
+ let shouldInstall;
312
+ if (opts.withAgentMetricsCli === true) {
313
+ shouldInstall = true;
314
+ }
315
+ else {
316
+ const nonInteractive = opts.yes || !!opts.apiKey || !process.stdin.isTTY;
317
+ if (nonInteractive) {
318
+ info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install (non-interactive — pass --with-agent-metrics-cli to install)`));
319
+ return null;
320
+ }
321
+ const { confirm } = await import("@inquirer/prompts");
322
+ shouldInstall = await confirm({
323
+ message: `Install ${AGENT_METRICS_PACKAGE} globally (provides the ${chalk.cyan(AGENT_METRICS_BIN)} command for reading captures)?`,
324
+ default: true,
325
+ });
326
+ if (!shouldInstall) {
327
+ info(chalk.dim(`Skipped global ${AGENT_METRICS_PACKAGE} install`));
328
+ return null;
329
+ }
330
+ }
331
+ const res = await installAgentMetricsCli({
332
+ dryRun: opts.dryRun,
333
+ executor: opts.executor,
334
+ });
335
+ if (opts.dryRun && !res.alreadyPresent) {
336
+ ok(`Would install ${AGENT_METRICS_PACKAGE} globally`);
337
+ return res;
338
+ }
339
+ if (res.alreadyPresent) {
340
+ ok(`${AGENT_METRICS_PACKAGE} already installed${res.version ? ` (${res.version})` : ""} — no change`);
341
+ return res;
342
+ }
343
+ if (res.installed) {
344
+ ok(`${AGENT_METRICS_PACKAGE} installed globally${res.version ? ` (${res.version})` : ""}`);
345
+ return res;
346
+ }
347
+ warn(`Could not install ${AGENT_METRICS_PACKAGE} globally — try ${chalk.cyan(`npm install -g ${AGENT_METRICS_PACKAGE}`)} manually`);
348
+ if (res.error) {
349
+ const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
350
+ if (oneLine)
351
+ info(chalk.dim(` ${oneLine}`));
352
+ }
353
+ return res;
354
+ }
216
355
  /** Ping tracker and registry health endpoints. */
217
356
  export async function runHealthCheck(opts) {
218
357
  if (!opts.skipValidation && !opts.dryRun) {
@@ -294,7 +433,11 @@ export async function checkConflicts(profile, localDefs) {
294
433
  const { confirm } = await import("@inquirer/prompts");
295
434
  const proceed = await confirm({ message: "Continue?", default: true });
296
435
  if (!proceed) {
297
- process.exit(0);
436
+ // Throw instead of process.exit so the multi-harness loop can catch and
437
+ // continue with sibling harnesses. The single-harness CLI top-level
438
+ // handler catches ConflictRejectedError and exits 0 cleanly, preserving
439
+ // today's UX. (Spec §7.5; replaces the prior process.exit(0).)
440
+ throw new ConflictRejectedError(profile.name);
298
441
  }
299
442
  }
300
443
  /** Fetch a URL and return true if the response is OK, false on any failure. */
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Per-harness orchestration types + the exit-code classifier.
3
+ *
4
+ * Lives in its own module so both `runSetup` (which produces these) and
5
+ * `printSetupSummary` (which renders them) can import without circular
6
+ * dependency. The classifier is testable in isolation — every row of the
7
+ * spec §7.5 4-tier table is one test.
8
+ */
9
+ import type { HarnessProfile } from "../harnesses/index.js";
10
+ import type { PartialStep } from "../lib/manifest.js";
11
+ import type { AgentsResult } from "../steps/agents.js";
12
+ import type { CommandsResult } from "../steps/commands.js";
13
+ import type { SkillsResult } from "../steps/skills.js";
14
+ import type { MetricsResult } from "../steps/metrics.js";
15
+ import type { McpResult } from "../steps/mcp.js";
16
+ /**
17
+ * Per-harness outcome captured by the orchestrator loop.
18
+ *
19
+ * `status` semantics:
20
+ * ok — every per-harness step succeeded; the user's choice was
21
+ * carried out and recorded in the manifest.
22
+ * failed — an OPERATIONAL error occurred (MCP write failed,
23
+ * agents-step mkdir EACCES, etc.). Drives exit code 1.
24
+ * declined — the user actively rejected the conflict prompt. NOT an
25
+ * error from the tool's perspective — it's a deliberate
26
+ * choice. Drives exit code 0 (with a non-zero summary line
27
+ * surfacing the count).
28
+ */
29
+ export interface PerHarnessResult {
30
+ harnessName: string;
31
+ profile: HarnessProfile;
32
+ status: "ok" | "failed" | "declined";
33
+ error?: string;
34
+ mcpResult?: McpResult;
35
+ agentsResult?: AgentsResult;
36
+ commandsResult?: CommandsResult;
37
+ skillsResult?: SkillsResult;
38
+ metricsResult?: MetricsResult;
39
+ partial?: PartialStep | null;
40
+ }
41
+ /**
42
+ * Decide the process exit code from a set of per-harness results.
43
+ *
44
+ * Spec §7.5 4-tier table:
45
+ *
46
+ * | Outcome | Exit |
47
+ * |------------------------------------------------------|------|
48
+ * | Every harness ok | 0 |
49
+ * | Any harness failed (operational) | 1 |
50
+ * | Any declined AND zero failed | 0 |
51
+ * | Empty (user unchecked all, or no harnesses to run) | 0 |
52
+ *
53
+ * Rationale: CI wrapping `--harness all` should not be poisoned by
54
+ * user-policy choices (declines, no-op outcomes) but MUST fail on
55
+ * operational errors (EACCES, ENOSPC, parse-error) so deploy pipelines
56
+ * can detect partial failure and re-run.
57
+ *
58
+ * `partial` is a property of a `failed` result (post-MCP-success step
59
+ * threw), not its own status — it's already covered by the failed
60
+ * branch. A successful run with file-level `failures[]` arrays is
61
+ * still `status: "ok"` (file failures are recoverable on re-run; the
62
+ * user has working state).
63
+ */
64
+ export declare function classifyExit(results: PerHarnessResult[]): number;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-harness orchestration types + the exit-code classifier.
3
+ *
4
+ * Lives in its own module so both `runSetup` (which produces these) and
5
+ * `printSetupSummary` (which renders them) can import without circular
6
+ * dependency. The classifier is testable in isolation — every row of the
7
+ * spec §7.5 4-tier table is one test.
8
+ */
9
+ /**
10
+ * Decide the process exit code from a set of per-harness results.
11
+ *
12
+ * Spec §7.5 4-tier table:
13
+ *
14
+ * | Outcome | Exit |
15
+ * |------------------------------------------------------|------|
16
+ * | Every harness ok | 0 |
17
+ * | Any harness failed (operational) | 1 |
18
+ * | Any declined AND zero failed | 0 |
19
+ * | Empty (user unchecked all, or no harnesses to run) | 0 |
20
+ *
21
+ * Rationale: CI wrapping `--harness all` should not be poisoned by
22
+ * user-policy choices (declines, no-op outcomes) but MUST fail on
23
+ * operational errors (EACCES, ENOSPC, parse-error) so deploy pipelines
24
+ * can detect partial failure and re-run.
25
+ *
26
+ * `partial` is a property of a `failed` result (post-MCP-success step
27
+ * threw), not its own status — it's already covered by the failed
28
+ * branch. A successful run with file-level `failures[]` arrays is
29
+ * still `status: "ok"` (file failures are recoverable on re-run; the
30
+ * user has working state).
31
+ */
32
+ export function classifyExit(results) {
33
+ if (results.length === 0)
34
+ return 0;
35
+ const anyFailed = results.some((r) => r.status === "failed");
36
+ return anyFailed ? 1 : 0;
37
+ }
@@ -1,4 +1,5 @@
1
- export declare function runSetup(opts: {
1
+ export type { PerHarnessResult } from "./per-harness.js";
2
+ interface RunSetupOpts {
2
3
  apiKey?: string;
3
4
  signup: boolean;
4
5
  scope: "global" | "local";
@@ -7,7 +8,10 @@ export declare function runSetup(opts: {
7
8
  skipValidation: boolean;
8
9
  dryRun: boolean;
9
10
  yes: boolean;
10
- harness: string;
11
+ harnesses: string[];
11
12
  withCli?: boolean;
12
13
  cli?: boolean;
13
- }): Promise<void>;
14
+ withAgentMetricsCli?: boolean;
15
+ agentMetricsCli?: boolean;
16
+ }
17
+ export declare function runSetup(opts: RunSetupOpts): Promise<void>;