@uluops/setup 0.11.0 → 0.12.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/CHANGELOG.md +772 -0
- package/README.md +81 -32
- package/dist/cli.js +7 -1
- package/dist/commands/helpers.js +70 -7
- package/dist/commands/per-harness.d.ts +5 -0
- package/dist/commands/per-harness.js +5 -0
- package/dist/commands/setup.d.ts +7 -0
- package/dist/commands/setup.js +100 -35
- package/dist/commands/uninstall.d.ts +7 -0
- package/dist/commands/uninstall.js +36 -9
- package/dist/commands/verify.d.ts +5 -0
- package/dist/commands/verify.js +5 -0
- package/dist/harnesses/claude-code.js +15 -7
- package/dist/harnesses/codex.js +21 -4
- package/dist/harnesses/gemini-cli.js +13 -6
- package/dist/harnesses/index.d.ts +8 -0
- package/dist/harnesses/index.js +10 -0
- package/dist/harnesses/opencode.js +25 -7
- package/dist/lib/asset-catalog.js +15 -2
- package/dist/lib/atomic-write.d.ts +6 -0
- package/dist/lib/atomic-write.js +10 -0
- package/dist/lib/config-merger.js +27 -8
- package/dist/lib/display.d.ts +8 -0
- package/dist/lib/display.js +27 -1
- package/dist/lib/file-ops.d.ts +13 -4
- package/dist/lib/file-ops.js +69 -18
- package/dist/lib/install-lock.js +45 -13
- package/dist/lib/json-guards.d.ts +15 -0
- package/dist/lib/json-guards.js +30 -0
- package/dist/lib/manifest.d.ts +9 -2
- package/dist/lib/manifest.js +66 -8
- package/dist/lib/mcp-packages.d.ts +17 -15
- package/dist/lib/mcp-packages.js +15 -13
- package/dist/lib/settings-merger.js +53 -9
- package/dist/lib/version.js +19 -2
- package/dist/lib/write-coordinator.d.ts +50 -0
- package/dist/lib/write-coordinator.js +89 -0
- package/dist/steps/agent-metrics-cli.d.ts +6 -0
- package/dist/steps/agent-metrics-cli.js +19 -1
- package/dist/steps/agents.js +22 -4
- package/dist/steps/auth.js +53 -13
- package/dist/steps/cli.js +14 -1
- package/dist/steps/commands.js +28 -10
- package/dist/steps/mcp.js +18 -9
- package/dist/steps/metrics.js +77 -7
- package/dist/steps/shell.d.ts +4 -1
- package/dist/steps/shell.js +44 -6
- package/dist/steps/signup.d.ts +4 -0
- package/dist/steps/signup.js +18 -2
- package/dist/steps/skills.d.ts +11 -0
- package/dist/steps/skills.js +35 -6
- package/dist/steps/username.js +10 -2
- package/dist/steps/verify.js +55 -6
- package/package.json +7 -4
- package/dist/lib/agent-transform.d.ts +0 -12
- package/dist/lib/agent-transform.js +0 -129
package/dist/steps/metrics.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Only active for harnesses that support hooks (currently Claude Code only).
|
|
6
6
|
*/
|
|
7
7
|
import { mkdir, readdir, copyFile, rm, access, readFile } from "node:fs/promises";
|
|
8
|
+
import { isEnoent } from "../lib/file-ops.js";
|
|
9
|
+
import { warn } from "../lib/display.js";
|
|
8
10
|
import { join, dirname } from "node:path";
|
|
9
11
|
import { fileURLToPath } from "node:url";
|
|
10
12
|
/** Where agent-metrics dist files are installed (derived from profile) */
|
|
@@ -84,8 +86,12 @@ async function copyJsDir(srcDir, destDir, dryRun) {
|
|
|
84
86
|
count++;
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
|
-
catch {
|
|
88
|
-
//
|
|
89
|
+
catch (err) {
|
|
90
|
+
// ENOENT = optional subdir absent. Anything else throws — swallowing it
|
|
91
|
+
// after the destructive rm below turns a read failure into a destroyed
|
|
92
|
+
// previously-working install reporting 0 files with no error.
|
|
93
|
+
if (!isEnoent(err))
|
|
94
|
+
throw err;
|
|
89
95
|
}
|
|
90
96
|
return count;
|
|
91
97
|
}
|
|
@@ -99,6 +105,28 @@ async function copyToolFiles(srcRoot, destRoot, dryRun) {
|
|
|
99
105
|
// disk and shadow the new one. Wipe dist/ before repopulating so the
|
|
100
106
|
// installed tree matches the source tree exactly.
|
|
101
107
|
if (!dryRun) {
|
|
108
|
+
// Verify the SOURCE is readable BEFORE the destructive wipe — an
|
|
109
|
+
// unreadable source after the rm leaves a destroyed install. A missing
|
|
110
|
+
// source dist (ENOENT) skips the whole copy without wiping. Subdirs are
|
|
111
|
+
// probed too: a readable top level with an EACCES subdir would otherwise
|
|
112
|
+
// throw with the installed tree already gone.
|
|
113
|
+
try {
|
|
114
|
+
await readdir(srcDist);
|
|
115
|
+
for (const sub of subDirs) {
|
|
116
|
+
try {
|
|
117
|
+
await readdir(join(srcDist, sub));
|
|
118
|
+
}
|
|
119
|
+
catch (subErr) {
|
|
120
|
+
if (!isEnoent(subErr))
|
|
121
|
+
throw subErr; // absent subdir is fine
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (isEnoent(err))
|
|
127
|
+
return 0;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
102
130
|
await rm(destDist, { recursive: true, force: true });
|
|
103
131
|
await mkdir(destDist, { recursive: true });
|
|
104
132
|
for (const sub of subDirs) {
|
|
@@ -116,8 +144,11 @@ async function copyToolFiles(srcRoot, destRoot, dryRun) {
|
|
|
116
144
|
}
|
|
117
145
|
filesCopied++;
|
|
118
146
|
}
|
|
119
|
-
catch {
|
|
120
|
-
//
|
|
147
|
+
catch (err) {
|
|
148
|
+
// Non-fatal, but not silent: this file is what
|
|
149
|
+
// readInstalledMetricsVersion reads — a silent miss surfaces later as
|
|
150
|
+
// "version unknown" / spurious drift in verify.
|
|
151
|
+
warn(`Could not copy agent-metrics package.json: ${err instanceof Error ? err.message : String(err)} — installed-version detection will be degraded`);
|
|
121
152
|
}
|
|
122
153
|
return filesCopied;
|
|
123
154
|
}
|
|
@@ -137,6 +168,12 @@ export async function installMetrics(profile, dryRun) {
|
|
|
137
168
|
const toolDir = profile.paths.toolsDir;
|
|
138
169
|
const settingsPath = profile.paths.settingsPath;
|
|
139
170
|
const source = await findMetricsSource();
|
|
171
|
+
if (!source) {
|
|
172
|
+
// Unresolvable source is a DEGRADED state, not a silent no-op: a prior
|
|
173
|
+
// install's hook.js may keep hookConfigured true below while the files
|
|
174
|
+
// go stale. Say so.
|
|
175
|
+
warn("@uluops/agent-metrics could not be resolved — tool files not refreshed (a previously installed hook, if any, keeps running its old version)");
|
|
176
|
+
}
|
|
140
177
|
let toolFilesCopied = 0;
|
|
141
178
|
if (source) {
|
|
142
179
|
if (!dryRun) {
|
|
@@ -155,6 +192,29 @@ export async function installMetrics(profile, dryRun) {
|
|
|
155
192
|
else if (hookJsExists && dryRun) {
|
|
156
193
|
hookConfigured = true;
|
|
157
194
|
}
|
|
195
|
+
else {
|
|
196
|
+
// hook.js absent: the SETTINGS entry may still exist (externally
|
|
197
|
+
// cleared tool dir) — recording false from disk-existence alone would
|
|
198
|
+
// falsify a live hook. Ask the settings file, which is the authority
|
|
199
|
+
// verify already consults.
|
|
200
|
+
try {
|
|
201
|
+
hookConfigured = await profile.hooks.check(settingsPath);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// Unreadable settings = hook state UNKNOWN, not false. Recording an
|
|
205
|
+
// observed false here would make uninstall skip hook removal over a
|
|
206
|
+
// possibly-live hook — return unobserved so the manifest keeps its
|
|
207
|
+
// prior record (the metricsObserved gate exists for exactly this).
|
|
208
|
+
warn(`Could not read ${settingsPath} (${err instanceof Error ? err.message : String(err)}) — hook state unknown; keeping the previously recorded value`);
|
|
209
|
+
const hooksInstalledVersionUnknown = source?.version ?? (await readInstalledMetricsVersion(toolDir));
|
|
210
|
+
return {
|
|
211
|
+
toolFilesCopied,
|
|
212
|
+
hookConfigured: false,
|
|
213
|
+
hooksInstalledVersion: hooksInstalledVersionUnknown,
|
|
214
|
+
skippedReason: "hook-state-unknown",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
158
218
|
// Prefer the source version (from import.meta.resolve); fall back to reading
|
|
159
219
|
// the just-copied package.json from the harness tree so the manifest still
|
|
160
220
|
// records something useful when the source resolution returned null but a
|
|
@@ -170,14 +230,24 @@ export async function uninstallMetrics(profile, dryRun) {
|
|
|
170
230
|
return;
|
|
171
231
|
}
|
|
172
232
|
if (!dryRun) {
|
|
173
|
-
|
|
233
|
+
try {
|
|
234
|
+
await profile.hooks.remove(profile.paths.settingsPath, false);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// A malformed settings file must not abort the rest of uninstall —
|
|
238
|
+
// mirror the try/catch the MCP-removal path already has.
|
|
239
|
+
warn(`Could not remove metrics hook from ${profile.paths.settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
240
|
+
}
|
|
174
241
|
}
|
|
175
242
|
if (!dryRun) {
|
|
176
243
|
try {
|
|
177
244
|
await rm(profile.paths.toolsDir, { recursive: true, force: true });
|
|
178
245
|
}
|
|
179
|
-
catch {
|
|
180
|
-
//
|
|
246
|
+
catch (err) {
|
|
247
|
+
// force:true already tolerates ENOENT, so anything landing here is a
|
|
248
|
+
// REAL failure (EACCES/EBUSY) — name it, matching the hook-removal
|
|
249
|
+
// catch above.
|
|
250
|
+
warn(`Could not remove tool files at ${profile.paths.toolsDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
181
251
|
}
|
|
182
252
|
}
|
|
183
253
|
}
|
package/dist/steps/shell.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/** Write a fenced ULUOPS_API_KEY export block into the user's shell profile, replacing any existing UluOps block. */
|
|
2
2
|
export declare function writeShellExport(profilePath: string, apiKey: string, dryRun: boolean): Promise<void>;
|
|
3
3
|
/** Remove the fenced UluOps export block from the user's shell profile. */
|
|
4
|
-
export declare function removeShellExport(profilePath: string): Promise<
|
|
4
|
+
export declare function removeShellExport(profilePath: string): Promise<{
|
|
5
|
+
removed: boolean;
|
|
6
|
+
reason?: string;
|
|
7
|
+
}>;
|
package/dist/steps/shell.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { atomicWrite } from "../lib/atomic-write.js";
|
|
4
|
+
import { isEnoent } from "../lib/file-ops.js";
|
|
3
5
|
const FENCE_START = "# --- UluOps (managed by @uluops/setup) ---";
|
|
4
6
|
const FENCE_END = "# --- /UluOps ---";
|
|
5
7
|
/** Characters safe for shell variable values (no metacharacters). */
|
|
@@ -9,13 +11,28 @@ export async function writeShellExport(profilePath, apiKey, dryRun) {
|
|
|
9
11
|
if (!SAFE_KEY_PATTERN.test(apiKey)) {
|
|
10
12
|
throw new Error("API key contains characters unsafe for shell export. Only alphanumeric, underscore, hyphen, and dot are allowed.");
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
// Fish has no `export` builtin — bash syntax in config.fish prints a
|
|
15
|
+
// parse error on EVERY new shell and never sets the variable (silent auth
|
|
16
|
+
// failure + visible breakage). Dialect follows the profile path.
|
|
17
|
+
const isFish = profilePath.endsWith("config.fish");
|
|
18
|
+
const exportLine = isFish
|
|
19
|
+
? `set -gx ULUOPS_API_KEY "${apiKey}"`
|
|
20
|
+
: `export ULUOPS_API_KEY="${apiKey}"`;
|
|
21
|
+
const block = `${FENCE_START}\n${exportLine}\n${FENCE_END}`;
|
|
13
22
|
let content;
|
|
14
23
|
try {
|
|
15
24
|
content = await readFile(profilePath, "utf-8");
|
|
16
25
|
}
|
|
17
|
-
catch {
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (!isEnoent(err)) {
|
|
28
|
+
// An unreadable-but-present shell profile must never be treated as
|
|
29
|
+
// absent — the fresh-file write below would replace the user's rc.
|
|
30
|
+
throw new Error(`Could not read ${profilePath} (${err instanceof Error ? err.message : String(err)}) — refusing to write the API-key export over a file that exists but could not be read. Nothing was modified.`);
|
|
31
|
+
}
|
|
18
32
|
if (!dryRun) {
|
|
33
|
+
// A fresh fish user may have no ~/.config/fish/ yet — atomicWrite's
|
|
34
|
+
// 'wx' temp open ENOENTs on a missing parent.
|
|
35
|
+
await mkdir(dirname(profilePath), { recursive: true });
|
|
19
36
|
await atomicWrite(profilePath, block + "\n", { mode: 0o600 });
|
|
20
37
|
}
|
|
21
38
|
return;
|
|
@@ -43,14 +60,35 @@ export async function removeShellExport(profilePath) {
|
|
|
43
60
|
try {
|
|
44
61
|
content = await readFile(profilePath, "utf-8");
|
|
45
62
|
}
|
|
46
|
-
catch {
|
|
47
|
-
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (isEnoent(err)) {
|
|
65
|
+
return { removed: true, reason: "no profile file" }; // nothing to remove
|
|
66
|
+
}
|
|
67
|
+
// Unreadable-but-present: the export (and the plaintext key in it) may
|
|
68
|
+
// still be there — the caller must NOT print success.
|
|
69
|
+
return {
|
|
70
|
+
removed: false,
|
|
71
|
+
reason: `could not read ${profilePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
72
|
+
};
|
|
48
73
|
}
|
|
49
74
|
const startIdx = content.indexOf(FENCE_START);
|
|
50
75
|
const endIdx = content.lastIndexOf(FENCE_END);
|
|
51
76
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
52
77
|
const before = content.slice(0, startIdx);
|
|
53
78
|
const after = content.slice(endIdx + FENCE_END.length);
|
|
54
|
-
|
|
79
|
+
try {
|
|
80
|
+
await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"), { mode: 0o600 });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
// A write failure must land in the same result shape as a read
|
|
84
|
+
// failure — throwing here escapes past the caller's plaintext-key
|
|
85
|
+
// warning and aborts the rest of uninstall.
|
|
86
|
+
return {
|
|
87
|
+
removed: false,
|
|
88
|
+
reason: `could not rewrite ${profilePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { removed: true };
|
|
55
92
|
}
|
|
93
|
+
return { removed: true, reason: "no export block present" };
|
|
56
94
|
}
|
package/dist/steps/signup.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ declare function getPasswordHint(password: string): string | null;
|
|
|
10
10
|
/**
|
|
11
11
|
* Inquirer validate wrapper. Always returns true (hints are non-blocking)
|
|
12
12
|
* and emits the pure-computed hint via console.warn for display.
|
|
13
|
+
*
|
|
14
|
+
* console.warn (stderr) is DELIBERATE, not a display.ts bypass: this fires
|
|
15
|
+
* mid-inquirer-prompt, and display's console.log helpers write to stdout —
|
|
16
|
+
* the stream inquirer is actively repainting. stderr interleaves cleanly.
|
|
13
17
|
* @internal Exported for testing only — not part of the public API.
|
|
14
18
|
*/
|
|
15
19
|
declare function hintPassword(password: string): true;
|
package/dist/steps/signup.js
CHANGED
|
@@ -22,6 +22,10 @@ function getPasswordHint(password) {
|
|
|
22
22
|
/**
|
|
23
23
|
* Inquirer validate wrapper. Always returns true (hints are non-blocking)
|
|
24
24
|
* and emits the pure-computed hint via console.warn for display.
|
|
25
|
+
*
|
|
26
|
+
* console.warn (stderr) is DELIBERATE, not a display.ts bypass: this fires
|
|
27
|
+
* mid-inquirer-prompt, and display's console.log helpers write to stdout —
|
|
28
|
+
* the stream inquirer is actively repainting. stderr interleaves cleanly.
|
|
25
29
|
* @internal Exported for testing only — not part of the public API.
|
|
26
30
|
*/
|
|
27
31
|
function hintPassword(password) {
|
|
@@ -89,13 +93,25 @@ async function callApi(url, method, body, bearerToken, validate) {
|
|
|
89
93
|
});
|
|
90
94
|
}
|
|
91
95
|
catch (err) {
|
|
92
|
-
|
|
96
|
+
// TypeError = network failure; TimeoutError = AbortSignal.timeout — both
|
|
97
|
+
// deserve the friendly message, not a raw DOMException.
|
|
98
|
+
if (err instanceof TypeError || err?.name === "TimeoutError") {
|
|
93
99
|
throw new Error("Can't reach api.uluops.ai — check your connection.");
|
|
94
100
|
}
|
|
95
101
|
throw err;
|
|
96
102
|
}
|
|
97
103
|
if (res.ok) {
|
|
98
|
-
|
|
104
|
+
// A 200 with a non-JSON body (proxy, captive portal) must not surface as
|
|
105
|
+
// a raw SyntaxError — mirror auth.ts's guarded decode. Worst case here is
|
|
106
|
+
// register: the account may exist server-side while the user sees a
|
|
107
|
+
// parse error, so the message must say what to do next.
|
|
108
|
+
let body;
|
|
109
|
+
try {
|
|
110
|
+
body = await res.json();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new Error("API returned a non-JSON response. If this was a signup, the account may already exist — re-run and choose 'existing API key', or check app.uluops.ai.");
|
|
114
|
+
}
|
|
99
115
|
if (typeof body !== "object" || body === null) {
|
|
100
116
|
throw new Error("Unexpected API response shape");
|
|
101
117
|
}
|
package/dist/steps/skills.d.ts
CHANGED
|
@@ -10,5 +10,16 @@ export interface SkillsResult {
|
|
|
10
10
|
error: string;
|
|
11
11
|
}[];
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Install the harness's skill files from the bundled assets: unchanged files
|
|
15
|
+
* are skipped (hash comparison), updated files overwritten, and manifest
|
|
16
|
+
* entries no longer in the assets removed. `localDefs` redirects the install
|
|
17
|
+
* to `./uluops/skills` (project-scoped). Per-file failures are collected in
|
|
18
|
+
* the result, not thrown.
|
|
19
|
+
*/
|
|
13
20
|
export declare function installSkills(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestSkills?: string[]): Promise<SkillsResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Remove the manifest-listed skill files under `defsPath/skills`, then prune
|
|
23
|
+
* any directories left empty. Returns the number of files removed.
|
|
24
|
+
*/
|
|
14
25
|
export declare function uninstallSkills(files: string[], defsPath: string): Promise<number>;
|
package/dist/steps/skills.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { mkdir, readdir, rmdir } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
|
|
4
|
-
import { copyIfChanged, removeStaleFiles, unlinkFiles } from "../lib/file-ops.js";
|
|
4
|
+
import { copyIfChanged, removeStaleFiles, unlinkFiles, isEnoent } from "../lib/file-ops.js";
|
|
5
5
|
async function listFilesRecursive(dir, prefix = "") {
|
|
6
6
|
let entries;
|
|
7
7
|
try {
|
|
8
8
|
entries = await readdir(dir, { withFileTypes: true });
|
|
9
9
|
}
|
|
10
|
-
catch {
|
|
10
|
+
catch (err) {
|
|
11
|
+
// ENOENT = no skills shipped/installed at this level. Anything else
|
|
12
|
+
// throws — an empty list here becomes the manifest's authoritative
|
|
13
|
+
// skills record.
|
|
14
|
+
if (!isEnoent(err))
|
|
15
|
+
throw err;
|
|
11
16
|
return [];
|
|
12
17
|
}
|
|
13
18
|
const files = [];
|
|
@@ -23,6 +28,13 @@ async function listFilesRecursive(dir, prefix = "") {
|
|
|
23
28
|
}
|
|
24
29
|
return files.sort((a, b) => a.localeCompare(b));
|
|
25
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Install the harness's skill files from the bundled assets: unchanged files
|
|
33
|
+
* are skipped (hash comparison), updated files overwritten, and manifest
|
|
34
|
+
* entries no longer in the assets removed. `localDefs` redirects the install
|
|
35
|
+
* to `./uluops/skills` (project-scoped). Per-file failures are collected in
|
|
36
|
+
* the result, not thrown.
|
|
37
|
+
*/
|
|
26
38
|
export async function installSkills(profile, localDefs, dryRun, existingManifestSkills) {
|
|
27
39
|
const srcBase = join(ASSETS_DIR, profile.name, "skills");
|
|
28
40
|
const destBase = localDefs
|
|
@@ -74,21 +86,38 @@ export async function installSkills(profile, localDefs, dryRun, existingManifest
|
|
|
74
86
|
});
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
// See agents.ts: stale = no-longer-shipped, reconciled against SOURCE.
|
|
90
|
+
const removed = await removeStaleFiles(destBase, existingManifestSkills, files, dryRun);
|
|
91
|
+
const recordedSkillFiles = [
|
|
92
|
+
...installedFiles,
|
|
93
|
+
...failures
|
|
94
|
+
.map((f) => f.file)
|
|
95
|
+
.filter((f) => existingManifestSkills?.includes(f) ?? false),
|
|
96
|
+
];
|
|
97
|
+
return { copied, skipped, removed, files: recordedSkillFiles, failures };
|
|
79
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Remove the manifest-listed skill files under `defsPath/skills`, then prune
|
|
101
|
+
* any directories left empty. Returns the number of files removed.
|
|
102
|
+
*/
|
|
80
103
|
export async function uninstallSkills(files, defsPath) {
|
|
81
104
|
const skillsDir = join(defsPath, "skills");
|
|
82
105
|
const removed = await unlinkFiles(skillsDir, files);
|
|
83
106
|
const skillDirs = new Set(files
|
|
107
|
+
.filter((file) => file.includes("/")) // top-level assets have no dir to prune
|
|
84
108
|
.map((file) => file.split("/")[0])
|
|
85
109
|
.filter((dir) => typeof dir === "string" && dir.length > 0));
|
|
86
110
|
for (const dir of skillDirs) {
|
|
87
111
|
try {
|
|
88
112
|
await rmdir(join(skillsDir, dir));
|
|
89
113
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
114
|
+
catch (err) {
|
|
115
|
+
const code = err?.code;
|
|
116
|
+
if (code !== "ENOENT" && code !== "ENOTEMPTY") {
|
|
117
|
+
// ENOENT (already gone) and ENOTEMPTY (user files present) are the
|
|
118
|
+
// expected outcomes; anything else is a real cleanup failure.
|
|
119
|
+
console.warn(` ⚠ Could not remove skill dir ${join(skillsDir, dir)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
120
|
+
}
|
|
92
121
|
}
|
|
93
122
|
}
|
|
94
123
|
return removed;
|
package/dist/steps/username.js
CHANGED
|
@@ -49,13 +49,21 @@ export async function setUsername(apiKey, username) {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
catch (err) {
|
|
52
|
-
if (err instanceof TypeError) {
|
|
52
|
+
if (err instanceof TypeError || err?.name === "TimeoutError") {
|
|
53
53
|
throw new Error("Can't reach api.uluops.ai — check your connection.");
|
|
54
54
|
}
|
|
55
55
|
throw err;
|
|
56
56
|
}
|
|
57
57
|
if (res.ok) {
|
|
58
|
-
|
|
58
|
+
// Guarded decode — a 200 with a non-JSON body must not throw a raw
|
|
59
|
+
// SyntaxError (mirrors auth.ts / signup.ts).
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await res.json();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new Error("API returned a non-JSON response — try again, or set the username later at app.uluops.ai.");
|
|
66
|
+
}
|
|
59
67
|
if (typeof body !== "object" || body === null) {
|
|
60
68
|
throw new Error("Unexpected API response shape");
|
|
61
69
|
}
|
package/dist/steps/verify.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getHealthTimeout } from "../lib/health.js";
|
|
|
5
5
|
import { getProfile } from "../harnesses/index.js";
|
|
6
6
|
import { readInstalledMetricsVersion } from "./metrics.js";
|
|
7
7
|
import { extractEmail } from "../lib/json-guards.js";
|
|
8
|
+
import { checkMcpPackageAvailability } from "../lib/config-merger.js";
|
|
8
9
|
/**
|
|
9
10
|
* Readiness probe: did the harness pick up the config after install?
|
|
10
11
|
* The proxy signal is `configFile.mtimeMs > installedAt + 100ms` — a config
|
|
@@ -132,7 +133,20 @@ async function checkHooks(profile, hm, checks) {
|
|
|
132
133
|
if (!hm.hooksInstalled || !profile.hooks || !profile.paths.settingsPath) {
|
|
133
134
|
return true;
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
+
// A malformed settings file must read as a failed check with the parse
|
|
137
|
+
// error as detail, not crash the whole verify run.
|
|
138
|
+
let hookPresent;
|
|
139
|
+
try {
|
|
140
|
+
hookPresent = await profile.hooks.check(profile.paths.settingsPath);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
checks.push({
|
|
144
|
+
label: `[${profile.displayName}] Agent metrics hook`,
|
|
145
|
+
passed: false,
|
|
146
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
147
|
+
});
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
136
150
|
let hookFilePresent = false;
|
|
137
151
|
if (profile.paths.toolsDir) {
|
|
138
152
|
try {
|
|
@@ -255,6 +269,9 @@ export async function verify() {
|
|
|
255
269
|
// object at all (surfaced below as a check failure) and returns null
|
|
256
270
|
// when data/email are absent or wrong-typed.
|
|
257
271
|
let email;
|
|
272
|
+
// No early return on a bad body: failing this check must not
|
|
273
|
+
// suppress the unrelated npm-resolvability check below.
|
|
274
|
+
let emailDecodeFailed = false;
|
|
258
275
|
try {
|
|
259
276
|
email = extractEmail(await res.json());
|
|
260
277
|
}
|
|
@@ -265,12 +282,15 @@ export async function verify() {
|
|
|
265
282
|
detail: err instanceof Error ? err.message : "Unexpected response shape",
|
|
266
283
|
});
|
|
267
284
|
allOk = false;
|
|
268
|
-
|
|
285
|
+
emailDecodeFailed = true;
|
|
286
|
+
email = null;
|
|
287
|
+
}
|
|
288
|
+
if (!emailDecodeFailed) {
|
|
289
|
+
checks.push({
|
|
290
|
+
label: `API key valid${email ? ` (user: ${email})` : ""}`,
|
|
291
|
+
passed: true,
|
|
292
|
+
});
|
|
269
293
|
}
|
|
270
|
-
checks.push({
|
|
271
|
-
label: `API key valid${email ? ` (user: ${email})` : ""}`,
|
|
272
|
-
passed: true,
|
|
273
|
-
});
|
|
274
294
|
}
|
|
275
295
|
else {
|
|
276
296
|
checks.push({
|
|
@@ -290,5 +310,34 @@ export async function verify() {
|
|
|
290
310
|
allOk = false;
|
|
291
311
|
}
|
|
292
312
|
}
|
|
313
|
+
// MCP client packages resolvable on npm. The install-time probe warns and
|
|
314
|
+
// moves on (non-blocking by design) — this is where that warning stops
|
|
315
|
+
// being detached from runtime reality: the harness runs `npx -y <spec>` at
|
|
316
|
+
// startup, so an unresolvable package means MCP servers silently fail to
|
|
317
|
+
// start long after setup reported success. --verify re-asks the question
|
|
318
|
+
// on demand.
|
|
319
|
+
try {
|
|
320
|
+
const { missing } = await checkMcpPackageAvailability();
|
|
321
|
+
if (missing.length === 0) {
|
|
322
|
+
checks.push({ label: "MCP packages resolvable on npm", passed: true });
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
checks.push({
|
|
326
|
+
label: "MCP packages resolvable on npm",
|
|
327
|
+
passed: false,
|
|
328
|
+
detail: `not found in registry: ${missing.join(", ")} — MCP servers will fail to start`,
|
|
329
|
+
});
|
|
330
|
+
allOk = false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
checks.push({
|
|
335
|
+
label: "MCP packages resolvable on npm",
|
|
336
|
+
passed: false,
|
|
337
|
+
detail: "npm registry unreachable — could not verify",
|
|
338
|
+
});
|
|
339
|
+
// Network-down is already reflected by the connectivity checks; do not
|
|
340
|
+
// double-fail the run for the same outage.
|
|
341
|
+
}
|
|
293
342
|
return { ok: allOk, checks };
|
|
294
343
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uluops/setup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Zero-friction installer for UluOps agentic harnesses",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"uluops-setup": "./dist/cli.js"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
+
"CHANGELOG.md",
|
|
27
28
|
"dist/cli.js",
|
|
28
29
|
"dist/cli.d.ts",
|
|
29
30
|
"dist/cli/**",
|
|
@@ -37,7 +38,9 @@
|
|
|
37
38
|
"node": ">=20.0.0"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
41
|
+
"prebuild": "rm -rf dist",
|
|
40
42
|
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"postbuild": "chmod +x dist/cli.js",
|
|
41
44
|
"dev": "tsx src/cli.ts",
|
|
42
45
|
"typecheck": "tsc -p tsconfig.build.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
43
46
|
"test": "vitest run",
|
|
@@ -45,7 +48,7 @@
|
|
|
45
48
|
"prepublishOnly": "npm run build"
|
|
46
49
|
},
|
|
47
50
|
"dependencies": {
|
|
48
|
-
"@inquirer/prompts": "8.
|
|
51
|
+
"@inquirer/prompts": "8.6.0",
|
|
49
52
|
"@uluops/agent-metrics": "0.8.0",
|
|
50
53
|
"chalk": "5.6.2",
|
|
51
54
|
"commander": "15.0.0",
|
|
@@ -53,8 +56,8 @@
|
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"@types/node": "25.9.3",
|
|
56
|
-
"tsx": "4.
|
|
59
|
+
"tsx": "4.23.12",
|
|
57
60
|
"typescript": "6.0.3",
|
|
58
|
-
"vitest": "4.1.
|
|
61
|
+
"vitest": "4.1.11"
|
|
59
62
|
}
|
|
60
63
|
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent Transform
|
|
3
|
-
*
|
|
4
|
-
* Transforms Claude Code agent markdown to other harness formats at install time.
|
|
5
|
-
* Single source of truth: assets/agents/ contains Claude Code format only.
|
|
6
|
-
* Each harness gets its frontmatter rewritten; the body is identical across all targets.
|
|
7
|
-
*/
|
|
8
|
-
/**
|
|
9
|
-
* Transform a Claude Code agent markdown file to a target harness format.
|
|
10
|
-
* Returns the original content for claude-code; rewrites frontmatter for others.
|
|
11
|
-
*/
|
|
12
|
-
export declare function transformAgent(markdown: string, harnessName: string): string;
|