@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.
Files changed (58) hide show
  1. package/README.md +94 -10
  2. package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
  3. package/dist/cli/select-harnesses.d.ts +91 -0
  4. package/dist/cli/select-harnesses.js +108 -0
  5. package/dist/cli.js +78 -37
  6. package/dist/commands/errors.d.ts +24 -0
  7. package/dist/commands/errors.js +28 -0
  8. package/dist/commands/helpers.d.ts +7 -0
  9. package/dist/commands/helpers.js +80 -3
  10. package/dist/commands/per-harness.d.ts +64 -0
  11. package/dist/commands/per-harness.js +37 -0
  12. package/dist/commands/setup.d.ts +5 -3
  13. package/dist/commands/setup.js +174 -48
  14. package/dist/commands/uninstall-filter.d.ts +36 -0
  15. package/dist/commands/uninstall-filter.js +69 -0
  16. package/dist/commands/uninstall.d.ts +12 -2
  17. package/dist/commands/uninstall.js +121 -45
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +212 -22
  20. package/dist/harnesses/index.js +6 -1
  21. package/dist/harnesses/opencode.d.ts +8 -0
  22. package/dist/harnesses/opencode.js +24 -1
  23. package/dist/harnesses/types.d.ts +2 -0
  24. package/dist/harnesses/types.js +8 -1
  25. package/dist/lib/atomic-write.js +10 -2
  26. package/dist/lib/config-merger.d.ts +6 -3
  27. package/dist/lib/config-merger.js +50 -7
  28. package/dist/lib/display.d.ts +21 -5
  29. package/dist/lib/display.js +118 -13
  30. package/dist/lib/file-ops.d.ts +13 -5
  31. package/dist/lib/file-ops.js +34 -34
  32. package/dist/lib/install-lock.js +11 -1
  33. package/dist/lib/json-guards.d.ts +22 -0
  34. package/dist/lib/json-guards.js +33 -0
  35. package/dist/lib/manifest.d.ts +20 -0
  36. package/dist/lib/manifest.js +61 -12
  37. package/dist/lib/paths.d.ts +0 -17
  38. package/dist/lib/paths.js +0 -19
  39. package/dist/lib/settings-merger.js +3 -1
  40. package/dist/steps/agent-metrics-cli.d.ts +9 -1
  41. package/dist/steps/agent-metrics-cli.js +66 -20
  42. package/dist/steps/agents.d.ts +11 -0
  43. package/dist/steps/agents.js +30 -25
  44. package/dist/steps/auth.d.ts +13 -0
  45. package/dist/steps/auth.js +61 -5
  46. package/dist/steps/cli.d.ts +6 -0
  47. package/dist/steps/cli.js +29 -10
  48. package/dist/steps/commands.d.ts +10 -0
  49. package/dist/steps/commands.js +31 -30
  50. package/dist/steps/detect.js +15 -1
  51. package/dist/steps/mcp.js +1 -8
  52. package/dist/steps/metrics.js +10 -3
  53. package/dist/steps/shell.js +3 -13
  54. package/dist/steps/signup.js +14 -1
  55. package/dist/steps/skills.d.ts +14 -0
  56. package/dist/steps/skills.js +95 -0
  57. package/dist/steps/verify.js +195 -91
  58. package/package.json +3 -2
@@ -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>;
@@ -1,5 +1,5 @@
1
- import { readFile, writeFile, mkdir, unlink, access, copyFile, readdir } from "node:fs/promises";
2
- import { join, basename } from "node:path";
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
- let removed = 0;
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
- }
@@ -16,7 +16,7 @@
16
16
  import { mkdir, readFile, rm, unlink, writeFile, } from "node:fs/promises";
17
17
  import { rmSync } from "node:fs";
18
18
  import { hostname } from "node:os";
19
- import { join } from "node:path";
19
+ import { dirname, join } from "node:path";
20
20
  import { getInstallLockDir } from "./paths.js";
21
21
  const META_FILENAME = "meta.json";
