@uluops/setup 0.6.5 → 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.
Files changed (59) hide show
  1. package/README.md +91 -9
  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 +90 -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 +32 -0
  9. package/dist/commands/helpers.js +146 -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 +7 -3
  13. package/dist/commands/setup.js +232 -71
  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 +190 -82
  18. package/dist/harnesses/codex.d.ts +5 -10
  19. package/dist/harnesses/codex.js +89 -21
  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 +5 -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.d.ts +47 -0
  33. package/dist/lib/install-lock.js +251 -0
  34. package/dist/lib/json-guards.d.ts +22 -0
  35. package/dist/lib/json-guards.js +33 -0
  36. package/dist/lib/manifest.d.ts +28 -0
  37. package/dist/lib/manifest.js +61 -12
  38. package/dist/lib/paths.d.ts +2 -17
  39. package/dist/lib/paths.js +4 -19
  40. package/dist/lib/settings-merger.js +3 -1
  41. package/dist/steps/agent-metrics-cli.d.ts +72 -0
  42. package/dist/steps/agent-metrics-cli.js +147 -0
  43. package/dist/steps/agents.d.ts +11 -0
  44. package/dist/steps/agents.js +30 -25
  45. package/dist/steps/auth.d.ts +13 -0
  46. package/dist/steps/auth.js +61 -5
  47. package/dist/steps/cli.d.ts +6 -0
  48. package/dist/steps/cli.js +29 -10
  49. package/dist/steps/commands.d.ts +10 -0
  50. package/dist/steps/commands.js +31 -30
  51. package/dist/steps/detect.js +15 -1
  52. package/dist/steps/mcp.js +1 -8
  53. package/dist/steps/metrics.js +10 -3
  54. package/dist/steps/shell.js +3 -13
  55. package/dist/steps/signup.js +14 -1
  56. package/dist/steps/skills.d.ts +14 -0
  57. package/dist/steps/skills.js +95 -0
  58. package/dist/steps/verify.js +195 -91
  59. package/package.json +3 -2
@@ -1,6 +1,8 @@
1
- import { readFile, access } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { readFile, access, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { extractEmail } from "../lib/json-guards.js";
5
+ import { atomicWrite } from "../lib/atomic-write.js";
4
6
  function getKeyPrefix() {
5
7
  return process.env["ULUOPS_KEY_PREFIX"] ?? "ulr_";
6
8
  }
@@ -21,6 +23,50 @@ export async function hasCredentialsFile() {
21
23
  return false;
22
24
  }
23
25
  }
26
+ /**
27
+ * Persist an API key to ~/.uluops/credentials.json so @uluops/cli and the SDK
28
+ * can resolve it from disk without ULUOPS_API_KEY in the shell environment.
29
+ *
30
+ * Merges into the existing file when present: only the `default` profile is
31
+ * replaced; any other named profiles are preserved. Creates ~/.uluops/ with
32
+ * mode 0o700 if missing. Writes the file at 0o600 via atomicWrite.
33
+ */
34
+ export async function writeCredentialsFile(apiKey, opts) {
35
+ if (opts?.dryRun)
36
+ return;
37
+ const credsPath = credentialsPath();
38
+ await mkdir(dirname(credsPath), { recursive: true, mode: 0o700 });
39
+ // Merge: preserve any non-default profiles already on disk.
40
+ let existing = {};
41
+ try {
42
+ const raw = await readFile(credsPath, "utf-8");
43
+ const parsed = JSON.parse(raw);
44
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
45
+ existing = parsed;
46
+ }
47
+ }
48
+ catch {
49
+ // No existing file, or unparseable — start fresh.
50
+ }
51
+ const merged = {
52
+ ...existing,
53
+ default: {
54
+ // type discriminant must match the shape @uluops/cli writes via
55
+ // saveCredentials and the shape @uluops/sdk-core's StoredProfile expects.
56
+ // Without it, `ulu auth logout` reads creds.type !== 'api_key' and
57
+ // dispatches the revocation call with no bearer header — the server-side
58
+ // key survives a "successful" logout.
59
+ type: "api_key",
60
+ apiKey,
61
+ email: opts?.email ?? null,
62
+ createdAt: new Date().toISOString(),
63
+ source: opts?.source ?? "flag",
64
+ },
65
+ };
66
+ await atomicWrite(credsPath, JSON.stringify(merged, null, 2) + "\n", {
67
+ mode: 0o600,
68
+ });
69
+ }
24
70
  /**
25
71
  * Resolve API key from flags, env, credentials file, or interactive prompt.
26
72
  */
