@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.
- package/README.md +91 -9
- package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
- package/dist/cli/select-harnesses.d.ts +91 -0
- package/dist/cli/select-harnesses.js +108 -0
- package/dist/cli.js +90 -37
- package/dist/commands/errors.d.ts +24 -0
- package/dist/commands/errors.js +28 -0
- package/dist/commands/helpers.d.ts +32 -0
- package/dist/commands/helpers.js +146 -3
- package/dist/commands/per-harness.d.ts +64 -0
- package/dist/commands/per-harness.js +37 -0
- package/dist/commands/setup.d.ts +7 -3
- package/dist/commands/setup.js +232 -71
- package/dist/commands/uninstall-filter.d.ts +36 -0
- package/dist/commands/uninstall-filter.js +69 -0
- package/dist/commands/uninstall.d.ts +12 -2
- package/dist/commands/uninstall.js +190 -82
- package/dist/harnesses/codex.d.ts +5 -10
- package/dist/harnesses/codex.js +89 -21
- package/dist/harnesses/index.js +6 -1
- package/dist/harnesses/opencode.d.ts +8 -0
- package/dist/harnesses/opencode.js +24 -1
- package/dist/harnesses/types.d.ts +2 -0
- package/dist/harnesses/types.js +5 -1
- package/dist/lib/atomic-write.js +10 -2
- package/dist/lib/config-merger.d.ts +6 -3
- package/dist/lib/config-merger.js +50 -7
- package/dist/lib/display.d.ts +21 -5
- package/dist/lib/display.js +118 -13
- package/dist/lib/file-ops.d.ts +13 -5
- package/dist/lib/file-ops.js +34 -34
- package/dist/lib/install-lock.d.ts +47 -0
- package/dist/lib/install-lock.js +251 -0
- package/dist/lib/json-guards.d.ts +22 -0
- package/dist/lib/json-guards.js +33 -0
- package/dist/lib/manifest.d.ts +28 -0
- package/dist/lib/manifest.js +61 -12
- package/dist/lib/paths.d.ts +2 -17
- package/dist/lib/paths.js +4 -19
- package/dist/lib/settings-merger.js +3 -1
- package/dist/steps/agent-metrics-cli.d.ts +72 -0
- package/dist/steps/agent-metrics-cli.js +147 -0
- package/dist/steps/agents.d.ts +11 -0
- package/dist/steps/agents.js +30 -25
- package/dist/steps/auth.d.ts +13 -0
- package/dist/steps/auth.js +61 -5
- package/dist/steps/cli.d.ts +6 -0
- package/dist/steps/cli.js +29 -10
- package/dist/steps/commands.d.ts +10 -0
- package/dist/steps/commands.js +31 -30
- package/dist/steps/detect.js +15 -1
- package/dist/steps/mcp.js +1 -8
- package/dist/steps/metrics.js +10 -3
- package/dist/steps/shell.js +3 -13
- package/dist/steps/signup.js +14 -1
- package/dist/steps/skills.d.ts +14 -0
- package/dist/steps/skills.js +95 -0
- package/dist/steps/verify.js +195 -91
- 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
try {
|
|
31
|
+
const manifest = await loadManifest();
|
|
32
|
+
if (!manifest) {
|
|
33
|
+
warn("No manifest found — nothing to uninstall.");
|
|
34
|
+
return;
|
|
43
35
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
if (validation.warnings.length > 0) {
|
|
46
|
+
for (const w of validation.warnings)
|
|
47
|
+
warn(w);
|
|
48
|
+
console.log();
|
|
52
49
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
ok(`Removed ${
|
|
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.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
2
|
+
* Codex Harness Profile
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
9
|
+
import { type HarnessProfile } from "./types.js";
|
|
15
10
|
export declare const codexProfile: HarnessProfile;
|
package/dist/harnesses/codex.js
CHANGED
|
@@ -1,34 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex Harness Profile
|
|
2
|
+
* Codex Harness Profile
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
115
|
+
commandsDir: join(home, "commands"),
|
|
116
|
+
skillsDir: join(home, "skills"),
|
|
49
117
|
settingsPath: null,
|
|
50
118
|
toolsDir: null,
|
|
51
119
|
},
|
package/dist/harnesses/index.js
CHANGED
|
@@ -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
|
-
|
|
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 */
|
package/dist/harnesses/types.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/dist/lib/atomic-write.js
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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
|
-
|
|
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.
|