@uluops/setup 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +91 -9
  2. package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
  3. package/dist/cli/select-harnesses.d.ts +91 -0
  4. package/dist/cli/select-harnesses.js +108 -0
  5. package/dist/cli.js +78 -37
  6. package/dist/commands/errors.d.ts +24 -0
  7. package/dist/commands/errors.js +28 -0
  8. package/dist/commands/helpers.d.ts +7 -0
  9. package/dist/commands/helpers.js +80 -3
  10. package/dist/commands/per-harness.d.ts +64 -0
  11. package/dist/commands/per-harness.js +37 -0
  12. package/dist/commands/setup.d.ts +5 -3
  13. package/dist/commands/setup.js +174 -48
  14. package/dist/commands/uninstall-filter.d.ts +36 -0
  15. package/dist/commands/uninstall-filter.js +69 -0
  16. package/dist/commands/uninstall.d.ts +12 -2
  17. package/dist/commands/uninstall.js +121 -45
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +89 -21
  20. package/dist/harnesses/index.js +6 -1
  21. package/dist/harnesses/opencode.d.ts +8 -0
  22. package/dist/harnesses/opencode.js +24 -1
  23. package/dist/harnesses/types.d.ts +2 -0
  24. package/dist/harnesses/types.js +5 -1
  25. package/dist/lib/atomic-write.js +10 -2
  26. package/dist/lib/config-merger.d.ts +6 -3
  27. package/dist/lib/config-merger.js +50 -7
  28. package/dist/lib/display.d.ts +21 -5
  29. package/dist/lib/display.js +118 -13
  30. package/dist/lib/file-ops.d.ts +13 -5
  31. package/dist/lib/file-ops.js +34 -34
  32. package/dist/lib/install-lock.js +11 -1
  33. package/dist/lib/json-guards.d.ts +22 -0
  34. package/dist/lib/json-guards.js +33 -0
  35. package/dist/lib/manifest.d.ts +20 -0
  36. package/dist/lib/manifest.js +61 -12
  37. package/dist/lib/paths.d.ts +0 -17
  38. package/dist/lib/paths.js +0 -19
  39. package/dist/lib/settings-merger.js +3 -1
  40. package/dist/steps/agent-metrics-cli.d.ts +9 -1
  41. package/dist/steps/agent-metrics-cli.js +66 -20
  42. package/dist/steps/agents.d.ts +11 -0
  43. package/dist/steps/agents.js +30 -25
  44. package/dist/steps/auth.d.ts +13 -0
  45. package/dist/steps/auth.js +61 -5
  46. package/dist/steps/cli.d.ts +6 -0
  47. package/dist/steps/cli.js +29 -10
  48. package/dist/steps/commands.d.ts +10 -0
  49. package/dist/steps/commands.js +31 -30
  50. package/dist/steps/detect.js +15 -1
  51. package/dist/steps/mcp.js +1 -8
  52. package/dist/steps/metrics.js +10 -3
  53. package/dist/steps/shell.js +3 -13
  54. package/dist/steps/signup.js +14 -1
  55. package/dist/steps/skills.d.ts +14 -0
  56. package/dist/steps/skills.js +95 -0
  57. package/dist/steps/verify.js +195 -91
  58. package/package.json +3 -2
@@ -2,53 +2,155 @@ 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
8
  import { acquireInstallLock, } from "../lib/install-lock.js";
9
- import { initContext, checkConflicts, configureMcpStep, installAgentsDefs, installCommandsDefs, configureMetricsStep, configureCliStep, configureAgentMetricsCliStep, runHealthCheck, configureShell, } from "./helpers.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";
10
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
+ }
11
17
  const version = await getVersion();
12
- 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));
13
22
  console.log();
14
23
  console.log(` ${chalk.dim("⟨u⟩")} ${chalk.cyan.bold("ulu")}${chalk.bold("·ops")}`);
15
24
  console.log(` ${chalk.dim("operating intelligence as infrastructure")}`);
16
25
  console.log();
