@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
@@ -1,14 +1,18 @@
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
+ import { acquireInstallLock, } from "../lib/install-lock.js";
3
4
  import { uninstallMcp } from "../steps/mcp.js";
4
5
  import { uninstallAgents } from "../steps/agents.js";
5
6
  import { uninstallCommands } from "../steps/commands.js";
7
+ import { uninstallSkills } from "../steps/skills.js";
6
8
  import { removeShellExport } from "../steps/shell.js";
7
9
  import { uninstallMetrics } from "../steps/metrics.js";
8
10
  import { uninstallCli, CLI_PACKAGE } from "../steps/cli.js";
11
+ import { uninstallAgentMetricsCli, AGENT_METRICS_PACKAGE, } from "../steps/agent-metrics-cli.js";
9
12
  import { ok, warn, fail, info } from "../lib/display.js";
10
13
  import { getVersion } from "../lib/version.js";
11
14
  import { getProfile } from "../harnesses/index.js";
15
+ import { resolveUninstallFilter, UninstallFilterError, } from "./uninstall-filter.js";
12
16
  export async function runUninstall(opts) {
13
17
  const version = await getVersion();
14
18
  console.log();
@@ -17,110 +21,214 @@ export async function runUninstall(opts) {
17
21
  if (opts.dryRun) {
18
22
  info(chalk.dim("(dry run — no changes will be made)\n"));
19
23
  }
20
- const manifest = await loadManifest();
21
- if (!manifest) {
22
- warn("No manifest found — nothing to uninstall.");
23
- return;
24
- }
25
- const validation = await validateManifest(manifest);
26
- if (!validation.valid) {
27
- fail("Manifest references paths that no longer exist:");
28
- for (const err of validation.errors)
29
- info(` ${err}`);
30
- console.log();
31
- info("Uninstall may be incomplete. Proceeding with what's available.");
32
- console.log();
33
- }
34
- if (validation.warnings.length > 0) {
35
- for (const w of validation.warnings)
36
- warn(w);
37
- console.log();
24
+ // Same lock as runSetup — concurrent setup+uninstall would race the same
25
+ // shared state. Skipped on dry-run (read-only).
26
+ let lock = null;
27
+ if (!opts.dryRun) {
28
+ lock = await acquireInstallLock();
38
29
  }
39
- for (const [harnessName, hm] of Object.entries(manifest.harnesses)) {
40
- let profile;
41
- try {
42
- profile = getProfile(harnessName);
30
+ try {
31
+ const manifest = await loadManifest();
32
+ if (!manifest) {
33
+ warn("No manifest found — nothing to uninstall.");
34
+ return;
43
35
  }
44
- catch {
45
- warn(`Unknown harness "${harnessName}" in manifest — skipping`);
46
- continue;
36
+ const validation = await validateManifest(manifest);
37
+ if (!validation.valid) {
38
+ fail("Manifest references paths that no longer exist:");
39
+ for (const err of validation.errors)
40
+ info(` ${err}`);
41
+ console.log();
42
+ info("Uninstall may be incomplete. Proceeding with what's available.");
43
+ console.log();
47
44
  }
48
- info(chalk.bold(profile.displayName));
49
- if (!opts.dryRun) {
50
- const agentCount = await uninstallAgents(hm.agents, hm.defsPath);
51
- ok(`Removed ${agentCount} agent(s)`);
45
+ if (validation.warnings.length > 0) {
46
+ for (const w of validation.warnings)
47
+ warn(w);
48
+ console.log();
52
49
  }
53
- else {
54
- ok(`Would remove ${hm.agents.length} agent(s)`);
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
+ });
55
62
  }
56
- if (hm.commands.length > 0) {
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
+ }
84
+ let profile;
85
+ try {
86
+ profile = getProfile(harnessName);
87
+ }
88
+ catch {
89
+ warn(`Unknown harness "${harnessName}" in manifest — skipping`);
90
+ continue;
91
+ }
92
+ info(chalk.bold(profile.displayName));
57
93
  if (!opts.dryRun) {
58
- const cmdCount = await uninstallCommands(hm.commands, hm.defsPath);
59
- ok(`Removed ${cmdCount} command(s)`);
94
+ const agentCount = await uninstallAgents(hm.agents, hm.defsPath);
95
+ ok(`Removed ${agentCount} agent(s)`);
60
96
  }
61
97
  else {
62
- ok(`Would remove ${hm.commands.length} command(s)`);
98
+ ok(`Would remove ${hm.agents.length} agent(s)`);
99
+ }
100
+ if (hm.commands.length > 0) {
101
+ if (!opts.dryRun) {
102
+ const cmdCount = await uninstallCommands(hm.commands, hm.defsPath);
103
+ ok(`Removed ${cmdCount} command(s)`);
104
+ }
105
+ else {
106
+ ok(`Would remove ${hm.commands.length} command(s)`);
107
+ }
63
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
+ }
118
+ if (!opts.dryRun) {
119
+ try {
120
+ await uninstallMcp(profile, hm.mcpConfigPath);
121
+ ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
122
+ }
123
+ catch {
124
+ warn(`Could not remove MCP servers from ${hm.mcpConfigPath}`);
125
+ }
126
+ }
127
+ else {
128
+ ok(`Would remove MCP servers from ${hm.mcpConfigPath}`);
129
+ }
130
+ if (hm.hooksInstalled) {
131
+ if (!opts.dryRun) {
132
+ await uninstallMetrics(profile, false);
133
+ ok("Removed agent-metrics hook and tool files");
134
+ }
135
+ else {
136
+ ok("Would remove agent-metrics hook and tool files");
137
+ }
138
+ }
139
+ console.log();
64
140
  }
65
- if (!opts.dryRun) {
66
- try {
67
- await uninstallMcp(profile, hm.mcpConfigPath);
68
- ok(`Removed MCP servers from ${hm.mcpConfigPath}`);
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
+ }
69
161
  }
70
- catch {
71
- warn(`Could not remove MCP servers from ${hm.mcpConfigPath}`);
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
+ }
178
+ }
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}`);
188
+ }
72
189
  }
73
190
  }
74
191
  else {
75
- ok(`Would remove MCP servers from ${hm.mcpConfigPath}`);
192
+ info(chalk.dim(`Preserving global @uluops/cli, @uluops/agent-metrics, and shell export — subset uninstall does not touch shared infrastructure`));
193
+ console.log();
76
194
  }
77
- if (hm.hooksInstalled) {
78
- if (!opts.dryRun) {
79
- await uninstallMetrics(profile, false);
80
- ok("Removed agent-metrics hook and tool files");
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");
81
204
  }
82
205
  else {
83
- ok("Would remove agent-metrics hook and tool files");
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)");
217
+ }
84
218
  }
85
219
  }
86
220
  console.log();
87
- }
88
- // Remove global @uluops/cli if WE installed it (per manifest).
89
- // We never auto-remove a user-installed CLI — manifest.cliInstalled gates this.
90
- if (manifest.cliInstalled) {
91
- const res = await uninstallCli({ dryRun: opts.dryRun });
92
- if (opts.dryRun) {
93
- ok(`Would remove ${CLI_PACKAGE} (global)`);
94
- }
95
- else if (res.removed) {
96
- ok(`Removed ${CLI_PACKAGE} (global)`);
221
+ if (isFullUninstall) {
222
+ info("UluOps has been removed. Restart your harness to complete.");
97
223
  }
98
224
  else {
99
- warn(`Could not remove ${CLI_PACKAGE} (global) — try \`npm uninstall -g ${CLI_PACKAGE}\` manually`);
100
- if (res.error) {
101
- const oneLine = res.error.split("\n")[0]?.slice(0, 120) ?? "";
102
- if (oneLine)
103
- info(` ${oneLine}`);
104
- }
105
- }
106
- }
107
- // Remove shell export
108
- if (manifest.shellModified) {
109
- const { getShellProfile } = await import("../lib/paths.js");
110
- const shellProfile = getShellProfile();
111
- if (shellProfile && !opts.dryRun) {
112
- await removeShellExport(shellProfile.path);
113
- ok(`Removed export from ${shellProfile.path}`);
114
- }
115
- else if (shellProfile) {
116
- ok(`Would remove export from ${shellProfile.path}`);
225
+ const removed = toUninstall.join(", ");
226
+ info(`Removed UluOps from ${removed}. Restart the affected harness(es) to complete.`);
117
227
  }
228
+ console.log();
118
229
  }
