@uluops/setup 0.7.0 → 0.9.0
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 +94 -10
- 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 +78 -37
- package/dist/commands/errors.d.ts +24 -0
- package/dist/commands/errors.js +28 -0
- package/dist/commands/helpers.d.ts +7 -0
- package/dist/commands/helpers.js +80 -3
- package/dist/commands/per-harness.d.ts +64 -0
- package/dist/commands/per-harness.js +37 -0
- package/dist/commands/setup.d.ts +5 -3
- package/dist/commands/setup.js +174 -48
- 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 +121 -45
- package/dist/harnesses/codex.d.ts +5 -10
- package/dist/harnesses/codex.js +212 -22
- 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 +8 -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.js +11 -1
- package/dist/lib/json-guards.d.ts +22 -0
- package/dist/lib/json-guards.js +33 -0
- package/dist/lib/manifest.d.ts +20 -0
- package/dist/lib/manifest.js +61 -12
- package/dist/lib/paths.d.ts +0 -17
- package/dist/lib/paths.js +0 -19
- package/dist/lib/settings-merger.js +3 -1
- package/dist/steps/agent-metrics-cli.d.ts +9 -1
- package/dist/steps/agent-metrics-cli.js +66 -20
- 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
package/dist/harnesses/codex.js
CHANGED
|
@@ -1,41 +1,230 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Tools to seed with `approval_mode = "approve"` (Codex's auto-allow opt-in).
|
|
17
|
+
*
|
|
18
|
+
* Seeded ONLY when the user has no prior `[mcp_servers.NAME.tools.*]` blocks
|
|
19
|
+
* for the server — a re-install over a hand-tuned config preserves the user's
|
|
20
|
+
* choices verbatim (the merge bails on its own seeds the moment it sees user
|
|
21
|
+
* intent).
|
|
22
|
+
*
|
|
23
|
+
* Only read-side tools are seeded. Writes still prompt — the user retains a
|
|
24
|
+
* choice point on every state-changing operation. Lists mirror the
|
|
25
|
+
* `sideEffects: 'read'` declarations in the respective server's tool-registry
|
|
26
|
+
* (sources of truth: ops-uluops-mcp/src/config/tool-registry.ts and
|
|
27
|
+
* uluops-registry-mcp/src/config/tool-registry.ts). If a new read tool ships
|
|
28
|
+
* there, add it here in the same PR — Codex users will silently get a prompt
|
|
29
|
+
* on first use otherwise.
|
|
30
|
+
*/
|
|
31
|
+
const TRACKER_READ_TOOLS = [
|
|
32
|
+
"diff_runs",
|
|
33
|
+
"get_agent_lifecycle",
|
|
34
|
+
"get_agent_matrix",
|
|
35
|
+
"get_agent_reliability",
|
|
36
|
+
"get_agent_runs_analysis",
|
|
37
|
+
"get_analytics",
|
|
38
|
+
"get_burndown",
|
|
39
|
+
"get_discovery",
|
|
40
|
+
"get_full_taxonomy_analytics",
|
|
41
|
+
"get_issue_by_fingerprint",
|
|
42
|
+
"get_issue_details",
|
|
43
|
+
"get_issue_history",
|
|
44
|
+
"get_latest_run",
|
|
45
|
+
"get_project",
|
|
46
|
+
"get_project_analysis",
|
|
47
|
+
"get_project_summary",
|
|
48
|
+
"get_project_trends",
|
|
49
|
+
"get_run",
|
|
50
|
+
"get_run_analysis",
|
|
51
|
+
"get_run_details",
|
|
52
|
+
"get_taxonomy",
|
|
53
|
+
"get_velocity",
|
|
54
|
+
"list_agents",
|
|
55
|
+
"list_projects",
|
|
56
|
+
"list_runs",
|
|
57
|
+
"query_analysis_records",
|
|
58
|
+
"query_issues",
|
|
59
|
+
"search_issues",
|
|
60
|
+
"validate_run",
|
|
61
|
+
];
|
|
62
|
+
const REGISTRY_READ_TOOLS = [
|
|
63
|
+
"batch_users",
|
|
64
|
+
"compare_effectiveness",
|
|
65
|
+
"diff_versions",
|
|
66
|
+
"get_definition",
|
|
67
|
+
"get_dependencies",
|
|
68
|
+
"get_dependents",
|
|
69
|
+
"get_diff_impact",
|
|
70
|
+
"get_ecosystem_overview",
|
|
71
|
+
"get_effectiveness",
|
|
72
|
+
"get_evolution",
|
|
73
|
+
"get_execution_stats",
|
|
74
|
+
"get_fork_lineage",
|
|
75
|
+
"get_health",
|
|
76
|
+
"get_language",
|
|
77
|
+
"get_lineage",
|
|
78
|
+
"get_model",
|
|
79
|
+
"get_translation_analytics",
|
|
80
|
+
"get_translator_version",
|
|
81
|
+
"get_user",
|
|
82
|
+
"is_forkable",
|
|
83
|
+
"list_aliases",
|
|
84
|
+
"list_definitions",
|
|
85
|
+
"list_forks",
|
|
86
|
+
"list_languages",
|
|
87
|
+
"list_models",
|
|
88
|
+
"list_providers",
|
|
89
|
+
"list_versions",
|
|
90
|
+
"render_definition",
|
|
91
|
+
"resolve_alias",
|
|
92
|
+
"search_definitions",
|
|
93
|
+
"set_default_type",
|
|
94
|
+
"validate_definition",
|
|
95
|
+
];
|
|
96
|
+
function tomlString(value) {
|
|
97
|
+
return JSON.stringify(value);
|
|
98
|
+
}
|
|
99
|
+
function toolBlock(serverName, toolName) {
|
|
100
|
+
return [
|
|
101
|
+
`[mcp_servers.${serverName}.tools.${toolName}]`,
|
|
102
|
+
`approval_mode = "approve"`,
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
function serverBlock(name, pkg, apiKey, seedTools) {
|
|
106
|
+
const lines = [
|
|
107
|
+
`[mcp_servers.${name}]`,
|
|
108
|
+
`command = "npx"`,
|
|
109
|
+
`args = ["-y", ${tomlString(pkg)}]`,
|
|
110
|
+
``,
|
|
111
|
+
`[mcp_servers.${name}.env]`,
|
|
112
|
+
`ULUOPS_API_KEY = ${tomlString(apiKey)}`,
|
|
113
|
+
];
|
|
114
|
+
for (const tool of seedTools) {
|
|
115
|
+
lines.push(``, toolBlock(name, tool));
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
function isServerTableFor(name, table) {
|
|
120
|
+
return table === `mcp_servers.${name}` || table === `mcp_servers."${name}"`;
|
|
121
|
+
}
|
|
122
|
+
function isServerEnvTableFor(name, table) {
|
|
123
|
+
return (table === `mcp_servers.${name}.env` ||
|
|
124
|
+
table === `mcp_servers."${name}".env`);
|
|
125
|
+
}
|
|
126
|
+
function isServerSubtableFor(name, table) {
|
|
127
|
+
const unquotedPrefix = `mcp_servers.${name}.`;
|
|
128
|
+
const quotedPrefix = `mcp_servers."${name}".`;
|
|
129
|
+
return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
|
|
130
|
+
}
|
|
131
|
+
function isServerToolTableFor(name, table) {
|
|
132
|
+
const unquotedPrefix = `mcp_servers.${name}.tools.`;
|
|
133
|
+
const quotedPrefix = `mcp_servers."${name}".tools.`;
|
|
134
|
+
return table.startsWith(unquotedPrefix) || table.startsWith(quotedPrefix);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Strip the main `[mcp_servers.NAME]` and `[mcp_servers.NAME.env]` blocks
|
|
138
|
+
* but preserve `[mcp_servers.NAME.tools.*]` blocks. Tool overrides may be
|
|
139
|
+
* user customizations — destroying them on every re-install would punish
|
|
140
|
+
* anyone who hand-edited their Codex config to deny a specific tool or to
|
|
141
|
+
* approve one we don't seed.
|
|
142
|
+
*/
|
|
143
|
+
function removeServerConfigBlocks(raw, name) {
|
|
144
|
+
const lines = raw.split("\n");
|
|
145
|
+
const kept = [];
|
|
146
|
+
let skipping = false;
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
|
|
149
|
+
if (table) {
|
|
150
|
+
skipping = isServerTableFor(name, table) || isServerEnvTableFor(name, table);
|
|
151
|
+
}
|
|
152
|
+
if (!skipping)
|
|
153
|
+
kept.push(line);
|
|
154
|
+
}
|
|
155
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
156
|
+
}
|
|
157
|
+
function removeServerSubtree(raw, name) {
|
|
158
|
+
const lines = raw.split("\n");
|
|
159
|
+
const kept = [];
|
|
160
|
+
let skipping = false;
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
|
|
163
|
+
if (table) {
|
|
164
|
+
skipping = isServerTableFor(name, table) || isServerSubtableFor(name, table);
|
|
165
|
+
}
|
|
166
|
+
if (!skipping)
|
|
167
|
+
kept.push(line);
|
|
168
|
+
}
|
|
169
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
170
|
+
}
|
|
171
|
+
function hasUserToolEntries(raw, name) {
|
|
172
|
+
for (const line of raw.split("\n")) {
|
|
173
|
+
const table = line.trim().match(/^\[([^\]]+)\]$/)?.[1];
|
|
174
|
+
if (table && isServerToolTableFor(name, table))
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
17
179
|
class CodexMcpConfig {
|
|
18
|
-
async read() {
|
|
19
|
-
|
|
180
|
+
async read(path) {
|
|
181
|
+
try {
|
|
182
|
+
return { [RAW_TOML]: await readFile(path, "utf-8") };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { [RAW_TOML]: "" };
|
|
186
|
+
}
|
|
20
187
|
}
|
|
21
|
-
merge() {
|
|
22
|
-
|
|
188
|
+
merge(config, apiKey) {
|
|
189
|
+
let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
|
|
190
|
+
// Detect prior user customization BEFORE we strip anything. A hand-tuned
|
|
191
|
+
// config (any `[mcp_servers.NAME.tools.*]` block present) signals user
|
|
192
|
+
// intent — we replay only main + env, leaving the user's per-tool
|
|
193
|
+
// approval choices untouched. A fresh config gets the seeded read-tool
|
|
194
|
+
// approvals so first-launch UX skips the prompt cascade.
|
|
195
|
+
const trackerHasUserTools = hasUserToolEntries(raw, "uluops-tracker");
|
|
196
|
+
const registryHasUserTools = hasUserToolEntries(raw, "uluops-registry");
|
|
197
|
+
raw = removeServerConfigBlocks(removeServerConfigBlocks(raw, "uluops-tracker"), "uluops-registry");
|
|
198
|
+
const blocks = [
|
|
199
|
+
serverBlock("uluops-tracker", "@uluops/ops-mcp", apiKey, trackerHasUserTools ? [] : TRACKER_READ_TOOLS),
|
|
200
|
+
serverBlock("uluops-registry", "@uluops/registry-mcp", apiKey, registryHasUserTools ? [] : REGISTRY_READ_TOOLS),
|
|
201
|
+
].join("\n\n");
|
|
202
|
+
return { [RAW_TOML]: [raw.trimEnd(), blocks].filter(Boolean).join("\n\n") + "\n" };
|
|
23
203
|
}
|
|
24
|
-
remove() {
|
|
25
|
-
|
|
204
|
+
remove(config) {
|
|
205
|
+
let raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
|
|
206
|
+
for (const name of ULUOPS_SERVERS) {
|
|
207
|
+
raw = removeServerSubtree(raw, name);
|
|
208
|
+
}
|
|
209
|
+
return { [RAW_TOML]: raw.trimEnd() ? `${raw.trimEnd()}\n` : "" };
|
|
26
210
|
}
|
|
27
|
-
async write() {
|
|
28
|
-
|
|
211
|
+
async write(path, config) {
|
|
212
|
+
const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
|
|
213
|
+
await atomicWrite(path, raw, { mode: 0o600 });
|
|
29
214
|
}
|
|
30
|
-
check() {
|
|
31
|
-
|
|
215
|
+
check(config) {
|
|
216
|
+
const raw = typeof config[RAW_TOML] === "string" ? config[RAW_TOML] : "";
|
|
217
|
+
return ULUOPS_SERVERS.every((name) => {
|
|
218
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
219
|
+
return new RegExp(String.raw `\[mcp_servers\.(?:"${escaped}"|${escaped})\]`).test(raw);
|
|
220
|
+
});
|
|
32
221
|
}
|
|
33
222
|
}
|
|
34
223
|
const home = join(homedir(), ".codex");
|
|
35
224
|
export const codexProfile = {
|
|
36
225
|
name: "codex",
|
|
37
226
|
displayName: "Codex",
|
|
38
|
-
status: "
|
|
227
|
+
status: "stable",
|
|
39
228
|
homeDir: home,
|
|
40
229
|
agentFormat: "toml",
|
|
41
230
|
factoryTarget: "codex",
|
|
@@ -45,7 +234,8 @@ export const codexProfile = {
|
|
|
45
234
|
globalMcpConfig: join(home, "config.toml"),
|
|
46
235
|
localMcpConfig: ".codex/config.toml",
|
|
47
236
|
agentsDir: join(home, "agents"),
|
|
48
|
-
commandsDir: join(
|
|
237
|
+
commandsDir: join(home, "commands"),
|
|
238
|
+
skillsDir: join(home, "skills"),
|
|
49
239
|
settingsPath: null,
|
|
50
240
|
toolsDir: null,
|
|
51
241
|
},
|
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,14 @@ 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, codex. When a new stable
|
|
26
|
+
// profile lands, add it here so the error stays actionable. (As of
|
|
27
|
+
// 0.9.0 every shipped profile is stable, so this constructor is
|
|
28
|
+
// currently unreachable — kept as a structural slot for the next
|
|
29
|
+
// experimental harness scaffold.)
|
|
30
|
+
`${harnessName} harness is not yet tested. Use --harness claude-code, --harness opencode, --harness gemini-cli, or --harness codex.`);
|
|
24
31
|
this.name = "HarnessNotTestedError";
|
|
25
32
|
}
|
|
26
33
|
}
|
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.
|
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { atomicWrite } from "./atomic-write.js";
|
|
3
3
|
const MCP_PACKAGES = ["@uluops/ops-mcp", "@uluops/registry-mcp"];
|
|
4
|
+
/**
|
|
5
|
+
* In-process memoization for the npm availability probe.
|
|
6
|
+
*
|
|
7
|
+
* Setup runs once per process; with multi-harness installs (or any future
|
|
8
|
+
* code path that calls installMcp more than once), the probe was firing
|
|
9
|
+
* redundantly against the npm registry. We cache the in-flight promise so
|
|
10
|
+
* concurrent callers share a single round-trip and subsequent callers get
|
|
11
|
+
* the resolved value instantly. Cache lifetime is the process — setup is
|
|
12
|
+
* one-shot, so a TTL adds state without buying anything.
|
|
13
|
+
*
|
|
14
|
+
* `__resetAvailabilityCacheForTesting` exists ONLY for tests that stub
|
|
15
|
+
* `fetch` per-case. Without a reset, the first test in a file would lock
|
|
16
|
+
* the cached result for every subsequent test in the same process.
|
|
17
|
+
*/
|
|
18
|
+
let _availabilityCache = null;
|
|
19
|
+
/** Test-only: drop the memoized availability promise so the next call re-probes. */
|
|
20
|
+
export function __resetAvailabilityCacheForTesting() {
|
|
21
|
+
_availabilityCache = null;
|
|
22
|
+
}
|
|
4
23
|
/** Check whether the UluOps MCP client packages exist on the npm registry. Returns lists of available and missing packages. */
|
|
5
|
-
export
|
|
24
|
+
export function checkMcpPackageAvailability() {
|
|
25
|
+
if (_availabilityCache)
|
|
26
|
+
return _availabilityCache;
|
|
27
|
+
_availabilityCache = probeAvailability().catch((err) => {
|
|
28
|
+
// If the probe itself throws unexpectedly (not an individual fetch — those
|
|
29
|
+
// are caught by Promise.allSettled), drop the cache so retries don't
|
|
30
|
+
// permanently inherit a poisoned promise.
|
|
31
|
+
_availabilityCache = null;
|
|
32
|
+
throw err;
|
|
33
|
+
});
|
|
34
|
+
return _availabilityCache;
|
|
35
|
+
}
|
|
36
|
+
async function probeAvailability() {
|
|
6
37
|
const available = [];
|
|
7
38
|
const missing = [];
|
|
8
39
|
const results = await Promise.allSettled(MCP_PACKAGES.map((pkg) => fetch(`https://registry.npmjs.org/${pkg}`, {
|
|
@@ -10,16 +41,28 @@ export async function checkMcpPackageAvailability() {
|
|
|
10
41
|
signal: AbortSignal.timeout(5000),
|
|
11
42
|
redirect: "follow",
|
|
12
43
|
}).then((res) => ({ pkg, ok: res.ok }))));
|
|
44
|
+
// Per-index correspondence: results[i] corresponds to MCP_PACKAGES[i] by
|
|
45
|
+
// Promise.allSettled's stable ordering. The previous `?? "unknown"` fallback
|
|
46
|
+
// could emit a literal "unknown" string into `missing`, hiding the real
|
|
47
|
+
// failure reason (DNS error, timeout, 404) under an undiagnosable label.
|
|
13
48
|
for (let i = 0; i < results.length; i++) {
|
|
14
49
|
const result = results[i];
|
|
15
|
-
|
|
16
|
-
|
|
50
|
+
const pkg = MCP_PACKAGES[i];
|
|
51
|
+
if (result.status === "fulfilled") {
|
|
52
|
+
if (result.value.ok) {
|
|
53
|
+
available.push(pkg);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Registry returned non-2xx — package likely missing or unpublished.
|
|
57
|
+
missing.push(pkg);
|
|
58
|
+
}
|
|
17
59
|
}
|
|
18
60
|
else {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
61
|
+
// Network failure: AbortError (timeout), DNS, TLS, EAI_AGAIN, etc.
|
|
62
|
+
const reason = result.reason instanceof Error
|
|
63
|
+
? result.reason.message
|
|
64
|
+
: String(result.reason);
|
|
65
|
+
missing.push(`${pkg} (network: ${reason})`);
|
|
23
66
|
}
|
|
24
67
|
}
|
|
25
68
|
return { available, missing };
|
package/dist/lib/display.d.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PerHarnessResult } from "../commands/per-harness.js";
|
|
2
2
|
declare const ok: (msg: string) => void;
|
|
3
3
|
declare const warn: (msg: string) => void;
|
|
4
4
|
declare const fail: (msg: string) => void;
|
|
5
5
|
declare const info: (msg: string) => void;
|
|
6
6
|
export { ok, warn, fail, info };
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Render the final post-run summary.
|
|
9
|
+
*
|
|
10
|
+
* Single-harness: preserves today's banner format (Setup complete!
|
|
11
|
+
* + agent list + restart instruction) — regression baseline.
|
|
12
|
+
*
|
|
13
|
+
* Multi-harness: aggregate header line, per-harness section block with
|
|
14
|
+
* status icons + counts + re-run hints, single API-key reminder, single
|
|
15
|
+
* restart instruction naming each successfully-installed harness.
|
|
16
|
+
*
|
|
17
|
+
* Status rendering:
|
|
18
|
+
* ok — ✓ green installed (counts)
|
|
19
|
+
* ok+files-failed — same line, the per-step warn()s already surfaced
|
|
20
|
+
* the failed files during install (not re-printed)
|
|
21
|
+
* failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
|
|
22
|
+
* failed (pre-MCP)— ✗ red failed — <error>; re-run hint
|
|
23
|
+
* declined — ⊘ dim skipped — user declined conflict prompt
|
|
24
|
+
*/
|
|
25
|
+
export declare function printSetupSummary(input: {
|
|
26
|
+
results: PerHarnessResult[];
|
|
11
27
|
apiKey: string;
|
|
12
28
|
}): Promise<void>;
|
|
13
29
|
export declare function maskKey(key: string): string;
|
package/dist/lib/display.js
CHANGED
|
@@ -5,30 +5,135 @@ const warn = (msg) => console.log(` ${chalk.yellow("⚠")} ${msg}`);
|
|
|
5
5
|
const fail = (msg) => console.log(` ${chalk.red("✗")} ${msg}`);
|
|
6
6
|
const info = (msg) => console.log(` ${msg}`);
|
|
7
7
|
export { ok, warn, fail, info };
|
|
8
|
-
|
|
8
|
+
const DIVIDER = ` ${chalk.dim("━".repeat(46))}`;
|
|
9
|
+
/**
|
|
10
|
+
* Render the final post-run summary.
|
|
11
|
+
*
|
|
12
|
+
* Single-harness: preserves today's banner format (Setup complete!
|
|
13
|
+
* + agent list + restart instruction) — regression baseline.
|
|
14
|
+
*
|
|
15
|
+
* Multi-harness: aggregate header line, per-harness section block with
|
|
16
|
+
* status icons + counts + re-run hints, single API-key reminder, single
|
|
17
|
+
* restart instruction naming each successfully-installed harness.
|
|
18
|
+
*
|
|
19
|
+
* Status rendering:
|
|
20
|
+
* ok — ✓ green installed (counts)
|
|
21
|
+
* ok+files-failed — same line, the per-step warn()s already surfaced
|
|
22
|
+
* the failed files during install (not re-printed)
|
|
23
|
+
* failed (partial) — ⚠ yellow partial — failed at "<step>"; re-run hint
|
|
24
|
+
* failed (pre-MCP)— ✗ red failed — <error>; re-run hint
|
|
25
|
+
* declined — ⊘ dim skipped — user declined conflict prompt
|
|
26
|
+
*/
|
|
27
|
+
export async function printSetupSummary(input) {
|
|
28
|
+
const { results, apiKey } = input;
|
|
29
|
+
if (results.length === 0) {
|
|
30
|
+
// runSetup's empty-list branch already printed "nothing to install"
|
|
31
|
+
// and returned; this is defense-in-depth so the summary never crashes
|
|
32
|
+
// on an empty input.
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
9
35
|
console.log();
|
|
10
|
-
console.log(
|
|
36
|
+
console.log(DIVIDER);
|
|
11
37
|
console.log();
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
38
|
+
const installed = results.filter((r) => r.status === "ok").length;
|
|
39
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
40
|
+
const declined = results.filter((r) => r.status === "declined").length;
|
|
41
|
+
const total = results.length;
|
|
42
|
+
// Header
|
|
43
|
+
if (total === 1) {
|
|
44
|
+
const only = results[0];
|
|
45
|
+
if (only.status === "ok") {
|
|
46
|
+
console.log(` ${chalk.bold("Setup complete!")} ${chalk.dim(`(${only.profile.displayName})`)} ${renderCounts(only)}`);
|
|
47
|
+
}
|
|
48
|
+
else if (only.status === "declined") {
|
|
49
|
+
console.log(` ${chalk.bold("Setup skipped")} ${chalk.dim(`(${only.profile.displayName})`)} — you declined the conflict prompt`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.log(` ${chalk.red.bold("Setup failed")} ${chalk.dim(`(${only.profile.displayName})`)} — ${only.error ?? "see output above"}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const summaryParts = [`${installed} installed`];
|
|
57
|
+
if (failed > 0)
|
|
58
|
+
summaryParts.push(`${failed} failed`);
|
|
59
|
+
if (declined > 0)
|
|
60
|
+
summaryParts.push(`${declined} declined`);
|
|
61
|
+
const allOk = failed === 0 && declined === 0;
|
|
62
|
+
const headerLabel = allOk ? "Setup complete:" : "Setup finished:";
|
|
63
|
+
console.log(` ${chalk.bold(headerLabel)} ${summaryParts.join(", ")} of ${total} harnesses`);
|
|
64
|
+
console.log();
|
|
65
|
+
for (const r of results) {
|
|
66
|
+
printHarnessLine(r);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
18
69
|
console.log();
|
|
19
|
-
|
|
70
|
+
// Agent list — only for single-harness claude-code success (the bulk of
|
|
71
|
+
// single-harness installs). Multi-harness summaries omit it: it's long
|
|
72
|
+
// and per-claude-code, and the multi-harness reader is more interested
|
|
73
|
+
// in the per-harness status block than the agent catalog.
|
|
74
|
+
if (total === 1 &&
|
|
75
|
+
results[0].status === "ok" &&
|
|
76
|
+
results[0].profile.name === "claude-code") {
|
|
20
77
|
await printAgentList();
|
|
21
78
|
}
|
|
22
|
-
|
|
79
|
+
// API-key reminder — once per run regardless of harness count.
|
|
80
|
+
const masked = maskKey(apiKey);
|
|
23
81
|
info("For SDK/CLI usage, add to your shell profile:");
|
|
24
82
|
info(` ${chalk.cyan(`export ULUOPS_API_KEY="${masked}"`)}`);
|
|
25
83
|
console.log();
|
|
26
84
|
info(`Run again to update: ${chalk.cyan("npx @uluops/setup")}`);
|
|
27
85
|
console.log();
|
|
28
|
-
console.log(
|
|
29
|
-
console.log();
|
|
30
|
-
console.log(` ${chalk.yellow.bold(`Restart ${opts.profile.displayName} to load agents.`)}`);
|
|
86
|
+
console.log(DIVIDER);
|
|
31
87
|
console.log();
|
|
88
|
+
// Restart instruction — names each successfully-installed harness so
|
|
89
|
+
// the user knows what to restart. Suppressed entirely when nothing
|
|
90
|
+
// installed (all declined / all failed pre-MCP) — there's nothing to
|
|
91
|
+
// restart.
|
|
92
|
+
const restartTargets = results.filter((r) => r.status === "ok");
|
|
93
|
+
if (restartTargets.length > 0) {
|
|
94
|
+
const names = restartTargets.map((r) => r.profile.displayName).join(", ");
|
|
95
|
+
const verb = restartTargets.length === 1 ? "Restart" : "Restart each of";
|
|
96
|
+
console.log(` ${chalk.yellow.bold(`${verb} ${names} to load agents.`)}`);
|
|
97
|
+
console.log();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function printHarnessLine(r) {
|
|
101
|
+
const label = chalk.bold(`[${r.profile.displayName}]`);
|
|
102
|
+
switch (r.status) {
|
|
103
|
+
case "ok": {
|
|
104
|
+
const counts = renderCounts(r);
|
|
105
|
+
console.log(` ${chalk.green("✓")} ${label} installed ${counts}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "failed": {
|
|
109
|
+
if (r.partial) {
|
|
110
|
+
console.log(` ${chalk.yellow("⚠")} ${label} partial — failed at "${r.partial}"${r.error ? `: ${r.error}` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log(` ${chalk.red("✗")} ${label} failed — ${r.error ?? "see output above"}`);
|
|
114
|
+
}
|
|
115
|
+
console.log(` ${chalk.dim(`Re-run: npx @uluops/setup --harness ${r.harnessName}`)}`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case "declined":
|
|
119
|
+
console.log(` ${chalk.dim("⊘")} ${label} skipped — user declined conflict prompt`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function renderCounts(r) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
const agents = r.agentsResult?.files.length ?? 0;
|
|
126
|
+
const commands = r.commandsResult?.files.length ?? 0;
|
|
127
|
+
const skills = r.skillsResult?.files.length ?? 0;
|
|
128
|
+
if (agents > 0)
|
|
129
|
+
parts.push(`${agents} agents`);
|
|
130
|
+
if (commands > 0)
|
|
131
|
+
parts.push(`${commands} commands`);
|
|
132
|
+
if (skills > 0)
|
|
133
|
+
parts.push(`${skills} skills`);
|
|
134
|
+
if (r.metricsResult?.hookConfigured)
|
|
135
|
+
parts.push("metrics");
|
|
136
|
+
return parts.length > 0 ? `(${parts.join(" · ")})` : "";
|
|
32
137
|
}
|
|
33
138
|
export function maskKey(key) {
|
|
34
139
|
if (!key || key.length <= 4)
|