17
- 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)}`);
18
30
  console.log();
19
31
  if (opts.dryRun) {
20
32
  info(chalk.dim("(dry run — no changes will be made)\n"));
21
33
  }
34
+ // === Once-per-run: BEFORE the per-harness loop ===
22
35
  const { env, apiKey } = await initContext(opts);
23
36
  console.log();
24
37
  // Acquire the install lock before touching any shared state. Skipped on
25
- // dry-run (read-only). The lock excludes a second concurrent `uluops-setup`
38
+ // dry-run (read-only). The lock excludes a second concurrent uluops-setup
26
39
  // from racing the manifest / MCP config / shell-profile / settings.json
27
- // read-merge-write windows. Released in `finally` below.
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).
28
43
  let lock = null;
29
44
  if (!opts.dryRun) {
30
45
  lock = await acquireInstallLock();
31
46
  }
32
47
  try {
33
- // Load existing manifest for update detection
34
48
  const existingManifest = await loadManifest();
35
- const existingHarness = existingManifest?.harnesses[profile.name];
36
49
  if (existingManifest && existingManifest.version !== version) {
37
50
  info(`Updating ${chalk.dim(existingManifest.version)} → ${chalk.green(version)}`);
38
51
  console.log();
39
52
  }
40
- else if (existingHarness) {
41
- info(chalk.dim(`Already at v${version} — checking for changes`));
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
+ });
42
149
  console.log();
43
150
  }
44
- // Check for conflicts on first install for this harness
45
- if (!existingHarness && !opts.yes && !opts.dryRun) {
46
- await checkConflicts(profile, opts.localDefs);
47
- }
48
- const mcpResult = await configureMcpStep(profile, apiKey, opts);
49
- const agentsResult = await installAgentsDefs(profile, opts, existingHarness?.agents);
50
- const commandsResult = await installCommandsDefs(profile, opts, existingHarness?.commands);
51
- const metricsResult = await configureMetricsStep(profile, opts);
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.
52
154
  const cliResult = await configureCliStep({
53
155
  withCli: opts.withCli,
54
156
  cli: opts.cli,
@@ -56,10 +158,14 @@ export async function runSetup(opts) {
56
158
  apiKey: opts.apiKey,
57
159
  dryRun: opts.dryRun,
58
160
  });
59
- // Only offer the agent-metrics CLI when the hook itself got configured —
60
- // the CLI's purpose is reading captures the hook produces, so without the
61
- // hook there's nothing for the CLI to surface.
62
- const agentMetricsCliResult = metricsResult.hookConfigured
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
63
169
  ? await configureAgentMetricsCliStep({
64
170
  withAgentMetricsCli: opts.withAgentMetricsCli,
65
171
  agentMetricsCli: opts.agentMetricsCli,
@@ -70,23 +176,10 @@ export async function runSetup(opts) {
70
176
  : null;
71
177
  await runHealthCheck(opts);
72
178
  const shellModified = await configureShell(env, apiKey, opts);
73
- // Save manifest
179
+ // Save manifest ONCE at end, aggregating every successful or partial
180
+ // harness entry. Declined harnesses and pre-MCP failures land no entry.
74
181
  if (!opts.dryRun) {
75
182
  const now = new Date().toISOString();
76
- const harnessEntry = {
77
- installedAt: now,
78
- setupVersion: version,
79
- mcpScope: opts.scope,
80
- mcpConfigPath: mcpResult.configPath,
81
- defsScope: opts.localDefs ? "local" : "global",
82
- defsPath: opts.localDefs
83
- ? join(await findProjectRoot(), "uluops")
84
- : profile.paths.home,
85
- agents: agentsResult.files,
86
- commands: commandsResult.files,
87
- hooksInstalled: metricsResult.hookConfigured,
88
- hooksInstalledVersion: metricsResult.hooksInstalledVersion,
89
- };
90
183
  const manifest = existingManifest ?? {
91
184
  version,
92
185
  installedAt: now,
@@ -96,30 +189,63 @@ export async function runSetup(opts) {
96
189
  manifest.version = version;
97
190
  manifest.installedAt = now;
98
191
  manifest.shellModified = shellModified || manifest.shellModified;
99
- manifest.harnesses[profile.name] = harnessEntry;
100
- // Only flip cliInstalled to true when WE installed it (not when user-installed).
101
- // Once true, persist across re-runs so uninstall remains symmetric — until the
102
- // user explicitly removes it with --no-cli + uninstall, this manifest owns it.
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).
103
215
  if (cliResult && cliResult.installed && !cliResult.alreadyPresent) {
104
216
  manifest.cliInstalled = true;
105
217
  manifest.cliInstalledVersion = cliResult.version;
106
218
  }
107
- // Same ownership rule for agent-metrics CLI — only manifest a global install
108
- // we performed ourselves.
109
219
  if (agentMetricsCliResult &&
110
220
  agentMetricsCliResult.installed &&
111
221
  !agentMetricsCliResult.alreadyPresent) {
112
222
  manifest.agentMetricsCliInstalled = true;
113
- manifest.agentMetricsCliInstalledVersion = agentMetricsCliResult.version;
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);
114
231
  }
115
- await saveManifest(manifest);
116
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.
117
238
  await printSetupSummary({
118
- profile,
119
- agentCount: agentsResult.files.length,
120
- commandCount: commandsResult.files.length,
239
+ results: perHarnessResults,
121
240
  apiKey,
122
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
+ }
123
249
  }
124
250
  finally {
125
251
  if (lock)
@@ -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>;
@@ -1,9 +1,10 @@
1
1
  import chalk from "chalk";
2
- import { loadManifest, deleteManifest, validateManifest, } from "../lib/manifest.js";
2
+ import { loadManifest, saveManifest, deleteManifest, validateManifest, } from "../lib/manifest.js";
3
3
  import { acquireInstallLock, } from "../lib/install-lock.js";
4
4
  import { uninstallMcp } from "../steps/mcp.js";
5
5
  import { uninstallAgents } from "../steps/agents.js";
6
6
  import { uninstallCommands } from "../steps/commands.js";
7
+ import { uninstallSkills } from "../steps/skills.js";
7
8
  import { removeShellExport } from "../steps/shell.js";
8
9
  import { uninstallMetrics } from "../steps/metrics.js";
9
10
  import { uninstallCli, CLI_PACKAGE } from "../steps/cli.js";
@@ -11,6 +12,7 @@ import { uninstallAgentMetricsCli, AGENT_METRICS_PACKAGE, } from "../steps/agent
11
12
  import { ok, warn, fail, info } from "../lib/display.js";
12
13
  import { getVersion } from "../lib/version.js";
13
14
  import { getProfile } from "../harnesses/index.js";
15
+ import { resolveUninstallFilter, UninstallFilterError, } from "./uninstall-filter.js";
14
16
  export async function runUninstall(opts) {
15
17
  const version = await getVersion();
16
18
  console.log();
@@ -45,7 +47,40 @@ export async function runUninstall(opts) {
45
47
  warn(w);
46
48
  console.log();
47
49
  }
48
- for (const [harnessName, hm] of Object.entries(manifest.harnesses)) {
50
+ // Resolve the subset of harnesses to uninstall. `null` means "all
51
+ // harnesses in the manifest" (today's no-filter behavior). String[]
52
+ // means "only these — leave the rest of the manifest intact".
53
+ const manifestHarnesses = Object.keys(manifest.harnesses);
54
+ let filter;
55
+ try {
56
+ filter = resolveUninstallFilter({
57
+ harnessArg: opts.harnessArg,
58
+ harnessFromCli: opts.harnessFromCli ?? false,
59
+ allDetected: opts.allDetected ?? false,
60
+ manifestHarnesses,
61
+ });
62
+ }
63
+ catch (err) {
64
+ if (err instanceof UninstallFilterError) {
65
+ fail(err.message);
66
+ console.log();
67
+ process.exit(1);
68
+ }
69
+ throw err;
70
+ }
71
+ const toUninstall = filter ?? manifestHarnesses;
72
+ const isFullUninstall = toUninstall.length === manifestHarnesses.length;
73
+ if (!isFullUninstall) {
74
+ info(chalk.dim(`Uninstalling subset (${toUninstall.length} of ${manifestHarnesses.length}): ${toUninstall.join(", ")}`));
75
+ console.log();
76
+ }
77
+ for (const harnessName of toUninstall) {
78
+ const hm = manifest.harnesses[harnessName];
79
+ if (!hm) {
80
+ // Defense — filter already validated, but keep the guard so a
81
+ // mid-run manifest mutation doesn't crash.
82
+ continue;
83
+ }
49
84
  let profile;
50
85
  try {
51
86
  profile = getProfile(harnessName);
@@ -71,6 +106,15 @@ export async function runUninstall(opts) {
71
106
  ok(`Would remove ${hm.commands.length} command(s)`);
72
107
  }
73
108
  }
109
+ if ((hm.skills?.length ?? 0) > 0) {
110
+ if (!opts.dryRun) {
111
+ const skillCount = await uninstallSkills(hm.skills ?? [], hm.defsPath);
112
+ ok(`Removed ${skillCount} skill file(s)`);
113
+ }
114
+ else {
115
+ ok(`Would remove ${hm.skills?.length ?? 0} skill file(s)`);
116
+ }
117
+ }
74
118
  if (!opts.dryRun) {
75
119
  try {
76
120
  await uninstallMcp(profile, hm.mcpConfigPath);
@@ -94,62 +138,94 @@ export async function runUninstall(opts) {
94
138
  }
95
139
  console.log();
96
140
  }
97
- // Remove global @uluops/cli if WE installed it (per manifest).
98
- // We never auto-remove a user-installed CLI — manifest.cliInstalled gates this.
99
- if (manifest.cliInstalled) {
100
- const res = await uninstallCli({ dryRun: opts.dryRun });
101
- if (opts.dryRun) {
102
- ok(`Would remove ${CLI_PACKAGE} (global)`);
141
+ // Globals (CLI, agent-metrics CLI, shell export) are only removed on
142
+ // a FULL uninstall — they're shared infrastructure that may serve
143
+ // remaining harnesses or other tools after a subset uninstall.
144
+ if (isFullUninstall) {
145
+ if (manifest.cliInstalled) {
146
+ const res = await uninstallCli({ dryRun: opts.dryRun });
147
+ if (opts.dryRun) {
148
+ ok(`Would remove ${CLI_PACKAGE} (global)`);
149
+ }
150
+ else if (res.removed) {
151
+ ok(`Removed ${CLI_PACKAGE} (global)`);
152
+ }
153
+ else {
154
+ warn(`Could not remove ${CLI_PACKAGE} (global) — try \`npm uninstall -g ${CLI_PACKAGE}\` manually`);
155
+ if (res.error) {
156
+ const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
157
+ if (oneLine)
158
+ info(` ${oneLine}`);
159
+ }
160
+ }
103
161
  }