119
- if (!opts.dryRun) {
120
- await deleteManifest();
121
- ok("Manifest deleted");
230
+ finally {
231
+ if (lock)
232
+ await lock.release();
122
233
  }
123
- console.log();
124
- info("UluOps has been removed. Restart your harness to complete.");
125
- console.log();
126
234
  }
@@ -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;
@@ -1,34 +1,101 @@
1
1
  /**
2
- * Codex Harness Profile (Scaffold)
2
+ * Codex Harness Profile
3
3
  *
4
- * Paths and metadata are verified from vendor docs.
5
- * Codex uses TOML config with `mcp_servers` key (nested tables).
4
+ * Codex uses TOML config with `mcp_servers` nested tables.
6
5
  * Agent definitions are TOML, not markdown.
7
- * Skills use a different path ($HOME/.agents/skills/) than agents (~/.codex/agents/).
8
- *
9
- * NOT YET TESTED with UluOps agents. McpConfigStrategy throws
10
- * until integration testing is complete.
11
- *
12
- * Will require `smol-toml` dependency when fully implemented.
6
+ * Skills live under ~/.codex/skills and are the preferred Codex-native
7
+ * surface for UluOps operator workflows.
13
8
  */
9
+ import { readFile } from "node:fs/promises";
14
10
  import { homedir } from "node:os";