@@ -102,9 +148,19 @@ async function validateKey(apiKey) {
102
148
  if (!res.ok) {
103
149
  throw new Error(`API returned ${res.status}. Try --skip-validation to continue offline.`);
104
150
  }
105
- // ops-uluops-api wraps user payloads as { data: { email, ... } }
106
- const body = (await res.json());
107
- return { email: body.data?.email ?? null };
151
+ // ops-uluops-api wraps user payloads as { data: { email, ... } }.
152
+ // Guard against malformed responses (HTML error pages, proxy interception,
153
+ // schema drift) — bare `as` would let `data` be a string, number, or null
154
+ // and produce a silent `email: null` result that looks like a normal
155
+ // logged-in user with no email on record.
156
+ let body;
157
+ try {
158
+ body = await res.json();
159
+ }
160
+ catch {
161
+ throw new Error("API returned a non-JSON response. The endpoint may be down or behind a captive portal — try --skip-validation to continue offline.");
162
+ }
163
+ return { email: extractEmail(body) };
108
164
  }
109
165
  catch (err) {
110
166
  // fetch() throws TypeError for network failures (ENOTFOUND, ECONNREFUSED).
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  export declare const CLI_PACKAGE = "@uluops/cli";
2
3
  export declare const CLI_BIN = "ulu";
3
4
  export interface CliExecutor {
@@ -14,6 +15,11 @@ export interface CliExecutor {
14
15
  error?: string;
15
16
  };
16
17
  }
18
+ /** @internal — exported for test access to the timeout-summarization branch. */
19
+ export declare function summarizeSpawnResult(r: ReturnType<typeof spawnSync>, op: string): {
20
+ ok: boolean;
21
+ error?: string;
22
+ };
17
23
  /** Default executor — shells out to `ulu` and `npm`. */
18
24
  export declare const defaultExecutor: CliExecutor;
19
25
  export interface CliInstallResult {
package/dist/steps/cli.js CHANGED
@@ -1,12 +1,37 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  export const CLI_PACKAGE = "@uluops/cli";
3
3
  export const CLI_BIN = "ulu";
4
+ /**
5
+ * Maximum time we wait for an `npm install -g` / `npm uninstall -g` to
6
+ * complete before terminating it. Bounded to protect setup from indefinite
7
+ * hangs caused by corporate proxy stalls, lifecycle scripts awaiting stdin,
8
+ * or registry slow-response storms. 5 minutes is generous for an npm install
9
+ * of a small CLI and tight enough that users notice and can intervene.
10
+ */
11
+ const NPM_TIMEOUT_MS = 5 * 60_000;
12
+ /** Short read; 30s is plenty for `ulu --version`. */
13
+ const DETECT_TIMEOUT_MS = 30_000;
14
+ /** @internal — exported for test access to the timeout-summarization branch. */
15
+ export function summarizeSpawnResult(r, op) {
16
+ if (r.status === 0)
17
+ return { ok: true };
18
+ if (r.signal === "SIGTERM" && r.status === null) {
19
+ return {
20
+ ok: false,
21
+ error: `npm ${op} exceeded ${NPM_TIMEOUT_MS / 1000}s timeout and was terminated`,
22
+ };
23
+ }
24
+ const stderr = (r.stderr ?? "").toString().trim();
25
+ const stdout = (r.stdout ?? "").toString().trim();
26
+ return { ok: false, error: stderr || stdout || `exit ${r.status}` };
27
+ }
4
28
  /** Default executor — shells out to `ulu` and `npm`. */
5
29
  export const defaultExecutor = {
6
30
  detect: () => {
7
31
  const r = spawnSync(CLI_BIN, ["--version"], {
8
32
  encoding: "utf-8",
9
33
  stdio: ["ignore", "pipe", "ignore"],
34
+ timeout: DETECT_TIMEOUT_MS,
10
35
  });
11
36
  if (r.status !== 0 || !r.stdout)
12
37
  return null;
@@ -16,23 +41,17 @@ export const defaultExecutor = {
16
41
  const r = spawnSync("npm", ["install", "-g", CLI_PACKAGE], {
17
42
  encoding: "utf-8",
18
43
  stdio: ["ignore", "pipe", "pipe"],
44
+ timeout: NPM_TIMEOUT_MS,
19
45
  });
20
- if (r.status === 0)
21
- return { ok: true };
22
- const stderr = (r.stderr ?? "").toString().trim();
23
- const stdout = (r.stdout ?? "").toString().trim();
24
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
46
+ return summarizeSpawnResult(r, "install");
25
47
  },
26
48
  uninstall: () => {
27
49
  const r = spawnSync("npm", ["uninstall", "-g", CLI_PACKAGE], {
28
50
  encoding: "utf-8",
29
51
  stdio: ["ignore", "pipe", "pipe"],
52
+ timeout: NPM_TIMEOUT_MS,
30
53
  });
31
- if (r.status === 0)
32
- return { ok: true };
33
- const stderr = (r.stderr ?? "").toString().trim();
34
- const stdout = (r.stdout ?? "").toString().trim();
35
- return { ok: false, error: stderr || stdout || `exit ${r.status}` };
54
+ return summarizeSpawnResult(r, "uninstall");
36
55
  },
37
56
  };
38
57
  /**
@@ -7,6 +7,16 @@ export interface CommandsResult {
7
7
  removed: number;
8
8
  files: string[];
9
9
  skippedReason?: string;
10
+ /**
11
+ * Per-file copy failures across all subdirs. The loop continues past errors
12
+ * so a single bad file cannot abort the install and leave commands half-
13
+ * installed. The caller surfaces these via `warn()`. Files in this list are
14
+ * NOT counted in agent/workflow/pipeline counters (or `skipped`).
15
+ */
16
+ failures: {
17
+ file: string;
18
+ error: string;
19
+ }[];
10
20
  }
11
21
  /** Install pre-rendered command files from harness-specific assets. */
12
22
  export declare function installCommands(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestCommands?: string[]): Promise<CommandsResult>;
@@ -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
  const SUBDIRS = ["agents", "workflows", "pipelines"];
6
6
  /** Install pre-rendered command files from harness-specific assets. */
7
7
  export async function installCommands(profile, localDefs, dryRun, existingManifestCommands) {
@@ -27,6 +27,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
27
27
  removed: 0,
28
28
  files: [],
29
29
  skippedReason: "not-supported",
30
+ failures: [],
30
31
  };
31
32
  }
32
33
  let agentCommands = 0;
@@ -34,6 +35,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
34
35
  let pipelineCommands = 0;
35
36
  let skipped = 0;
36
37
  const allFiles = [];
38
+ const failures = [];
37
39
  for (const subdir of SUBDIRS) {
38
40
  const srcDir = join(srcBase, subdir);
39
41
  const destDir = join(destBase, subdir);
@@ -49,38 +51,36 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
49
51
  }
50
52
  for (const file of files) {
51
53
  const relativePath = `${subdir}/${file}`;
52
- const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
53
- if (result === "copied") {
54
- if (subdir === "agents")
55
- agentCommands++;
56
- else if (subdir === "workflows")
57
- workflowCommands++;
58
- else
59
- pipelineCommands++;
60
- }
61
- else {
62
- skipped++;
63
- }
64
- allFiles.push(relativePath);
65
- }
66
- }
67
- // Remove files that were in the old manifest but no longer in the package
68
- let removed = 0;
69
- if (existingManifestCommands) {
70
- for (const oldFile of existingManifestCommands) {
71
- if (!allFiles.includes(oldFile)) {
72
- if (!dryRun) {
73
- try {
74
- await unlink(join(destBase, oldFile));
75
- }
76
- catch {
77
- // Already gone
78
- }
54
+ try {
55
+ const result = await copyIfChanged(join(srcDir, file), join(destDir, file), dryRun);
56
+ if (result === "copied") {
57
+ if (subdir === "agents")
58
+ agentCommands++;
59
+ else if (subdir === "workflows")
60
+ workflowCommands++;
61
+ else
62
+ pipelineCommands++;
63
+ }
64
+ else {
65
+ skipped++;
79
66
  }
80
- removed++;
67
+ // Only track files that actually made it into a known state. Failed
68
+ // copies do NOT enter allFiles — otherwise the manifest's "remove
69
+ // stale entries on re-run" diff would treat a never-copied file as
70
+ // present, and an `--uninstall` would later try to unlink something
71
+ // that was never written.
72
+ allFiles.push(relativePath);
73
+ }
74
+ catch (err) {
75
+ // Continue past per-file failures (see AgentsResult.failures rationale).
76
+ failures.push({
77
+ file: relativePath,
78
+ error: err instanceof Error ? err.message : String(err),
79
+ });
81
80
  }
82
81
  }
83
82
  }
83
+ const removed = await removeStaleFiles(destBase, existingManifestCommands, allFiles, dryRun);
84
84
  return {
85
85
  agentCommands,
86
86
  workflowCommands,
@@ -88,6 +88,7 @@ export async function installCommands(profile, localDefs, dryRun, existingManife
88
88
  skipped,
89
89
  removed,
90
90
  files: allFiles,
91
+ failures,
91
92
  };
92
93
  }
93
94
  /** Remove previously installed command files by relative path. Returns count of successfully removed files. */
@@ -16,7 +16,21 @@ export async function detect() {
16
16
  const isWsl = os === "linux" && release().toLowerCase().includes("microsoft");
17
17
  const profile = getShellProfile();
18
18
  const nodeVersion = process.version;
19
- const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0] ?? "0", 10);
19
+ // Standard process.version is v-prefixed semver ("v20.10.0"). We take the
20
+ // major after stripping the "v". The `?? "0"` removed from the prior version
21
+ // here was dead code — `"".split(".")` returns `[""]`, not an empty array,
22
+ // so the nullish coalesce never fired and an unparseable major silently
23
+ // resulted in NaN, which then fell through the `< 20` gate.
24
+ const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0] ?? "", 10);
25
+ if (Number.isNaN(majorVersion)) {
26
+ // Defense in depth: a tampered binary, a wrapper that spoofs
27
+ // process.version, or a future Node release that breaks the v-prefixed
28
+ // semver convention would all silently pass the major-version gate.
29
+ // Reject with a distinct error so the user sees "your runtime is wrong"
30
+ // rather than the misleading "requires Node.js 20 or higher."
31
+ throw new Error(`Cannot parse Node.js version "${nodeVersion}". ` +
32
+ `@uluops/setup requires a standard v-prefixed semver (e.g., v20.10.0).`);
33
+ }
20
34
  if (majorVersion < 20) {
21
35
  throw new Error(`Unsupported Node.js version: ${nodeVersion}. @uluops/setup requires Node.js 20 or higher.`);
22
36
  }
