@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
@@ -2,92 +2,253 @@ 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 } from "../lib/display.js";
5
+ import { info, printSetupSummary, warn } from "../lib/display.js";
6
6
  import { getVersion } from "../lib/version.js";
7
7
  import { getProfile } from "../harnesses/index.js";
8
- import { initContext, checkConflicts, configureMcpStep, installAgentsDefs, installCommandsDefs, configureMetricsStep, configureCliStep, runHealthCheck, configureShell, } from "./helpers.js";
8
+ import { acquireInstallLock, } from "../lib/install-lock.js";
9
+ import { initContext, checkConflicts, configureMcpStep, installAgentsDefs, installCommandsDefs, installSkillsDefs, configureMetricsStep, configureCliStep, configureAgentMetricsCliStep, runHealthCheck, configureShell, } from "./helpers.js";
10
+ import { ConflictRejectedError } from "./errors.js";
11
+ import { classifyExit, } from "./per-harness.js";
9
12
  export async function runSetup(opts) {
13
+ if (opts.harnesses.length === 0) {
14
+ info(chalk.dim("Nothing to install — re-run with at least one harness selected.\n"));
15
+ return;
16
+ }
10
17
  const version = await getVersion();
11
- const profile = getProfile(opts.harness);
18
+ // Resolve every harness up front so a typo fails fast before any state
19
+ // is touched. getProfile throws HarnessNotTestedError or a friendly
20
+ // unknown-name error; the top-level catch in cli.ts surfaces them.
21
+ const profiles = opts.harnesses.map((name) => getProfile(name));
12
22
  console.log();
13
23
  console.log(` ${chalk.dim("⟨u⟩")} ${chalk.cyan.bold("ulu")}${chalk.bold("·ops")}`);
14
24
  console.log(` ${chalk.dim("operating intelligence as infrastructure")}`);
15
25
  console.log();
16
- console.log(` Setup v${version} — ${chalk.bold(profile.displayName)}`);
26
+ const targetSummary = profiles.length === 1
27
+ ? profiles[0].displayName
28
+ : `${profiles.length} harnesses (${profiles.map((p) => p.displayName).join(", ")})`;
29
+ console.log(` Setup v${version} — ${chalk.bold(targetSummary)}`);
17
30
  console.log();
18
31
  if (opts.dryRun) {
19
32
  info(chalk.dim("(dry run — no changes will be made)\n"));
20
33
  }
34
+ // === Once-per-run: BEFORE the per-harness loop ===
21
35
  const { env, apiKey } = await initContext(opts);
22
36
  console.log();
23
- // Load existing manifest for update detection
24
- const existingManifest = await loadManifest();
25
- const existingHarness = existingManifest?.harnesses[profile.name];
26
- if (existingManifest && existingManifest.version !== version) {
27
- info(`Updating ${chalk.dim(existingManifest.version)} → ${chalk.green(version)}`);
28
- console.log();
29
- }
30
- else if (existingHarness) {
31
- info(chalk.dim(`Already at v${version} — checking for changes`));
32
- console.log();
33
- }
34
- // Check for conflicts on first install for this harness
35
- if (!existingHarness && !opts.yes && !opts.dryRun) {
36
- await checkConflicts(profile, opts.localDefs);
37
- }
38
- const mcpResult = await configureMcpStep(profile, apiKey, opts);
39
- const agentsResult = await installAgentsDefs(profile, opts, existingHarness?.agents);
40
- const commandsResult = await installCommandsDefs(profile, opts, existingHarness?.commands);
41
- const metricsResult = await configureMetricsStep(profile, opts);
42
- const cliResult = await configureCliStep({
43
- withCli: opts.withCli,
44
- cli: opts.cli,
45
- yes: opts.yes,
46
- apiKey: opts.apiKey,
47
- dryRun: opts.dryRun,
48
- });
49
- await runHealthCheck(opts);
50
- const shellModified = await configureShell(env, apiKey, opts);
51
- // Save manifest
37
+ // Acquire the install lock before touching any shared state. Skipped on
38
+ // dry-run (read-only). The lock excludes a second concurrent uluops-setup
39
+ // from racing the manifest / MCP config / shell-profile / settings.json
40
+ // read-merge-write windows. Held across the entire multi-harness loop —
41
+ // concurrent multi-harness installs from separate processes serialize
42
+ // (spec §10.6).
43
+ let lock = null;
52
44
  if (!opts.dryRun) {
53
- const now = new Date().toISOString();
54
- const harnessEntry = {
55
- installedAt: now,
56
- setupVersion: version,
57
- mcpScope: opts.scope,
58
- mcpConfigPath: mcpResult.configPath,
59
- defsScope: opts.localDefs ? "local" : "global",
60
- defsPath: opts.localDefs
61
- ? join(await findProjectRoot(), "uluops")
62
- : profile.paths.home,
63
- agents: agentsResult.files,
64
- commands: commandsResult.files,
65
- hooksInstalled: metricsResult.hookConfigured,
66
- hooksInstalledVersion: metricsResult.hooksInstalledVersion,
67
- };
68
- const manifest = existingManifest ?? {
69
- version,
70
- installedAt: now,
71
- shellModified: false,
72
- harnesses: {},
73
- };
74
- manifest.version = version;
75
- manifest.installedAt = now;
76
- manifest.shellModified = shellModified || manifest.shellModified;
77
- manifest.harnesses[profile.name] = harnessEntry;
78
- // Only flip cliInstalled to true when WE installed it (not when user-installed).
79
- // Once true, persist across re-runs so uninstall remains symmetric — until the
80
- // user explicitly removes it with --no-cli + uninstall, this manifest owns it.
81
- if (cliResult && cliResult.installed && !cliResult.alreadyPresent) {
82
- manifest.cliInstalled = true;
83
- manifest.cliInstalledVersion = cliResult.version;
45
+ lock = await acquireInstallLock();
46
+ }
47
+ try {
48
+ const existingManifest = await loadManifest();
49
+ if (existingManifest && existingManifest.version !== version) {
50
+ info(`Updating ${chalk.dim(existingManifest.version)} → ${chalk.green(version)}`);
51
+ console.log();
84
52
  }
85
- await saveManifest(manifest);
53
+ // === Per-harness loop ===
54
+ const perHarnessResults = [];
55
+ for (const profile of profiles) {
56
+ const harnessName = profile.name;
57
+ // Read each harness's own slice of prior state every iteration. Never
58
+ // reuse a single existingHarness across iterations — installAgents/
59
+ // Commands/Skills consume prev lists from existingHarness for drift
60
+ // detection; using the wrong harness's prev list silently orphans
61
+ // files (spec §7.6.1 per-iteration state isolation).
62
+ const existingHarness = existingManifest?.harnesses[harnessName];
63
+ console.log(chalk.dim(`▸ ${profile.displayName}`));
64
+ if (existingHarness && !existingHarness.partial) {
65
+ info(chalk.dim(` Already installed at v${version} — checking for changes`));
66
+ }
67
+ // First-install conflict check, OR re-run after a partial install
68
+ // (the user never confirmed the conflict on the failing run, so we
69
+ // re-prompt — spec §7.6.5).
70
+ const needsConflictCheck = !opts.yes &&
71
+ !opts.dryRun &&
72
+ (!existingHarness || existingHarness.partial != null);
73
+ if (needsConflictCheck) {
74
+ try {
75
+ await checkConflicts(profile, opts.localDefs);
76
+ }
77
+ catch (err) {
78
+ if (err instanceof ConflictRejectedError) {
79
+ perHarnessResults.push({
80
+ harnessName,
81
+ profile,
82
+ status: "declined",
83
+ error: err.message,
84
+ });
85
+ warn(`[${harnessName}] skipped (user declined conflict) — continuing with remaining harnesses`);
86
+ console.log();
87
+ continue;
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ // MCP must succeed for a manifest entry to exist (the entry depends
93
+ // on mcpResult.configPath). An MCP throw means no manifest entry —
94
+ // harness marked failed and the loop continues to siblings.
95
+ let mcpResult;
96
+ try {
97
+ mcpResult = await configureMcpStep(profile, apiKey, opts);
98
+ }
99
+ catch (err) {
100
+ perHarnessResults.push({
101
+ harnessName,
102
+ profile,
103
+ status: "failed",
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ warn(`[${harnessName}] MCP configuration failed — continuing with remaining harnesses`);
107
+ console.log();
108
+ continue;
109
+ }
110
+ // Subsequent steps may throw on pre-loop work (mkdir EACCES, etc.).
111
+ // If they do, build a partial manifest entry naming the failed step
112
+ // (spec §7.6.2 Case B). The per-file failures[] arrays on each
113
+ // result are surfaced inside the helper's warn() calls.
114
+ let agentsResult;
115
+ let commandsResult;
116
+ let skillsResult;
117
+ let metricsResult;
118
+ let failedStep = null;
119
+ let failedError;
120
+ try {
121
+ agentsResult = await installAgentsDefs(profile, opts, existingHarness?.agents);
122
+ commandsResult = await installCommandsDefs(profile, opts, existingHarness?.commands);
123
+ skillsResult = await installSkillsDefs(profile, opts, existingHarness?.skills);
124
+ metricsResult = await configureMetricsStep(profile, opts);
125
+ }
126
+ catch (err) {
127
+ failedError = err instanceof Error ? err.message : String(err);
128
+ failedStep = !agentsResult
129
+ ? "agents"
130
+ : !commandsResult
131
+ ? "commands"
132
+ : !skillsResult
133
+ ? "skills"
134
+ : "metrics";
135
+ warn(`[${harnessName}] ${failedStep} step failed: ${failedError} — recording partial state`);
136
+ }
137
+ perHarnessResults.push({
138
+ harnessName,
139
+ profile,
140
+ status: failedStep ? "failed" : "ok",
141
+ error: failedError,
142
+ mcpResult,
143
+ agentsResult,
144
+ commandsResult,
145
+ skillsResult,
146
+ metricsResult,
147
+ partial: failedStep,
148
+ });
149
+ console.log();
150
+ }
151
+ // === Once-per-run: AFTER the per-harness loop ===
152
+ // Global @uluops/cli install — single prompt, single install across the
153
+ // whole run regardless of harness count.
154
+ const cliResult = await configureCliStep({
155
+ withCli: opts.withCli,
156
+ cli: opts.cli,
157
+ yes: opts.yes,
158
+ apiKey: opts.apiKey,
159
+ dryRun: opts.dryRun,
160
+ });
161
+ // Agent-metrics CLI install — gated on AT LEAST ONE harness having
162
+ // successfully configured the hook. Aggregate gate replaces the
163
+ // per-iteration gate from the single-harness version. Without this,
164
+ // a multi-harness run where only one harness supports hooks (e.g.,
165
+ // Claude Code among Codex/Gemini siblings) would never prompt for the
166
+ // agent-metrics CLI (spec §7.6.2 aggregation).
167
+ const anyHookConfigured = perHarnessResults.some((r) => r.status === "ok" && r.metricsResult?.hookConfigured);
168
+ const agentMetricsCliResult = anyHookConfigured
169
+ ? await configureAgentMetricsCliStep({
170
+ withAgentMetricsCli: opts.withAgentMetricsCli,
171
+ agentMetricsCli: opts.agentMetricsCli,
172
+ yes: opts.yes,
173
+ apiKey: opts.apiKey,
174
+ dryRun: opts.dryRun,
175
+ })
176
+ : null;
177
+ await runHealthCheck(opts);
178
+ const shellModified = await configureShell(env, apiKey, opts);
179
+ // Save manifest ONCE at end, aggregating every successful or partial
180
+ // harness entry. Declined harnesses and pre-MCP failures land no entry.
181
+ if (!opts.dryRun) {
182
+ const now = new Date().toISOString();
183
+ const manifest = existingManifest ?? {
184
+ version,
185
+ installedAt: now,
186
+ shellModified: false,
187
+ harnesses: {},
188
+ };
189
+ manifest.version = version;
190
+ manifest.installedAt = now;
191
+ manifest.shellModified = shellModified || manifest.shellModified;
192
+ for (const r of perHarnessResults) {
193
+ if (!r.mcpResult)
194
+ continue; // no MCP success → no entry
195
+ const harnessEntry = {
196
+ installedAt: now,
197
+ setupVersion: version,
198
+ mcpScope: opts.scope,
199
+ mcpConfigPath: r.mcpResult.configPath,
200
+ defsScope: opts.localDefs ? "local" : "global",
201
+ defsPath: opts.localDefs
202
+ ? join(await findProjectRoot(), "uluops")
203
+ : r.profile.paths.home,
204
+ agents: r.agentsResult?.files ?? [],
205
+ commands: r.commandsResult?.files ?? [],
206
+ skills: r.skillsResult?.files ?? [],
207
+ hooksInstalled: r.metricsResult?.hookConfigured ?? false,
208
+ hooksInstalledVersion: r.metricsResult?.hooksInstalledVersion ?? null,
209
+ partial: r.partial ?? null,
210
+ };
211
+ manifest.harnesses[r.harnessName] = harnessEntry;
212
+ }
213
+ // Same ownership rule as before — only manifest a global install we
214
+ // performed ourselves (not user-installed).
215
+ if (cliResult && cliResult.installed && !cliResult.alreadyPresent) {
216
+ manifest.cliInstalled = true;
217
+ manifest.cliInstalledVersion = cliResult.version;
218
+ }
219
+ if (agentMetricsCliResult &&
220
+ agentMetricsCliResult.installed &&
221
+ !agentMetricsCliResult.alreadyPresent) {
222
+ manifest.agentMetricsCliInstalled = true;
223
+ manifest.agentMetricsCliInstalledVersion =
224
+ agentMetricsCliResult.version;
225
+ }
226
+ // Only save the manifest if it has at least one harness entry — the
227
+ // isNewManifest loader rejects empty-harnesses manifests, and saving
228
+ // one would write an unloadable file on every all-declined run.
229
+ if (Object.keys(manifest.harnesses).length > 0) {
230
+ await saveManifest(manifest);
231
+ }
232
+ }
233
+ // === Summary ===
234
+ // Single rendering call producing the full multi-harness block with
235
+ // per-harness status icons, partial markers, re-run hints, and the
236
+ // aggregate counts in the header. Single-harness path preserves
237
+ // today's Setup-complete banner format inside the same function.
238
+ await printSetupSummary({
239
+ results: perHarnessResults,
240
+ apiKey,
241
+ });
242
+ // Exit-code classifier (spec §7.5 4-tier table). One call, one place.
243
+ // Empty perHarnessResults already short-circuited above with the
244
+ // "nothing to install" message; classifyExit handles defense-in-depth.
245
+ const exitCode = classifyExit(perHarnessResults);
246
+ if (exitCode !== 0) {
247
+ process.exit(exitCode);
248
+ }
249
+ }
250
+ finally {
251
+ if (lock)
252
+ await lock.release();
86
253
  }
87
- await printSetupSummary({
88
- profile,
89
- agentCount: agentsResult.files.length,
90
- commandCount: commandsResult.files.length,
91
- apiKey,
92
- });
93
254
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Filter parser for `runUninstall`. Mirrors the install-side selection
3
+ * syntax (`--harness all`, comma-split, `--all-detected` synonym, conflict
4
+ * detection) but resolves against the manifest's recorded harnesses
5
+ * rather than detection — uninstall only acts on what's actually
6
+ * installed.
7
+ *
8
+ * Returns the resolved subset of harness names to uninstall, or `null`
9
+ * meaning "every harness in the manifest" (the no-filter default —
10
+ * today's behavior preserved).
11
+ *
12
+ * Throws on:
13
+ * - flag conflict: --harness <name> + --all-detected
14
+ * - unknown harness in filter: every named harness must exist in the
15
+ * manifest, otherwise the user typed something that won't do what
16
+ * they expect (fail fast, don't pretend to uninstall a no-op)
17
+ */
18
+ export declare class UninstallFilterError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ export interface UninstallFilterInput {
22
+ /** Value of --harness (may be undefined when not passed). */
23
+ harnessArg?: string;
24
+ /** Whether --harness was actually passed on the CLI (not just defaulted). */
25
+ harnessFromCli: boolean;
26
+ /** Whether --all-detected was passed. */
27
+ allDetected: boolean;
28
+ /** Names of harnesses actually present in the on-disk manifest. */
29
+ manifestHarnesses: string[];
30
+ }
31
+ /**
32
+ * @returns string[] — subset of manifestHarnesses to uninstall
33
+ * @returns null — uninstall every harness in the manifest (today's
34
+ * default behavior; preserved for back-compat)
35
+ */
36
+ export declare function resolveUninstallFilter(input: UninstallFilterInput): string[] | null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Filter parser for `runUninstall`. Mirrors the install-side selection
3
+ * syntax (`--harness all`, comma-split, `--all-detected` synonym, conflict
4
+ * detection) but resolves against the manifest's recorded harnesses
5
+ * rather than detection — uninstall only acts on what's actually
6
+ * installed.
7
+ *
8
+ * Returns the resolved subset of harness names to uninstall, or `null`
9
+ * meaning "every harness in the manifest" (the no-filter default —
10
+ * today's behavior preserved).
11
+ *
12
+ * Throws on:
13
+ * - flag conflict: --harness <name> + --all-detected
14
+ * - unknown harness in filter: every named harness must exist in the
15
+ * manifest, otherwise the user typed something that won't do what
16
+ * they expect (fail fast, don't pretend to uninstall a no-op)
17
+ */
18
+ export class UninstallFilterError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "UninstallFilterError";
22
+ }
23
+ }
24
+ /**
25
+ * @returns string[] — subset of manifestHarnesses to uninstall
26
+ * @returns null — uninstall every harness in the manifest (today's
27
+ * default behavior; preserved for back-compat)
28
+ */
29
+ export function resolveUninstallFilter(input) {
30
+ // Conflict: --all-detected with --harness <specific-name> is ambiguous.
31
+ // The 'all' literal is exempt because both flags mean the same thing
32
+ // there (uninstall everything).
33
+ if (input.allDetected &&
34
+ input.harnessFromCli &&
35
+ input.harnessArg &&
36
+ input.harnessArg.trim() !== "all") {
37
+ throw new UninstallFilterError(`--harness ${input.harnessArg} conflicts with --all-detected; pick one`);
38
+ }
39
+ // --all-detected (with or without --harness all) → no filter, uninstall all.
40
+ if (input.allDetected)
41
+ return null;
42
+ // No --harness passed → no filter, uninstall all (today's behavior).
43
+ if (!input.harnessFromCli || !input.harnessArg)
44
+ return null;
45
+ // --harness all → no filter, uninstall all.
46
+ const trimmed = input.harnessArg.trim();
47
+ if (trimmed === "all")
48
+ return null;
49
+ // Otherwise parse comma-split subset.
50
+ const requested = trimmed
51
+ .split(",")
52
+ .map((s) => s.trim())
53
+ .filter((s) => s.length > 0);
54
+ if (requested.length === 0)
55
+ return null; // pathological — treat as no filter
56
+ // Validate every requested name exists in the manifest. A typo or a
57
+ // harness the user never installed should fail fast — silently
58
+ // skipping would let `--uninstall --harness claude-coed` succeed with
59
+ // zero work and the user thinking they uninstalled something.
60
+ const unknown = requested.filter((n) => !input.manifestHarnesses.includes(n));
61
+ if (unknown.length > 0) {
62
+ const listed = input.manifestHarnesses.length > 0
63
+ ? input.manifestHarnesses.join(", ")
64
+ : "(none)";
65
+ throw new UninstallFilterError(`Unknown harness in --harness filter: ${unknown.join(", ")}. ` +
66
+ `Manifest contains: ${listed}`);
67
+ }
68
+ return requested;
69
+ }
@@ -1,3 +1,13 @@
1
- export declare function runUninstall(opts: {
1
+ export interface RunUninstallOpts {
2
2
  dryRun: boolean;
3
- }): Promise<void>;
3
+ /**
4
+ * Value of --harness (may be 'all', comma-split list, or a single name).
5
+ * Undefined when --harness wasn't passed.
6
+ */
7
+ harnessArg?: string;
8
+ /** Whether --harness was explicitly passed (vs. its commander default). */
9
+ harnessFromCli?: boolean;
10
+ /** Whether --all-detected was passed. */
11
+ allDetected?: boolean;
12
+ }
13
+ export declare function runUninstall(opts: RunUninstallOpts): Promise<void>;