@uluops/setup 0.10.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +772 -0
  2. package/README.md +81 -32
  3. package/dist/cli.js +7 -1
  4. package/dist/commands/helpers.js +70 -7
  5. package/dist/commands/per-harness.d.ts +5 -0
  6. package/dist/commands/per-harness.js +5 -0
  7. package/dist/commands/setup.d.ts +7 -0
  8. package/dist/commands/setup.js +100 -35
  9. package/dist/commands/uninstall.d.ts +7 -0
  10. package/dist/commands/uninstall.js +36 -9
  11. package/dist/commands/verify.d.ts +5 -0
  12. package/dist/commands/verify.js +5 -0
  13. package/dist/harnesses/claude-code.js +15 -7
  14. package/dist/harnesses/codex.js +21 -4
  15. package/dist/harnesses/gemini-cli.js +13 -6
  16. package/dist/harnesses/index.d.ts +8 -0
  17. package/dist/harnesses/index.js +10 -0
  18. package/dist/harnesses/opencode.js +25 -7
  19. package/dist/lib/asset-catalog.js +15 -2
  20. package/dist/lib/atomic-write.d.ts +6 -0
  21. package/dist/lib/atomic-write.js +10 -0
  22. package/dist/lib/config-merger.js +27 -8
  23. package/dist/lib/display.d.ts +8 -0
  24. package/dist/lib/display.js +27 -1
  25. package/dist/lib/file-ops.d.ts +13 -4
  26. package/dist/lib/file-ops.js +69 -18
  27. package/dist/lib/install-lock.js +45 -13
  28. package/dist/lib/json-guards.d.ts +15 -0
  29. package/dist/lib/json-guards.js +30 -0
  30. package/dist/lib/manifest.d.ts +9 -2
  31. package/dist/lib/manifest.js +66 -8
  32. package/dist/lib/mcp-packages.d.ts +17 -15
  33. package/dist/lib/mcp-packages.js +15 -13
  34. package/dist/lib/settings-merger.js +53 -9
  35. package/dist/lib/version.js +19 -2
  36. package/dist/lib/write-coordinator.d.ts +50 -0
  37. package/dist/lib/write-coordinator.js +89 -0
  38. package/dist/steps/agent-metrics-cli.d.ts +6 -0
  39. package/dist/steps/agent-metrics-cli.js +19 -1
  40. package/dist/steps/agents.js +22 -4
  41. package/dist/steps/auth.js +53 -13
  42. package/dist/steps/cli.js +14 -1
  43. package/dist/steps/commands.js +28 -10
  44. package/dist/steps/mcp.js +18 -9
  45. package/dist/steps/metrics.js +77 -7
  46. package/dist/steps/shell.d.ts +4 -1
  47. package/dist/steps/shell.js +44 -6
  48. package/dist/steps/signup.d.ts +4 -0
  49. package/dist/steps/signup.js +18 -2
  50. package/dist/steps/skills.d.ts +11 -0
  51. package/dist/steps/skills.js +35 -6
  52. package/dist/steps/username.js +10 -2
  53. package/dist/steps/verify.js +55 -6
  54. package/package.json +8 -5
  55. package/dist/lib/agent-transform.d.ts +0 -12
  56. package/dist/lib/agent-transform.js +0 -129
@@ -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
- // Directory doesn't exist — not critical
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
- // Not critical
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
- await profile.hooks.remove(profile.paths.settingsPath, false);
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
- // Already gone
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
  }
@@ -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<void>;
4
+ export declare function removeShellExport(profilePath: string): Promise<{
5
+ removed: boolean;
6
+ reason?: string;
7
+ }>;
@@ -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
- const block = `${FENCE_START}\nexport ULUOPS_API_KEY="${apiKey}"\n${FENCE_END}`;
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
- return;
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
- await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"), { mode: 0o600 });
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
  }
@@ -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;
@@ -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
- if (err instanceof TypeError) {
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
- const body = await res.json();
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
  }
@@ -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>;
@@ -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
- const removed = await removeStaleFiles(destBase, existingManifestSkills, installedFiles, dryRun);
78
- return { copied, skipped, removed, files: installedFiles, failures };
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
- // Already gone or non-empty due to user files.
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;
@@ -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
- const body = await res.json();
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
  }
@@ -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
- const hookPresent = await profile.hooks.check(profile.paths.settingsPath);
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
- return { ok: allOk, checks };
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.10.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,16 +48,16 @@
45
48
  "prepublishOnly": "npm run build"
46
49
  },
47
50
  "dependencies": {
48
- "@inquirer/prompts": "8.5.2",
49
- "@uluops/agent-metrics": "0.4.0",
51
+ "@inquirer/prompts": "8.6.0",
52
+ "@uluops/agent-metrics": "0.8.0",
50
53
  "chalk": "5.6.2",
51
54
  "commander": "15.0.0",
52
55
  "jsonc-parser": "3.3.1"
53
56
  },
54
57
  "devDependencies": {
55
58
  "@types/node": "25.9.3",
56
- "tsx": "4.22.4",
59
+ "tsx": "4.23.12",
57
60
  "typescript": "6.0.3",
58
- "vitest": "4.1.9"
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;