@uluops/setup 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -9
- package/assets/codex/skills/uluops-operator/SKILL.md +159 -0
- package/dist/cli/select-harnesses.d.ts +91 -0
- package/dist/cli/select-harnesses.js +108 -0
- package/dist/cli.js +78 -37
- package/dist/commands/errors.d.ts +24 -0
- package/dist/commands/errors.js +28 -0
- package/dist/commands/helpers.d.ts +7 -0
- package/dist/commands/helpers.js +80 -3
- package/dist/commands/per-harness.d.ts +64 -0
- package/dist/commands/per-harness.js +37 -0
- package/dist/commands/setup.d.ts +5 -3
- package/dist/commands/setup.js +174 -48
- package/dist/commands/uninstall-filter.d.ts +36 -0
- package/dist/commands/uninstall-filter.js +69 -0
- package/dist/commands/uninstall.d.ts +12 -2
- package/dist/commands/uninstall.js +121 -45
- package/dist/harnesses/codex.d.ts +5 -10
- package/dist/harnesses/codex.js +89 -21
- package/dist/harnesses/index.js +6 -1
- package/dist/harnesses/opencode.d.ts +8 -0
- package/dist/harnesses/opencode.js +24 -1
- package/dist/harnesses/types.d.ts +2 -0
- package/dist/harnesses/types.js +5 -1
- package/dist/lib/atomic-write.js +10 -2
- package/dist/lib/config-merger.d.ts +6 -3
- package/dist/lib/config-merger.js +50 -7
- package/dist/lib/display.d.ts +21 -5
- package/dist/lib/display.js +118 -13
- package/dist/lib/file-ops.d.ts +13 -5
- package/dist/lib/file-ops.js +34 -34
- package/dist/lib/install-lock.js +11 -1
- package/dist/lib/json-guards.d.ts +22 -0
- package/dist/lib/json-guards.js +33 -0
- package/dist/lib/manifest.d.ts +20 -0
- package/dist/lib/manifest.js +61 -12
- package/dist/lib/paths.d.ts +0 -17
- package/dist/lib/paths.js +0 -19
- package/dist/lib/settings-merger.js +3 -1
- package/dist/steps/agent-metrics-cli.d.ts +9 -1
- package/dist/steps/agent-metrics-cli.js +66 -20
- package/dist/steps/agents.d.ts +11 -0
- package/dist/steps/agents.js +30 -25
- package/dist/steps/auth.d.ts +13 -0
- package/dist/steps/auth.js +61 -5
- package/dist/steps/cli.d.ts +6 -0
- package/dist/steps/cli.js +29 -10
- package/dist/steps/commands.d.ts +10 -0
- package/dist/steps/commands.js +31 -30
- package/dist/steps/detect.js +15 -1
- package/dist/steps/mcp.js +1 -8
- package/dist/steps/metrics.js +10 -3
- package/dist/steps/shell.js +3 -13
- package/dist/steps/signup.js +14 -1
- package/dist/steps/skills.d.ts +14 -0
- package/dist/steps/skills.js +95 -0
- package/dist/steps/verify.js +195 -91
- package/package.json +3 -2
package/dist/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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/steps/commands.d.ts
CHANGED
|
@@ -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>;
|
package/dist/steps/commands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readdir, mkdir
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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. */
|
package/dist/steps/detect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
package/dist/steps/metrics.js
CHANGED
|
@@ -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
|
|
40
|
-
|
|
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 };
|
package/dist/steps/shell.js
CHANGED
|
@@ -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
|
-
}
|
package/dist/steps/signup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|