@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.
- 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 +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 +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.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,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.
|
|
@@ -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)
|
package/dist/lib/file-ops.d.ts
CHANGED
|
@@ -11,6 +11,19 @@ export declare function writeIfChanged(destPath: string, content: string, dryRun
|
|
|
11
11
|
* Remove files from a directory. Returns count of successfully removed files.
|
|
12
12
|
*/
|
|
13
13
|
export declare function unlinkFiles(dir: string, files: string[]): Promise<number>;
|
|
14
|
+
/**
|
|
15
|
+
* Reconcile a manifest's old file list against the current source set,
|
|
16
|
+
* unlinking the files that were installed previously but are no longer in
|
|
17
|
+
* the source. Returns the count of files that would have been removed
|
|
18
|
+
* (whether or not the unlink actually ran in dry-run mode).
|
|
19
|
+
*
|
|
20
|
+
* Extracted from three near-identical blocks in syncAssets, installAgents,
|
|
21
|
+
* and installCommands. Errors from unlink are swallowed silently — the
|
|
22
|
+
* "already gone" case is the dominant one (idempotent re-run, manual user
|
|
23
|
+
* deletion, prior failed install), and there's no recovery the caller
|
|
24
|
+
* can usefully perform mid-loop.
|
|
25
|
+
*/
|
|
26
|
+
export declare function removeStaleFiles(destDir: string, oldManifestFiles: string[] | undefined, currentFiles: string[], dryRun: boolean): Promise<number>;
|
|
14
27
|
/**
|
|
15
28
|
* Ensure a directory exists, then copy matching .md files using hash comparison.
|
|
16
29
|
* Returns list of copied files, skipped count, and removed count (for old manifest entries).
|
|
@@ -27,8 +40,3 @@ export declare function syncAssets(opts: {
|
|
|
27
40
|
removed: number;
|
|
28
41
|
files: string[];
|
|
29
42
|
}>;
|
|
30
|
-
/**
|
|
31
|
-
* Back up a file to the UluOps backup directory before modifying it.
|
|
32
|
-
* No-op if the source file doesn't exist.
|
|
33
|
-
*/
|
|
34
|
-
export declare function backupFile(srcPath: string, backupDir: string): Promise<void>;
|
package/dist/lib/file-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, unlink,
|
|
2
|
-
import { join
|
|
1
|
+
import { readFile, writeFile, mkdir, unlink, readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { fileHash } from "./hash.js";
|
|
4
4
|
/**
|
|
5
5
|
* Copy a file if its content has changed (hash comparison). Returns "copied" or "skipped".
|
|
@@ -57,6 +57,37 @@ export async function unlinkFiles(dir, files) {
|
|
|
57
57
|
}
|
|
58
58
|
return removed;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Reconcile a manifest's old file list against the current source set,
|
|
62
|
+
* unlinking the files that were installed previously but are no longer in
|
|
63
|
+
* the source. Returns the count of files that would have been removed
|
|
64
|
+
* (whether or not the unlink actually ran in dry-run mode).
|
|
65
|
+
*
|
|
66
|
+
* Extracted from three near-identical blocks in syncAssets, installAgents,
|
|
67
|
+
* and installCommands. Errors from unlink are swallowed silently — the
|
|
68
|
+
* "already gone" case is the dominant one (idempotent re-run, manual user
|
|
69
|
+
* deletion, prior failed install), and there's no recovery the caller
|
|
70
|
+
* can usefully perform mid-loop.
|
|
71
|
+
*/
|
|
72
|
+
export async function removeStaleFiles(destDir, oldManifestFiles, currentFiles, dryRun) {
|
|
73
|
+
if (!oldManifestFiles)
|
|
74
|
+
return 0;
|
|
75
|
+
let removed = 0;
|
|
76
|
+
for (const oldFile of oldManifestFiles) {
|
|
77
|
+
if (!currentFiles.includes(oldFile)) {
|
|
78
|
+
if (!dryRun) {
|
|
79
|
+
try {
|
|
80
|
+
await unlink(join(destDir, oldFile));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Already gone
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
removed++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
60
91
|
/**
|
|
61
92
|
* Ensure a directory exists, then copy matching .md files using hash comparison.
|
|
62
93
|
* Returns list of copied files, skipped count, and removed count (for old manifest entries).
|
|
@@ -83,40 +114,9 @@ export async function syncAssets(opts) {
|
|
|
83
114
|
}
|
|
84
115
|
}
|
|
85
116
|
// Remove files that were in the old manifest but no longer in the package
|
|
86
|
-
|
|
87
|
-
if (opts.oldManifestFiles) {
|
|
88
|
-
for (const oldFile of opts.oldManifestFiles) {
|
|
89
|
-
if (!assetFiles.includes(oldFile)) {
|
|
90
|
-
if (!opts.dryRun) {
|
|
91
|
-
try {
|
|
92
|
-
await unlink(join(opts.destDir, oldFile));
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
// Already gone
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
removed++;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
117
|
+
const removed = await removeStaleFiles(opts.destDir, opts.oldManifestFiles, assetFiles, opts.dryRun);
|
|
102
118
|
if (errors.length > 0) {
|
|
103
119
|
throw new Error(`Failed to copy ${errors.length} file(s):\n ${errors.join("\n ")}`);
|
|
104
120
|
}
|
|
105
121
|
return { copied, skipped, removed, files: assetFiles };
|
|
106
122
|
}
|
|
107
|
-
/**
|
|
108
|
-
* Back up a file to the UluOps backup directory before modifying it.
|
|
109
|
-
* No-op if the source file doesn't exist.
|
|
110
|
-
*/
|
|
111
|
-
export async function backupFile(srcPath, backupDir) {
|
|
112
|
-
try {
|
|
113
|
-
await access(srcPath);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return; // Nothing to back up
|
|
117
|
-
}
|
|
118
|
-
await mkdir(backupDir, { recursive: true });
|
|
119
|
-
const filename = basename(srcPath);
|
|
120
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
121
|
-
await copyFile(srcPath, join(backupDir, `${filename}.${timestamp}.bak`));
|
|
122
|
-
}
|