104
- else if (res.removed) {
105
- ok(`Removed ${CLI_PACKAGE} (global)`);
162
+ if (manifest.agentMetricsCliInstalled) {
163
+ const res = await uninstallAgentMetricsCli({ dryRun: opts.dryRun });
164
+ if (opts.dryRun) {
165
+ ok(`Would remove ${AGENT_METRICS_PACKAGE} (global)`);
166
+ }
167
+ else if (res.removed) {
168
+ ok(`Removed ${AGENT_METRICS_PACKAGE} (global)`);
169
+ }
170
+ else {
171
+ warn(`Could not remove ${AGENT_METRICS_PACKAGE} (global) — try \`npm uninstall -g ${AGENT_METRICS_PACKAGE}\` manually`);
172
+ if (res.error) {
173
+ const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
174
+ if (oneLine)
175
+ info(` ${oneLine}`);
176
+ }
177
+ }
106
178
  }
107
- else {
108
- warn(`Could not remove ${CLI_PACKAGE} (global) — try \`npm uninstall -g ${CLI_PACKAGE}\` manually`);
109
- if (res.error) {
110
- const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
111
- if (oneLine)
112
- info(` ${oneLine}`);
179
+ if (manifest.shellModified) {
180
+ const { getShellProfile } = await import("../lib/paths.js");
181
+ const shellProfile = getShellProfile();
182
+ if (shellProfile && !opts.dryRun) {
183
+ await removeShellExport(shellProfile.path);
184
+ ok(`Removed export from ${shellProfile.path}`);
185
+ }
186
+ else if (shellProfile) {
187
+ ok(`Would remove export from ${shellProfile.path}`);
113
188
  }
114
189
  }
115
190
  }
116
- // Same ownership rule for the agent-metrics CLI.
117
- if (manifest.agentMetricsCliInstalled) {
118
- const res = await uninstallAgentMetricsCli({ dryRun: opts.dryRun });
119
- if (opts.dryRun) {
120
- ok(`Would remove ${AGENT_METRICS_PACKAGE} (global)`);
121
- }
122
- else if (res.removed) {
123
- ok(`Removed ${AGENT_METRICS_PACKAGE} (global)`);
191
+ else {
192
+ info(chalk.dim(`Preserving global @uluops/cli, @uluops/agent-metrics, and shell export — subset uninstall does not touch shared infrastructure`));
193
+ console.log();
194
+ }
195
+ // Manifest update / delete logic:
196
+ // full uninstall → delete the manifest entirely (today's behavior)
197
+ // subset uninstall, entries remain → save the trimmed manifest
198
+ // subset uninstall, no entries remain → delete (manifest loader
199
+ // would reject an empty-harnesses file)
200
+ if (!opts.dryRun) {
201
+ if (isFullUninstall) {
202
+ await deleteManifest();
203
+ ok("Manifest deleted");
124
204
  }
125
205
  else {
126
- warn(`Could not remove ${AGENT_METRICS_PACKAGE} (global) — try \`npm uninstall -g ${AGENT_METRICS_PACKAGE}\` manually`);
127
- if (res.error) {
128
- const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
129
- if (oneLine)
130
- info(` ${oneLine}`);
206
+ for (const name of toUninstall) {
207
+ delete manifest.harnesses[name];
208
+ }
209
+ const remaining = Object.keys(manifest.harnesses).length;
210
+ if (remaining > 0) {
211
+ await saveManifest(manifest);
212
+ ok(`Manifest updated — ${remaining} harness(es) remain: ${Object.keys(manifest.harnesses).join(", ")}`);
213
+ }
214
+ else {
215
+ await deleteManifest();
216
+ ok("Manifest deleted (no harnesses remain)");
131
217
  }
132
218
  }
133
219
  }
134
- // Remove shell export
135
- if (manifest.shellModified) {
136
- const { getShellProfile } = await import("../lib/paths.js");
137
- const shellProfile = getShellProfile();
138
- if (shellProfile && !opts.dryRun) {
139
- await removeShellExport(shellProfile.path);
140
- ok(`Removed export from ${shellProfile.path}`);
141
- }
142
- else if (shellProfile) {
143
- ok(`Would remove export from ${shellProfile.path}`);
144
- }
220
+ console.log();
221
+ if (isFullUninstall) {
222
+ info("UluOps has been removed. Restart your harness to complete.");
145
223
  }
146
- if (!opts.dryRun) {
147
- await deleteManifest();
148
- ok("Manifest deleted");
224
+ else {
225
+ const removed = toUninstall.join(", ");
226
+ info(`Removed UluOps from ${removed}. Restart the affected harness(es) to complete.`);
149
227
  }
150
228
  console.log();
151
- info("UluOps has been removed. Restart your harness to complete.");
152
- console.log();
153
229
  }
154
230
  finally {
155
231
  if (lock)
@@ -1,15 +1,10 @@
1
1
  /**
2
- * Codex Harness Profile (Scaffold)
2
+ * Codex Harness Profile
3
3
  *
4
- * Paths and metadata are verified from vendor docs.
5
- * Codex uses TOML config with `mcp_servers` key (nested tables).
4
+ * Codex uses TOML config with `mcp_servers` nested tables.
6
5
  * Agent definitions are TOML, not markdown.
7
- * Skills use a different path ($HOME/.agents/skills/) than agents (~/.codex/agents/).
8
- *
9
- * NOT YET TESTED with UluOps agents. McpConfigStrategy throws
10
- * until integration testing is complete.
11
- *
12
- * Will require `smol-toml` dependency when fully implemented.
6
+ * Skills live under ~/.codex/skills and are the preferred Codex-native
7
+ * surface for UluOps operator workflows.
13
8
  */
14
- import type { HarnessProfile } from "./types.js";
9
+ import { type HarnessProfile } from "./types.js";
15
10
  export declare const codexProfile: HarnessProfile;