22
22
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 min
@@ -41,6 +41,16 @@ export async function acquireInstallLock(opts = {}) {
41
41
  const maxAgeMs = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
42
42
  const waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
43
43
  const deadline = Date.now() + waitMs;
44
+ // Ensure the parent directory exists before the atomic lock mkdir below.
45
+ // The lock dir itself is created with `recursive: false` to preserve
46
+ // mkdir-atomicity as the lock primitive (two racing processes can't both
47
+ // win the mkdir). But if the user has never run setup before, ~/.uluops/
48
+ // doesn't exist yet — and `recursive: false` mkdir surfaces ENOENT on
49
+ // the missing parent rather than EEXIST on the lock itself, so the loop
50
+ // below would treat that as an unrecoverable error. Pre-creating the
51
+ // parent with `recursive: true` is safe (it's idempotent and not part of
52
+ // the atomicity contract — only the lock dir mkdir is).
53
+ await mkdir(dirname(lockDir), { recursive: true });
44
54
  // First try (and one retry after stale-lock reclaim).
45
55
  for (let attempt = 0; attempt < 2; attempt++) {
46
56
  try {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Narrow guards for JSON responses from the UluOps API.
3
+ *
4
+ * Centralised so the same envelope shape (`{ data: { ... } }`) is decoded
5
+ * consistently across call sites (`steps/auth.ts`, `steps/verify.ts`,
6
+ * `steps/signup.ts`). Each guard returns `null` on absent/wrong-typed fields
7
+ * and throws a plain `Error` only when the top-level body shape is so wrong
8
+ * that proceeding would silently coerce garbage into a typed result.
9
+ *
10
+ * Why plain `Error`, not `TypeError`: call sites translate `TypeError` into
11
+ * "Can't reach api.uluops.ai" (fetch's network-failure shape). A `TypeError`
12
+ * here would be misclassified as a network outage.
13
+ */
14
+ /**
15
+ * Narrow `{ data: { email: string } }` from an unknown response body.
16
+ * Returns the email string when present and well-typed, null otherwise.
17
+ * Throws when `body` is not an object at all — that indicates the endpoint
18
+ * is no longer the one we expect (HTML error page, redirect to a captive
19
+ * portal, schema breakage) and should surface to the user rather than be
20
+ * papered over as "logged in with no email."
21
+ */
22
+ export declare function extractEmail(body: unknown): string | null;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Narrow guards for JSON responses from the UluOps API.
3
+ *
4
+ * Centralised so the same envelope shape (`{ data: { ... } }`) is decoded
5
+ * consistently across call sites (`steps/auth.ts`, `steps/verify.ts`,
6
+ * `steps/signup.ts`). Each guard returns `null` on absent/wrong-typed fields
7
+ * and throws a plain `Error` only when the top-level body shape is so wrong
8
+ * that proceeding would silently coerce garbage into a typed result.
9
+ *
10
+ * Why plain `Error`, not `TypeError`: call sites translate `TypeError` into
11
+ * "Can't reach api.uluops.ai" (fetch's network-failure shape). A `TypeError`
12
+ * here would be misclassified as a network outage.
13
+ */
14
+ /**
15
+ * Narrow `{ data: { email: string } }` from an unknown response body.
16
+ * Returns the email string when present and well-typed, null otherwise.
17
+ * Throws when `body` is not an object at all — that indicates the endpoint
18
+ * is no longer the one we expect (HTML error page, redirect to a captive
19
+ * portal, schema breakage) and should surface to the user rather than be
20
+ * papered over as "logged in with no email."
21
+ */
22
+ export function extractEmail(body) {
23
+ if (typeof body !== "object" || body === null) {
24
+ throw new Error("API returned an unexpected response shape (not an object). The endpoint may have changed — try --skip-validation to continue offline.");
25
+ }
26
+ const data = body.data;
27
+ if (data === undefined || data === null)
28
+ return null;
29
+ if (typeof data !== "object")
30
+ return null;
31
+ const email = data.email;
32
+ return typeof email === "string" ? email : null;
33
+ }
@@ -11,6 +11,14 @@
11
11
  * assumption is documented in the schema, not the comments.
12
12
  */
13
13
  export type HarnessInstanceKey = string;
14
+ /**
15
+ * Names of per-harness pipeline steps that can throw before producing a
16
+ * complete result. Recorded in `HarnessManifest.partial` so verify can warn
17
+ * and re-runs can re-prompt conflicts (spec §7.6.3). `configureMcpStep` is
18
+ * NOT in this set — an MCP throw produces no manifest entry at all because
19
+ * the entry depends on the MCP-derived `mcpConfigPath`.
20
+ */
21
+ export type PartialStep = "agents" | "commands" | "skills" | "metrics";
14
22
  /** Per-harness installation state. */
15
23
  export interface HarnessManifest {
16
24
  installedAt: string;
@@ -21,6 +29,7 @@ export interface HarnessManifest {
21
29
  defsPath: string;
22
30
  agents: string[];
23
31
  commands: string[];
32
+ skills?: string[];
24
33
  hooksInstalled: boolean;
25
34
  /**
26
35
  * Version of @uluops/agent-metrics whose dist/ was copied into the harness tree.
@@ -29,6 +38,17 @@ export interface HarnessManifest {
29
38
  * the shared version ledger across the setup↔agent-metrics seam.
30
39
  */
31
40
  hooksInstalledVersion?: string | null;
41
+ /**
42
+ * When non-null, names the per-harness pipeline step that threw before
43
+ * producing a complete result. Earlier steps' file lists are accurate;
44
+ * later steps were never attempted. Verify surfaces this as a WARNING.
45
+ * Re-run treats a partial entry as "checkConflicts must run again" (the
46
+ * user never confirmed it on the failing run — spec §7.6.5).
47
+ *
48
+ * Absent (undefined) on manifests written by pre-multi-target versions —
49
+ * those harnesses are assumed fully installed.
50
+ */
51
+ partial?: PartialStep | null;
32
52
  }
33
53
  /** Top-level manifest with per-harness entries. */
34
54
  export interface Manifest {
@@ -13,8 +13,15 @@ function isNewManifest(obj) {
13
13
  typeof m["harnesses"] !== "object" ||
14
14
  m["harnesses"] === null)
15
15
  return false;
16
- // Validate at least one harness entry has required fields
16
+ // An on-disk manifest must reference at least one harness installation.
17
+ // The empty-harnesses case used to pass vacuously (the for-loop iterated
18
+ // zero times), letting a truncated `{...harnesses:{}}` file masquerade as
19
+ // valid — and a subsequent uninstall would then iterate zero harnesses,
20
+ // delete the manifest, and report success while leaving every MCP config,
21
+ // agent, hook, and shell export in place.
17
22
  const harnesses = m["harnesses"];
23
+ if (Object.keys(harnesses).length === 0)
24
+ return false;
18
25
  for (const h of Object.values(harnesses)) {
19
26
  if (typeof h !== "object" || h === null)
20
27
  return false;
@@ -23,6 +30,14 @@ function isNewManifest(obj) {
23
30
  return false;
24
31
  if (!Array.isArray(hm["agents"]) || !Array.isArray(hm["commands"]))
25
32
  return false;
33
+ if ("skills" in hm && !Array.isArray(hm["skills"]))
34
+ return false;
35
+ if ("partial" in hm) {
36
+ const p = hm["partial"];
37
+ if (p !== null && p !== "agents" && p !== "commands" && p !== "skills" && p !== "metrics") {
38
+ return false;
39
+ }
40
+ }
26
41
  }
27
42
  return true;
28
43
  }
@@ -83,20 +98,54 @@ export async function validateManifest(manifest) {
83
98
  warnings.push(`[${harnessName}] Command files missing from disk: ${missing.join(", ")}`);
84
99
  }
85
100
  }
101
+ if ((hm.skills?.length ?? 0) > 0 && defsExists) {
102
+ const missing = await findMissingFiles(hm.defsPath, "skills", hm.skills ?? []);
103
+ if (missing.length > 0) {
104
+ warnings.push(`[${harnessName}] Skill files missing from disk: ${missing.join(", ")}`);
105
+ }
106
+ }
86
107
  }
87
- const manifestPath = getManifestPath();
88
- try {
89
- const raw = await readFile(manifestPath, "utf-8");
90
- const parsed = JSON.parse(raw);
91
- const { contentHash: storedHash, ...withoutHash } = parsed;
92
- const canonical = JSON.stringify(withoutHash, null, 2) + "\n";
93
- const currentHash = fileHash(canonical);
94
- if (storedHash && storedHash !== currentHash) {
95
- warnings.push("Manifest file has been modified since installation — content hash mismatch");
108
+ // Hash verification reads whichever manifest file actually exists. The
109
+ // previous implementation hardcoded the new path, which produced a false
110
+ // "Cannot read manifest file to verify content hash" warning on every
111
+ // uninstall after `loadManifest` migrated a legacy manifest in memory
112
+ // without writing it back to the new location. Silently skip the hash
113
+ // check when no manifest is on disk in either location (in-memory-only
114
+ // manifest, or both locations missing).
115
+ let raw = null;
116
+ for (const candidate of [getManifestPath(), getLegacyManifestPath()]) {
117
+ try {
118
+ raw = await readFile(candidate, "utf-8");
119
+ break;
120
+ }
121
+ catch {
122
+ // Try next candidate
96
123
  }
97
124
  }
98
- catch {
99
- warnings.push("Cannot read manifest file to verify content hash");
125
+ if (raw !== null) {
126
+ try {
127
+ const parsed = JSON.parse(raw);
128
+ const { contentHash: storedHash, ...withoutHash } = parsed;
129
+ const canonical = JSON.stringify(withoutHash, null, 2) + "\n";
130
+ const currentHash = fileHash(canonical);
131
+ // Typeof check rather than truthiness — a malformed manifest with
132
+ // `contentHash: 0`, `contentHash: false`, `contentHash: ""`, or no
133
+ // contentHash key at all would all evaluate `storedHash && ...` to
134
+ // false and silently skip tamper detection. Only a missing-key
135
+ // (undefined) manifest is legitimately exempt (legacy / pre-hash);
136
+ // a present-but-not-a-string value is suspect and should warn.
137
+ if (typeof storedHash === "string") {
138
+ if (storedHash !== currentHash) {
139
+ warnings.push("Manifest file has been modified since installation — content hash mismatch");
140
+ }
141
+ }
142
+ else if (storedHash !== undefined) {
143
+ warnings.push(`Manifest contentHash has wrong type (${typeof storedHash}); tamper detection skipped`);
144
+ }
145
+ }
146
+ catch {
147
+ warnings.push("Manifest file is unparseable JSON");
148
+ }
100
149
  }
101
150
  return { valid: errors.length === 0, errors, warnings };
102
151
  }
@@ -19,23 +19,6 @@ export declare function getManifestPath(): string;
19
19
  export declare function getInstallLockDir(): string;
20
20
  /** Return the legacy manifest path for migration. */
21
21
  export declare function getLegacyManifestPath(): string;
22
- /**
23
- * Return the backup directory for a harness's **config** files.
24
- *
25
- * Scope is deliberately narrow: this directory holds copies of mutable
26
- * user-owned config surfaces (the MCP config file, the shell profile),
27
- * NOT vendor-owned tool files in `~/.claude/tools/agent-metrics/`. Tool
28
- * files are treated as disposable — they can be regenerated by re-running
29
- * setup, and the source of truth lives in the npm-installed
30
- * `@uluops/agent-metrics` package. Backing them up would require a
31
- * different ritual (versioned snapshots tied to the manifest's
32
- * hooksInstalledVersion) and is not provided here.
33
- *
34
- * Renaming this to `getConfigBackupDir` would be more honest but is a
35
- * public surface change deferred until we have a tool-file backup to
36
- * disambiguate against.
37
- */
38
- export declare function getBackupDir(harnessName: string): string;
39
22
  /** Detect the user's shell and return its name and profile path, or null if unsupported. */
40
23
  export declare function getShellProfile(): {
41
24
  shell: string;
package/dist/lib/paths.js CHANGED
@@ -98,25 +98,6 @@ export function getInstallLockDir() {
98
98
  export function getLegacyManifestPath() {
99
99
  return join(getClaudeHome(), "uluops-manifest.json");
100
100
  }
101
- /**
102
- * Return the backup directory for a harness's **config** files.
103
- *
104
- * Scope is deliberately narrow: this directory holds copies of mutable
105
- * user-owned config surfaces (the MCP config file, the shell profile),
106
- * NOT vendor-owned tool files in `~/.claude/tools/agent-metrics/`. Tool
107
- * files are treated as disposable — they can be regenerated by re-running
108
- * setup, and the source of truth lives in the npm-installed
109
- * `@uluops/agent-metrics` package. Backing them up would require a
110
- * different ritual (versioned snapshots tied to the manifest's
111
- * hooksInstalledVersion) and is not provided here.
112
- *
113
- * Renaming this to `getConfigBackupDir` would be more honest but is a
114
- * public surface change deferred until we have a tool-file backup to
115
- * disambiguate against.
116
- */
117
- export function getBackupDir(harnessName) {
118
- return join(getUluopsDir(), "backups", harnessName);
119
- }
120
101
  /** Detect the user's shell and return its name and profile path, or null if unsupported. */
121
102
  export function getShellProfile() {
122
103
  const shell = process.env["SHELL"] ?? "";
@@ -82,7 +82,9 @@ export async function readSettings(path) {
82
82
  * Write settings back to file with stable formatting.
83
83
  */
84
84
  export async function writeSettings(path, settings) {
85
- await atomicWrite(path, JSON.stringify(settings, null, 2) + "\n");
85
+ await atomicWrite(path, JSON.stringify(settings, null, 2) + "\n", {
86
+ mode: 0o600,
87
+ });
86
88
  }
87
89
  /**
88
90
  * Merge the UluOps hook into settings, preserving all other
@@ -26,7 +26,15 @@ export interface AgentMetricsCliExecutor {
26
26
  error?: string;
27
27
  };
28
28
  }
29
- /** Default executor — shells out to `agent-metrics` and `npm`. */
29
+ /**
30
+ * Parse `npm ls -g --json` output and extract the installed version of
31
+ * AGENT_METRICS_PACKAGE, or null if absent or unparseable.
32
+ *
33
+ * Exported for direct unit testing — keeps the JSON shape contract explicit.
34
+ */
35
+ export declare function parseGlobalAgentMetricsVersion(stdout: string | undefined): string | null;
36
+ export declare function detectGlobalAgentMetrics(): string | null;
37
+ /** Default executor — queries npm directly to avoid npx-transient-PATH false positives. */
30
38
  export declare const defaultAgentMetricsExecutor: AgentMetricsCliExecutor;
31
39
  export interface AgentMetricsCliInstallResult {
32
40
  /** `agent-metrics` is on PATH after this step, regardless of how it got there. */
@@ -13,38 +13,84 @@ import { spawnSync } from "node:child_process";
13
13
  */
14
14
  export const AGENT_METRICS_PACKAGE = "@uluops/agent-metrics";
15
15
  export const AGENT_METRICS_BIN = "agent-metrics";
16
- /** Default executor — shells out to `agent-metrics` and `npm`. */
16
+ /**
17
+ * Parse `npm ls -g --json` output and extract the installed version of
18
+ * AGENT_METRICS_PACKAGE, or null if absent or unparseable.
19
+ *
20
+ * Exported for direct unit testing — keeps the JSON shape contract explicit.
21
+ */
22
+ export function parseGlobalAgentMetricsVersion(stdout) {
23
+ if (!stdout)
24
+ return null;
25
+ try {
26
+ const parsed = JSON.parse(stdout);
27
+ const entry = parsed.dependencies?.[AGENT_METRICS_PACKAGE];
28
+ return entry?.version ?? null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Detect whether `@uluops/agent-metrics` is installed in npm's GLOBAL prefix.
36
+ *
37
+ * IMPORTANT: this cannot be a simple `spawnSync("agent-metrics", ["--version"])`.
38
+ * `@uluops/agent-metrics` is a runtime dependency of `@uluops/setup` (used by
39
+ * `findMetricsSource` to resolve files to copy into the harness tree). When
40
+ * setup runs under `npx @uluops/setup`, npx prepends its transient cache
41
+ * `.bin/` to PATH for the spawned process — `agent-metrics` resolves there
42
+ * even when the user has nothing installed globally. The check then returns
43
+ * "already installed" and setup skips the global install, leaving the user
44
+ * with `command not found` after npx exits. This was the actual bug behavior
45
+ * observed in v0.7.0 on the first ship.
46
+ *
47
+ * We query npm directly via `npm ls -g --depth=0 --json` — answers the actual
48
+ * question ("is it in the user's global install") instead of a PATH-resolution
49
+ * proxy. `npm ls` exits non-zero when the queried package is missing but still
50
+ * emits valid JSON, so we rely on the JSON content rather than the exit code.
51
+ */
52
+ /** Same bounds as the @uluops/cli installer — see src/steps/cli.ts rationale. */
53
+ const NPM_TIMEOUT_MS = 5 * 60_000;
54
+ const DETECT_TIMEOUT_MS = 30_000;
55
+ function summarizeNpmResult(r, op) {
56
+ if (r.status === 0)
57
+ return { ok: true };
58
+ if (r.signal === "SIGTERM" && r.status === null) {
59
+ return {
60
+ ok: false,
61
+ error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
62
+ };
63
+ }
64
+ const stderr = (r.stderr ?? "").toString().trim();
65
+ const stdout = (r.stdout ?? "").toString().trim();
66
+ return { ok: false, error: stderr || stdout || `exit ${r.status}` };
67
+ }
68
+ export function detectGlobalAgentMetrics() {
69
+ const r = spawnSync("npm", ["ls", "-g", AGENT_METRICS_PACKAGE, "--depth=0", "--json"], {
70
+ encoding: "utf-8",
71
+ stdio: ["ignore", "pipe", "ignore"],
72
+ timeout: DETECT_TIMEOUT_MS,
73
+ });
74
+ return parseGlobalAgentMetricsVersion(r.stdout);
75
+ }
76
+ /** Default executor — queries npm directly to avoid npx-transient-PATH false positives. */
17
77
  export const defaultAgentMetricsExecutor = {
18
- detect: () => {
19
- const r = spawnSync(AGENT_METRICS_BIN, ["--version"], {
20
- encoding: "utf-8",
21
- stdio: ["ignore", "pipe", "ignore"],
22
- });
23
- if (r.status !== 0 || !r.stdout)
24
- return null;
25
- return r.stdout.trim() || null;
26
- },
78
+ detect: detectGlobalAgentMetrics,
27
79
  install: () => {
28
80
  const r = spawnSync("npm", ["install", "-g", AGENT_METRICS_PACKAGE], {
29
81
  encoding: "utf-8",
30
82
  stdio: ["ignore", "pipe", "pipe"],
83
+ timeout: NPM_TIMEOUT_MS,
31
84
  });
32
- if (r.status === 0)
33
- return { ok: true };
34
- const stderr = (r.stderr ?? "").toString().trim();
35
- const stdout = (r.stdout ?? "").toString().trim();
36
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
85
+ return summarizeNpmResult(r, "install");
37
86
  },
38
87
  uninstall: () => {
39
88
  const r = spawnSync("npm", ["uninstall", "-g", AGENT_METRICS_PACKAGE], {
40
89
  encoding: "utf-8",
41
90
  stdio: ["ignore", "pipe", "pipe"],
91
+ timeout: NPM_TIMEOUT_MS,
42
92
  });
43
- if (r.status === 0)
44
- return { ok: true };
45
- const stderr = (r.stderr ?? "").toString().trim();
46
- const stdout = (r.stdout ?? "").toString().trim();
47
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
93
+ return summarizeNpmResult(r, "uninstall");
48
94
  },
49
95
  };
50
96
  /**
@@ -4,6 +4,17 @@ export interface AgentsResult {
4
4
  skipped: number;
5
5
  removed: number;
6
6
  files: string[];
7
+ /**
8
+ * Per-file copy failures. The loop continues past errors so a single bad
9
+ * file (EACCES, ENOSPC, ENAMETOOLONG) cannot abort the whole install and
10
+ * leave the destination half-populated. The caller surfaces these via
11
+ * `warn()` and a re-run will pick up the failed files. Files in this list
12
+ * are NOT counted in `copied` (or `skipped`).
13
+ */
14
+ failures: {
15
+ file: string;
16
+ error: string;
17
+ }[];
7
18
  }
8
19
  /** Copy pre-rendered agent definitions from harness-specific assets to the target directory. */
9
20
  export declare function installAgents(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestAgents?: string[]): Promise<AgentsResult>;
@@ -1,7 +1,7 @@
1
- import { readdir, mkdir, unlink } from "node:fs/promises";
1
+ import { readdir, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
- import { copyIfChanged, unlinkFiles } from "../lib/file-ops.js";
4
+ import { copyIfChanged, unlinkFiles, removeStaleFiles, } from "../lib/file-ops.js";
5
5
  /** Copy pre-rendered agent definitions from harness-specific assets to the target directory. */
6
6
  export async function installAgents(profile, localDefs, dryRun, existingManifestAgents) {
7
7
  const srcDir = join(ASSETS_DIR, profile.name, "agents");
@@ -17,35 +17,40 @@ export async function installAgents(profile, localDefs, dryRun, existingManifest
17
17
  files = (await readdir(srcDir)).filter((f) => f.endsWith(ext));
18
18
  }
19
19
  catch {
20
- return { copied: 0, skipped: 0, removed: 0, files: [] };
20
+ return { copied: 0, skipped: 0, removed: 0, files: [], failures: [] };
21
21
  }
22
22
  let copied = 0;
23
23
  let skipped = 0;
24
+ const installedFiles = [];
25
+ const failures = [];
24
26
  for (const file of files) {
25
- const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
26
- if (result === "copied")
27
- copied++;
28
- else
29
- skipped++;
30
- }
31
- // Remove files that were in the old manifest but no longer in the package
32
- let removed = 0;
33
- if (existingManifestAgents) {
34
- for (const oldFile of existingManifestAgents) {
35
- if (!files.includes(oldFile)) {
36
- if (!dryRun) {
37
- try {
38
- await unlink(join(destDir, oldFile));
39
- }
40
- catch {
41
- // Already gone
42
- }
43
- }
44
- removed++;
45
- }
27
+ try {
28
+ const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
29
+ if (result === "copied")
30
+ copied++;
31
+ else
32
+ skipped++;
33
+ // Only files that actually landed on disk enter installedFiles.
34
+ // A failed copy must not appear here — the manifest treats this list
35
+ // as authoritative, and uninstall would later attempt to unlink a
36
+ // file that was never written. Aligned with installCommands /
37
+ // installSkills behavior; required by the multi-target install
38
+ // partial-state contract (§7.6.2 / §7.6.6).
39
+ installedFiles.push(file);
40
+ }
41
+ catch (err) {
42
+ // Continue past per-file failures so one bad file (EACCES, ENOSPC,
43
+ // ENAMETOOLONG) does not abort the whole install and leave the
44
+ // destination half-populated. The caller `warn()`s these and a re-run
45
+ // will retry — setup is idempotent on success.
46
+ failures.push({
47
+ file,
48
+ error: err instanceof Error ? err.message : String(err),
49
+ });
46
50
  }
47
51
  }
48
- return { copied, skipped, removed, files };
52
+ const removed = await removeStaleFiles(destDir, existingManifestAgents, installedFiles, dryRun);
53
+ return { copied, skipped, removed, files: installedFiles, failures };
49
54
  }
50
55
  /** Remove previously installed agent files by name. */
51
56
  export async function uninstallAgents(files, defsPath) {
@@ -8,6 +8,19 @@ export interface AuthResult {
8
8
  * Existence-only — does not validate the file's shape or contents.
9
9
  */
10
10
  export declare function hasCredentialsFile(): Promise<boolean>;
11
+ /**
12
+ * Persist an API key to ~/.uluops/credentials.json so @uluops/cli and the SDK
13
+ * can resolve it from disk without ULUOPS_API_KEY in the shell environment.
14
+ *
15
+ * Merges into the existing file when present: only the `default` profile is
16
+ * replaced; any other named profiles are preserved. Creates ~/.uluops/ with
17
+ * mode 0o700 if missing. Writes the file at 0o600 via atomicWrite.
18
+ */
19
+ export declare function writeCredentialsFile(apiKey: string, opts?: {
20
+ email?: string | null;
21
+ source?: "signup" | "flag" | "prompt";
22
+ dryRun?: boolean;
23
+ }): Promise<void>;
11
24
  /**
12
25
  * Resolve API key from flags, env, credentials file, or interactive prompt.
13
26
  */