package/dist/steps/mcp.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { readFile, access } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { checkMcpPackageAvailability } from "../lib/config-merger.js";
4
- import { findProjectRoot, getBackupDir } from "../lib/paths.js";
4
+ import { findProjectRoot } from "../lib/paths.js";
5
5
  import { atomicWrite } from "../lib/atomic-write.js";
6
- import { backupFile } from "../lib/file-ops.js";
7
6
  /** Write UluOps MCP server entries into a harness's config file. */
8
7
  export async function installMcp(profile, apiKey, scope, dryRun) {
9
8
  const configPath = scope === "global"
@@ -17,8 +16,6 @@ export async function installMcp(profile, apiKey, scope, dryRun) {
17
16
  packageWarnings.push(`npm packages not found in registry: ${missing.join(", ")}. MCP servers may fail to start.`);
18
17
  }
19
18
  if (!dryRun) {
20
- // Backup before first write
21
- await backupConfig(profile.name, configPath);
22
19
  await profile.mcpConfig.write(configPath, merged);
23
20
  }
24
21
  if (scope === "local" && !dryRun) {
@@ -28,14 +25,10 @@ export async function installMcp(profile, apiKey, scope, dryRun) {
28
25
  }
29
26
  /** Remove UluOps MCP server entries from the harness config. */
30
27
  export async function uninstallMcp(profile, configPath) {
31
- await backupConfig(profile.name, configPath);
32
28
  const config = await profile.mcpConfig.read(configPath);
33
29
  const cleaned = profile.mcpConfig.remove(config);
34
30
  await profile.mcpConfig.write(configPath, cleaned);
35
31
  }
36
- async function backupConfig(harnessName, configPath) {
37
- await backupFile(configPath, getBackupDir(harnessName));
38
- }
39
32
  async function addToGitignore(localConfigFilename) {
40
33
  const root = await findProjectRoot();
41
34
  try {
@@ -5,7 +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 { join } from "node:path";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  /** Where agent-metrics dist files are installed (derived from profile) */
10
11
  function getMetricsToolDir(profile) {
11
12
  return profile.paths.toolsDir;
@@ -36,8 +37,14 @@ export function getHookCommand(profile) {
36
37
  async function findMetricsSource() {
37
38
  try {
38
39
  const resolved = import.meta.resolve("@uluops/agent-metrics");
39
- // resolved is like file:///path/to/dist/index.js — get the package root
40
- const distDir = new URL(".", resolved).pathname;
40
+ // resolved is a file:// URL like file:///path/to/dist/index.js. Convert
41
+ // to a filesystem path via fileURLToPath, which handles Windows drive
42
+ // letters correctly. The previous `.pathname` access produced
43
+ // `/C:/path/to/...` on Windows (and WSL-mounted Windows paths),
44
+ // breaking the subsequent readFile of package.json — silently returning
45
+ // a null version and defeating verify's drift detection.
46
+ const distFile = fileURLToPath(resolved);
47
+ const distDir = dirname(distFile);
41
48
  const pkgRoot = join(distDir, "..");
42
49
  const version = await readSourceVersion(pkgRoot);
43
50
  return { pkgRoot, version };
@@ -1,8 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
2
  import { atomicWrite } from "../lib/atomic-write.js";
4
- import { backupFile } from "../lib/file-ops.js";
5
- import { getUluopsDir } from "../lib/paths.js";
6
3
  const FENCE_START = "# --- UluOps (managed by @uluops/setup) ---";
7
4
  const FENCE_END = "# --- /UluOps ---";
8
5
  /** Characters safe for shell variable values (no metacharacters). */
@@ -25,21 +22,18 @@ export async function writeShellExport(profilePath, apiKey, dryRun) {
25
22
  }
26
23
  const startIdx = content.indexOf(FENCE_START);
27
24
  const endIdx = content.lastIndexOf(FENCE_END);
28
- if (!dryRun) {
29
- await backupProfile(profilePath);
30
- }
31
25
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
32
26
  // Replace existing fenced block — using lastIndexOf for FENCE_END collapses
33
27
  // any duplicate blocks left by earlier buggy installs into a single new block
34
28
  const before = content.slice(0, startIdx);
35
29
  const after = content.slice(endIdx + FENCE_END.length);
36
30
  if (!dryRun) {
37
- await atomicWrite(profilePath, before + block + after);
31
+ await atomicWrite(profilePath, before + block + after, { mode: 0o600 });
38
32
  }
39
33
  }
40
34
  else {
41
35
  if (!dryRun) {
42
- await atomicWrite(profilePath, content.trimEnd() + "\n\n" + block + "\n");
36
+ await atomicWrite(profilePath, content.trimEnd() + "\n\n" + block + "\n", { mode: 0o600 });
43
37
  }
44
38
  }
45
39
  }
@@ -55,12 +49,8 @@ export async function removeShellExport(profilePath) {
55
49
  const startIdx = content.indexOf(FENCE_START);
56
50
  const endIdx = content.lastIndexOf(FENCE_END);
57
51
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
58
- await backupProfile(profilePath);
59
52
  const before = content.slice(0, startIdx);
60
53
  const after = content.slice(endIdx + FENCE_END.length);
61
- await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"));
54
+ await atomicWrite(profilePath, (before + after).replace(/\n{3,}/g, "\n\n"), { mode: 0o600 });
62
55
  }
63
56
  }
64
- async function backupProfile(profilePath) {
65
- await backupFile(profilePath, join(getUluopsDir(), "backups", "shell"));
66
- }
@@ -50,6 +50,15 @@ export async function signup() {
50
50
  email: registerRes.data.user?.email ?? email,
51
51
  };
52
52
  }
53
+ /**
54
+ * Issue a POST and decode the response into `T` via a caller-supplied
55
+ * structural predicate. `validate` is required, not optional — making it
56
+ * optional in earlier revisions meant a call site could omit it and get
57
+ * back `body as T` with only a "not null, is object" check, which is the
58
+ * pattern type-safety-validator flagged (EPI-OVR/H). Every current call
59
+ * site supplies a real predicate; this signature change locks that in for
60
+ * future call sites.
61
+ */
53
62
  async function callApi(url, method, body, bearerToken, validate) {
54
63
  const headers = {
55
64
  "Content-Type": "application/json",
@@ -77,7 +86,11 @@ async function callApi(url, method, body, bearerToken, validate) {
77
86
  if (typeof body !== "object" || body === null) {
78
87
  throw new Error("Unexpected API response shape");
79
88
  }
80
- if (validate && !validate(body)) {
89
+ // `body as T` is a type assertion, not a runtime check — the validate
90
+ // predicate is what actually proves the structure. With validate now
91
+ // required, every code path passes through a real shape check before
92
+ // the cast is trusted.
93
+ if (!validate(body)) {
81
94
  throw new Error("API response failed structural validation");
82
95
  }
83
96
  return body;
@@ -0,0 +1,14 @@
1
+ import type { HarnessProfile } from "../harnesses/index.js";
2
+ export interface SkillsResult {
3
+ copied: number;
4
+ skipped: number;
5
+ removed: number;
6
+ files: string[];
7
+ skippedReason?: string;
8
+ failures: {
9
+ file: string;
10
+ error: string;
11
+ }[];
12
+ }
13
+ export declare function installSkills(profile: HarnessProfile, localDefs: boolean, dryRun: boolean, existingManifestSkills?: string[]): Promise<SkillsResult>;
14
+ export declare function uninstallSkills(files: string[], defsPath: string): Promise<number>;
@@ -0,0 +1,95 @@
1
+ import { mkdir, readdir, rmdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ASSETS_DIR, findProjectRoot } from "../lib/paths.js";
4
+ import { copyIfChanged, removeStaleFiles, unlinkFiles } from "../lib/file-ops.js";
5
+ async function listFilesRecursive(dir, prefix = "") {
6
+ let entries;
7
+ try {
8
+ entries = await readdir(dir, { withFileTypes: true });
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ const files = [];
14
+ for (const entry of entries) {
15
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
16
+ const full = join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ files.push(...await listFilesRecursive(full, rel));
19
+ }
20
+ else if (entry.isFile()) {
21
+ files.push(rel);
22
+ }
23
+ }
24
+ return files.sort((a, b) => a.localeCompare(b));
25
+ }
26
+ export async function installSkills(profile, localDefs, dryRun, existingManifestSkills) {
27
+ const srcBase = join(ASSETS_DIR, profile.name, "skills");
28
+ const destBase = localDefs
29
+ ? join(await findProjectRoot(), "uluops", "skills")
30
+ : profile.paths.skillsDir;
31
+ if (!destBase) {
32
+ return {
33
+ copied: 0,
34
+ skipped: 0,
35
+ removed: 0,
36
+ files: [],
37
+ skippedReason: "not-supported",
38
+ failures: [],
39
+ };
40
+ }
41
+ const files = await listFilesRecursive(srcBase);
42
+ if (files.length === 0) {
43
+ return {
44
+ copied: 0,
45
+ skipped: 0,
46
+ removed: 0,
47
+ files: [],
48
+ skippedReason: "not-supported",
49
+ failures: [],
50
+ };
51
+ }
52
+ let copied = 0;
53
+ let skipped = 0;
54
+ const installedFiles = [];
55
+ const failures = [];
56
+ for (const file of files) {
57
+ const src = join(srcBase, file);
58
+ const dest = join(destBase, file);
59
+ try {
60
+ if (!dryRun) {
61
+ await mkdir(dirname(dest), { recursive: true });
62
+ }
63
+ const result = await copyIfChanged(src, dest, dryRun);
64
+ if (result === "copied")
65
+ copied++;
66
+ else
67
+ skipped++;
68
+ installedFiles.push(file);
69
+ }
70
+ catch (err) {
71
+ failures.push({
72
+ file,
73
+ error: err instanceof Error ? err.message : String(err),
74
+ });
75
+ }
76
+ }
77
+ const removed = await removeStaleFiles(destBase, existingManifestSkills, installedFiles, dryRun);
78
+ return { copied, skipped, removed, files: installedFiles, failures };
79
+ }
80
+ export async function uninstallSkills(files, defsPath) {
81
+ const skillsDir = join(defsPath, "skills");
82
+ const removed = await unlinkFiles(skillsDir, files);
83
+ const skillDirs = new Set(files
84
+ .map((file) => file.split("/")[0])
85
+ .filter((dir) => typeof dir === "string" && dir.length > 0));
86
+ for (const dir of skillDirs) {
87
+ try {
88
+ await rmdir(join(skillsDir, dir));
89
+ }
90
+ catch {
91
+ // Already gone or non-empty due to user files.
92
+ }
93
+ }
94
+ return removed;
95
+ }