15
11
  import { join } from "node:path";
16
- import { HarnessNotTestedError } from "./types.js";
12
+ import { ULUOPS_SERVERS, } from "./types.js";
13
+ import { atomicWrite } from "../lib/atomic-write.js";
14
+ const RAW_TOML = "__rawToml";
15
+ function tomlString(value) {
16
+ return JSON.stringify(value);
17
+ }
18
+ function serverBlock(name, pkg, apiKey) {
19
+ return [
20
+ `[mcp_servers.${tomlString(name)}]`,
21
+ `command = "npx"`,
22
+ `args = ["-y", ${tomlString(pkg)}]`,
23
+ ``,
24
+ `[mcp_servers.${tomlString(name)}.env]`,
25
+ `ULUOPS_API_KEY = ${tomlString(apiKey)}`,
26
+ ].join("\n");
27
+ }
28
+ function isServerTableFor(name, table) {
29
+ return table === `mcp_servers.${name}` || table === `mcp_servers."${name}"`;
30
+ }
31
+ function isServerSubtableFor(name, table) {
32
+ const unquotedPrefix = `mcp_servers.${name}.`;
33
+ const quotedPrefix = `mcp_servers."${name}".`;
34
+ return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
35
+ }
36
+ function removeServerConfigBlocks(raw, name) {
37
+ const lines = raw.split("\n");
38
+ const kept = [];
39
+ let skipping = false;
40
+ for (const line of lines) {
41
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
42
+ if (table) {
43
+ skipping = isServerTableFor(name, table) || table === `mcp_servers.${name}.env` || table === `mcp_servers."${name}".env`;
44
+ }
45
+ if (!skipping)
46
+ kept.push(line);
47
+ }
48
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
49
+ }
50
+ function removeServerSubtree(raw, name) {
51
+ const lines = raw.split("\n");
52
+ const kept = [];
53
+ let skipping = false;
54
+ for (const line of lines) {
55
+ const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
56
+ if (table) {
57
+ skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
58
+ }
59
+ if (!skipping)
60
+ kept.push(line);
61
+ }
62
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
63
+ }
17
64
  class CodexMcpConfig {
18
- async read() {
19
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
65
+ async read(path) {
66
+ try {
67
+ return { [RAW_TOML]: await readFile(path, "utf-8") };
68
+ }
69
+ catch {
70
+ return { [RAW_TOML]: "" };
71
+ }
20
72
  }
21
- merge() {
22
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
73
+ merge(config, apiKey) {
74
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
75
+ raw = removeServerConfigBlocks(removeServerConfigBlocks(raw, "uluops-tracker"), "uluops-registry");
76
+ const blocks = [
77
+ serverBlock("uluops-tracker", "@uluops/ops-mcp", apiKey),
78
+ serverBlock("uluops-registry", "@uluops/registry-mcp", apiKey),
79
+ ].join("\n\n");
80
+ return { [RAW_TOML]: [raw.trimEnd(), blocks].filter(Boolean).join("\n\n") + "\n" };
23
81
  }
24
- remove() {
25
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
82
+ remove(config) {
83
+ let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
84
+ for (const name of ULUOPS_SERVERS) {
85
+ raw = removeServerSubtree(raw, name);
86
+ }
87
+ return { [RAW_TOML]: raw.trimEnd() ? `${raw.trimEnd()}\n` : "" };
26
88
  }
27
- async write() {
28
- throw new HarnessNotTestedError("Codex (agent format compatibility under review)");
89
+ async write(path, config) {
90
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
91
+ await atomicWrite(path, raw, { mode: 0o600 });
29
92
  }
30
- check() {
31
- throw new HarnessNotTestedError("Codex (MCP config check not supported)");
93
+ check(config) {
94
+ const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
95
+ return ULUOPS_SERVERS.every((name) => {
96
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
97
+ return new RegExp(String.raw `\[mcp_servers\.(?:"${escaped}"|${escaped})\]`).test(raw);
98
+ });
32
99
  }
33
100
  }
34
101
  const home = join(homedir(), ".codex");
@@ -45,7 +112,8 @@ export const codexProfile = {
45
112
  globalMcpConfig: join(home, "config.toml"),
46
113
  localMcpConfig: ".codex/config.toml",
47
114
  agentsDir: join(home, "agents"),
48
- commandsDir: join(homedir(), ".agents", "skills"),
115
+ commandsDir: join(home, "commands"),
116
+ skillsDir: join(home, "skills"),
49
117
  settingsPath: null,
50
118
  toolsDir: null,
51
119
  },
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync } from "node:fs";
8
8
  import { claudeCodeProfile } from "./claude-code.js";
9
- import { opencodeProfile } from "./opencode.js";
9
+ import { opencodeProfile, assertOpencodeEnvironment } from "./opencode.js";
10
10
  import { geminiCliProfile } from "./gemini-cli.js";
11
11
  import { codexProfile } from "./codex.js";
12
12
  export { ConfigParseError, HarnessNotTestedError, } from "./types.js";
@@ -33,6 +33,11 @@ export function getProfile(name) {
33
33
  const available = ALL_PROFILES.map((p) => p.name).join(", ");
34
34
  throw new Error(`Unknown harness "${name}". Available: ${available}`);
35
35
  }
36
+ // Harness-specific environment validation deferred from module-load to
37
+ // selection time. Keeps `--help` and `--uninstall` usable even when an
38
+ // unrelated harness's env is misconfigured.
39
+ if (resolved === "opencode")
40
+ assertOpencodeEnvironment();
36
41
  return profile;
37
42
  }
38
43
  /**
@@ -11,4 +11,12 @@
11
11
  * Verified working shape from ~/opencode.jsonc (2026-04-30).
12
12
  */
13
13
  import { type HarnessProfile } from "./types.js";
14
+ /**
15
+ * Throws the deferred XDG_CONFIG_HOME validation error, if any. Called by
16
+ * the harness registry when the user actually selects opencode (via
17
+ * `--harness opencode` or auto-detection picking it). Other entry points
18
+ * (`--help`, `--uninstall` of an unrelated harness, `--list`) never trigger
19
+ * this and remain usable.
20
+ */
21
+ export declare function assertOpencodeEnvironment(): void;
14
22
  export declare const opencodeProfile: HarnessProfile;
@@ -106,17 +106,40 @@ class OpenCodeMcpConfig {
106
106
  return ULUOPS_SERVERS.every((name) => name in mcp);
107
107
  }
108
108
  }
109
+ /**
110
+ * XDG_CONFIG_HOME validation is deferred from module-load to first opencode
111
+ * use. The previous design threw at import, which crashed `uluops-setup
112
+ * --help` and `--uninstall` for any user with an invalid XDG_CONFIG_HOME —
113
+ * blocking them from running the very commands they need to recover. Now
114
+ * the IIFE caches any validation error and the harness registry calls
115
+ * `assertOpencodeEnvironment()` only when the opencode profile is actually
116
+ * selected. Unselected, the module loads cleanly and the fallback path is
117
+ * used for shape-only computations.
118
+ */
119
+ let deferredXdgConfigError = null;
109
120
  const xdgConfig = (() => {
110
121
  const env = process.env["XDG_CONFIG_HOME"];
111
122
  if (env) {
112
123
  if (!isAbsolute(env) || env.includes("..")) {
113
- throw new Error(`XDG_CONFIG_HOME must be an absolute path without traversal: ${env}`);
124
+ deferredXdgConfigError = new Error(`XDG_CONFIG_HOME must be an absolute path without traversal: ${env}`);
125
+ return join(homedir(), ".config");
114
126
  }
115
127
  return env;
116
128
  }
117
129
  return join(homedir(), ".config");
118
130
  })();
119
131
  const home = join(xdgConfig, "opencode");
132
+ /**
133
+ * Throws the deferred XDG_CONFIG_HOME validation error, if any. Called by
134
+ * the harness registry when the user actually selects opencode (via
135
+ * `--harness opencode` or auto-detection picking it). Other entry points
136
+ * (`--help`, `--uninstall` of an unrelated harness, `--list`) never trigger
137
+ * this and remain usable.
138
+ */
139
+ export function assertOpencodeEnvironment() {
140
+ if (deferredXdgConfigError)
141
+ throw deferredXdgConfigError;
142
+ }
120
143
  export const opencodeProfile = {
121
144
  name: "opencode",
122
145
  displayName: "OpenCode",
@@ -54,6 +54,8 @@ export interface HarnessPaths {
54
54
  readonly agentsDir: string;
55
55
  /** Global commands/skills dir */
56
56
  readonly commandsDir: string;
57
+ /** Global Codex-style skills dir, when the harness supports skills as first-class install assets */
58
+ readonly skillsDir?: string | null;
57
59
  /** Settings file path, or null if harness has no settings file */
58
60
  readonly settingsPath: string | null;
59
61
  /** Tool installation dir, or null if harness has no tool installation */
@@ -20,7 +20,11 @@ export class ConfigParseError extends Error {
20
20
  /** Thrown when a harness is scaffolded but not yet tested/implemented. */
21
21
  export class HarnessNotTestedError extends Error {
22
22
  constructor(harnessName) {
23
- super(`${harnessName} harness is not yet tested. Use --harness claude-code or --harness opencode.`);
23
+ super(
24
+ // Keep this list in sync with profiles whose `status === "stable"`.
25
+ // Today: claude-code, opencode, gemini-cli. When a new stable profile
26
+ // lands, add it here so the error stays actionable.
27
+ `${harnessName} harness is not yet tested. Use --harness claude-code, --harness opencode, or --harness gemini-cli.`);
24
28
  this.name = "HarnessNotTestedError";
25
29
  }
26
30
  }
@@ -5,10 +5,18 @@
5
5
  * from corrupting user config files.
6
6
  */
7
7
  import { writeFile, rename, unlink, chmod } from "node:fs/promises";
8
+ import { randomBytes } from "node:crypto";
8
9
  export async function atomicWrite(path, content, options) {
9
- const tmp = `${path}.uluops-tmp`;
10
+ // Random suffix + 'wx' flag (O_CREAT|O_EXCL) prevents symlink races:
11
+ // an attacker cannot pre-position a symlink at an unpredictable path,
12
+ // and 'wx' fails atomically rather than following one that exists.
13
+ const tmp = `${path}.uluops-tmp.${randomBytes(8).toString("hex")}`;
10
14
  try {
11
- await writeFile(tmp, content, { encoding: "utf-8", mode: options?.mode });
15
+ await writeFile(tmp, content, {
16
+ encoding: "utf-8",
17
+ mode: options?.mode,
18
+ flag: "wx",
19
+ });
12
20
  if (options?.mode) {
13
21
  // Ensure mode is applied even if umask is permissive
14
22
  await chmod(tmp, options.mode);
@@ -8,11 +8,14 @@ export interface ClaudeConfig {
8
8
  mcpServers?: Record<string, McpServerConfig>;
9
9
  [key: string]: unknown;
10
10
  }
11
- /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
12
- export declare function checkMcpPackageAvailability(): Promise<{
11
+ interface AvailabilityResult {
13
12
  available: string[];
14
13
  missing: string[];
15
- }>;
14
+ }
15
+ /** Test-only: drop the memoized availability promise so the next call re-probes. */
16
+ export declare function __resetAvailabilityCacheForTesting(): void;
17
+ /** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
18
+ export declare function checkMcpPackageAvailability(): Promise<AvailabilityResult>;
16
19
  /**
17
20
  * Read an existing config file, or return empty object if it doesn't exist.
18
21
  * Throws on malformed JSON to prevent silent data loss during merge+write.