@rryando/arcs 3.0.0 → 3.1.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 (48) hide show
  1. package/README.md +246 -340
  2. package/dist/cli/arcs-orchestrate.d.ts +1 -1
  3. package/dist/cli/arcs-orchestrate.d.ts.map +1 -1
  4. package/dist/cli/arcs-orchestrate.js +4 -3
  5. package/dist/cli/arcs-orchestrate.js.map +1 -1
  6. package/dist/cli/commands/next.js +26 -5
  7. package/dist/cli/commands/next.js.map +1 -1
  8. package/dist/cli/commands/task.d.ts.map +1 -1
  9. package/dist/cli/commands/task.js +14 -2
  10. package/dist/cli/commands/task.js.map +1 -1
  11. package/dist/retrieval/graph-builder.d.ts.map +1 -1
  12. package/dist/retrieval/graph-builder.js +18 -0
  13. package/dist/retrieval/graph-builder.js.map +1 -1
  14. package/dist/retrieval/graph-types.d.ts +1 -1
  15. package/dist/retrieval/graph-types.d.ts.map +1 -1
  16. package/dist/retrieval/graph-types.js +1 -0
  17. package/dist/retrieval/graph-types.js.map +1 -1
  18. package/dist/utils/diagram-generator.d.ts +3 -2
  19. package/dist/utils/diagram-generator.d.ts.map +1 -1
  20. package/dist/utils/diagram-generator.js +67 -6
  21. package/dist/utils/diagram-generator.js.map +1 -1
  22. package/dist/utils/errors.d.ts +2 -0
  23. package/dist/utils/errors.d.ts.map +1 -1
  24. package/dist/utils/errors.js +6 -0
  25. package/dist/utils/errors.js.map +1 -1
  26. package/dist/utils/task-store.d.ts +3 -0
  27. package/dist/utils/task-store.d.ts.map +1 -1
  28. package/dist/utils/task-store.js +35 -1
  29. package/dist/utils/task-store.js.map +1 -1
  30. package/dist/utils/toposort.d.ts +21 -0
  31. package/dist/utils/toposort.d.ts.map +1 -0
  32. package/dist/utils/toposort.js +126 -0
  33. package/dist/utils/toposort.js.map +1 -0
  34. package/dist/utils/workflow-policy.d.ts +1 -0
  35. package/dist/utils/workflow-policy.d.ts.map +1 -1
  36. package/dist/utils/workflow-policy.js +18 -2
  37. package/dist/utils/workflow-policy.js.map +1 -1
  38. package/opencode/arcs/prompts/arcs-orchestrate-caveman.txt +4 -3
  39. package/opencode/arcs/prompts/arcs-orchestrate.txt +4 -3
  40. package/opencode/arcs/skills/brainstorming/SKILL.md +2 -0
  41. package/opencode/arcs/skills/executing-plans/SKILL.md +2 -0
  42. package/opencode/arcs/skills/to-diagram/SKILL.md +2 -0
  43. package/package.json +4 -3
  44. package/scripts/arcs-init.mjs +81 -0
  45. package/scripts/build-opencode-bundle.mjs +178 -0
  46. package/scripts/deploy-opencode-bundle.mjs +203 -0
  47. package/scripts/lib/bundle-helpers.mjs +172 -0
  48. package/scripts/lint-bundle.mjs +206 -0
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ARCS CLI global registration
4
+ // Usage: node scripts/arcs-init.mjs [-g] [--uninstall]
5
+ // Creates symlink ~/.local/bin/arcs → scripts/arcs-cli.mjs
6
+
7
+ import { existsSync, lstatSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync } from "node:fs";
8
+ import { resolve, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { execSync } from "node:child_process";
11
+
12
+ // lstatSync-based check: works even for dangling symlinks (existsSync follows the target)
13
+ function linkExists(p) {
14
+ try { lstatSync(p); return true; } catch { return false; }
15
+ }
16
+
17
+ function isCommandAvailable(cmd) {
18
+ try {
19
+ execSync(`command -v ${cmd}`, { stdio: "ignore", shell: true });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const cliPath = resolve(__dirname, "arcs-cli.mjs");
28
+ const binDir = resolve(process.env.HOME || "~", ".local/bin");
29
+ const linkPath = resolve(binDir, "arcs");
30
+ const uninstall = process.argv.includes("--uninstall");
31
+
32
+ if (uninstall) {
33
+ if (linkExists(linkPath)) {
34
+ unlinkSync(linkPath);
35
+ console.log(`Removed: ${linkPath}`);
36
+ } else {
37
+ console.log("Nothing to remove.");
38
+ }
39
+ process.exit(0);
40
+ }
41
+
42
+ // Ensure bin dir
43
+ if (!existsSync(binDir)) {
44
+ mkdirSync(binDir, { recursive: true });
45
+ }
46
+
47
+ // Remove stale symlink if exists
48
+ if (linkExists(linkPath)) {
49
+ const current = readlinkSync(linkPath);
50
+ if (current === cliPath) {
51
+ console.log(`Already registered: ${linkPath} → ${cliPath}`);
52
+ process.exit(0);
53
+ }
54
+ unlinkSync(linkPath);
55
+ }
56
+
57
+ symlinkSync(cliPath, linkPath);
58
+ console.log(`Registered: ${linkPath} → ${cliPath}`);
59
+
60
+ // PATH check
61
+ const pathDirs = (process.env.PATH || "").split(":");
62
+ if (!pathDirs.includes(binDir)) {
63
+ console.warn(`\nWARNING: ${binDir} is not in PATH.`);
64
+ console.warn(`Add to your shell rc: export PATH="$HOME/.local/bin:$PATH"`);
65
+ }
66
+
67
+ console.log(`\nUsage: arcs <command> [args]`);
68
+ console.log(`Commands: context, task, plan, knowledge, search, diagram, batch, validate`);
69
+
70
+ // Dependency checks
71
+ console.log("");
72
+ if (!isCommandAvailable("gh")) {
73
+ console.warn(`WARNING: gh (GitHub CLI) not found.`);
74
+ console.warn(` Skills like deep-pr-review require it: https://cli.github.com/`);
75
+ }
76
+
77
+ if (!isCommandAvailable("rtk")) {
78
+ console.warn(`WARNING: rtk not found.`);
79
+ console.warn(` RTK improves AI command usage tracking: https://github.com/rtk-ai/rtk`);
80
+ console.warn(` Install: rtk init -g (or rtk init -g --opencode for OpenCode)`);
81
+ }
@@ -0,0 +1,178 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import {
6
+ normalizeRelativePath,
7
+ listDeclaredFiles,
8
+ validateDeclaredPath,
9
+ } from "./lib/bundle-helpers.mjs";
10
+
11
+ const repoRoot = resolve(import.meta.dirname, "..");
12
+ const defaultManifestPath = resolve(repoRoot, "opencode/arcs/bundle-runtime.json");
13
+ const defaultOutputRoot = resolve(repoRoot, "opencode/arcs");
14
+ // Files that are repo-authored and must not be pruned. The repo bundle
15
+ // directory IS the source of truth — there is no external mirror.
16
+ const preservedOutputFiles = new Set([
17
+ "manifest.json",
18
+ "bundle-runtime.json",
19
+ ".opencode/plugins/arcs.js",
20
+ // ARCS-native skills (authored in this repo, no upstream source)
21
+ // init-project skill — ARCS-native (mirrors orchestrator INIT workflow with
22
+ // graphify sub-flow, typed-agent dispatch, knowledge categories).
23
+ "skills/init-project/SKILL.md",
24
+ // Caveman commit skill — adapted from https://github.com/JuliusBrussee/caveman (MIT).
25
+ "skills/caveman-commit/SKILL.md",
26
+ // Agent prompt files (repo-authored, referenced via {file:} in manifest.json)
27
+ "prompts/software-engineer.txt",
28
+ "prompts/tech-architect.txt",
29
+ "prompts/qa-analyst.txt",
30
+ "prompts/oncall-ops.txt",
31
+ "prompts/arcs-docs.txt",
32
+ "prompts/system-architect.txt",
33
+ "prompts/code-reviewer.txt",
34
+ "prompts/docs-researcher.txt",
35
+ "prompts/devil-advocate.txt",
36
+ // Orchestrator prompt files — generated from src/cli/arcs-orchestrate*.ts during
37
+ // bundle build (see generateOrchestratorPrompts() below). TS modules remain the
38
+ // canonical source; these .txt files are committed mirrors so the bundle is
39
+ // self-describing and all prompts live in one directory.
40
+ "prompts/arcs-orchestrate.txt",
41
+ "prompts/arcs-orchestrate-caveman.txt",
42
+ ]);
43
+
44
+ function ensureParentDirectory(filePath) {
45
+ mkdirSync(dirname(filePath), { recursive: true });
46
+ }
47
+
48
+ function pruneUndeclaredFiles(rootPath, allowedFiles) {
49
+ for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
50
+ const entryPath = resolve(rootPath, entry.name);
51
+
52
+ if (entry.isDirectory()) {
53
+ pruneUndeclaredFiles(entryPath, allowedFiles);
54
+
55
+ if (readdirSync(entryPath).length === 0) {
56
+ rmSync(entryPath, { recursive: true, force: true });
57
+ }
58
+
59
+ continue;
60
+ }
61
+
62
+ const relativePath = normalizeRelativePath(relative(defaultOutputRootCurrent, entryPath));
63
+ if (!allowedFiles.has(relativePath)) {
64
+ rmSync(entryPath, { force: true });
65
+ }
66
+ }
67
+ }
68
+
69
+ let defaultOutputRootCurrent = defaultOutputRoot;
70
+
71
+ /**
72
+ * Generates the ARCS Orchestrator and ARCS Caveman prompt .txt files into
73
+ * <outputRoot>/prompts/. The TypeScript modules src/cli/arcs-orchestrate.ts
74
+ * and src/cli/arcs-orchestrate-caveman.ts remain the canonical source; these
75
+ * .txt files are committed mirrors so the bundle is self-describing alongside
76
+ * the static sub-agent prompts.
77
+ *
78
+ * Requires `tsc` to have run first (dist/cli/arcs-orchestrate.js must exist).
79
+ * package.json's build:opencode-bundle chains `tsc` before this script.
80
+ */
81
+ async function generateOrchestratorPrompts(outputRoot) {
82
+ const orchestrateModulePath = resolve(repoRoot, "dist/cli/arcs-orchestrate.js");
83
+ const cavemanModulePath = resolve(repoRoot, "dist/cli/arcs-orchestrate-caveman.js");
84
+
85
+ if (!existsSync(orchestrateModulePath) || !existsSync(cavemanModulePath)) {
86
+ throw new Error(
87
+ `Compiled orchestrator modules missing. Run \`npm run build\` before bundle build.\n` +
88
+ ` Expected: ${orchestrateModulePath}\n` +
89
+ ` Expected: ${cavemanModulePath}`, );
90
+ }
91
+
92
+ const orchestrateModule = await import(pathToFileURL(orchestrateModulePath).href);
93
+ const cavemanModule = await import(pathToFileURL(cavemanModulePath).href);
94
+
95
+ const orchestrateText = orchestrateModule.ORCHESTRATE_PROMPT_TEXT;
96
+ const cavemanText = cavemanModule.ORCHESTRATE_CAVEMAN_PROMPT_TEXT;
97
+
98
+ if (typeof orchestrateText !== "string" || orchestrateText.length === 0) {
99
+ throw new Error("ORCHESTRATE_PROMPT_TEXT not exported as non-empty string");
100
+ }
101
+ if (typeof cavemanText !== "string" || cavemanText.length === 0) {
102
+ throw new Error("ORCHESTRATE_CAVEMAN_PROMPT_TEXT not exported as non-empty string");
103
+ }
104
+
105
+ const promptsDir = resolve(outputRoot, "prompts");
106
+ mkdirSync(promptsDir, { recursive: true });
107
+
108
+ const orchestratePath = resolve(promptsDir, "arcs-orchestrate.txt");
109
+ const cavemanPath = resolve(promptsDir, "arcs-orchestrate-caveman.txt");
110
+
111
+ // Banner prepended to every generated prompt file. Uses HTML comment syntax
112
+ // so it's invisible when rendered as markdown but obvious to anyone opening
113
+ // the .txt directly. LLMs treat HTML comments as out-of-band metadata, so
114
+ // the banner does not pollute the prompt's actionable instructions.
115
+ const banner = (sourceFile) =>
116
+ `<!--\n` +
117
+ ` AUTO-GENERATED — DO NOT EDIT.\n` +
118
+ ` Source of truth: ${sourceFile}\n` +
119
+ ` Regenerate: npm run build:opencode-bundle\n` +
120
+ ` Edits to this file will be overwritten on the next build.\n` +
121
+ `-->\n\n`;
122
+
123
+ writeFileSync(
124
+ orchestratePath,
125
+ `${banner("src/cli/arcs-orchestrate.ts")}${orchestrateText}\n`,
126
+ "utf-8",
127
+ );
128
+ writeFileSync(
129
+ cavemanPath,
130
+ `${banner("src/cli/arcs-orchestrate-caveman.ts")}${cavemanText}\n`,
131
+ "utf-8",
132
+ );
133
+ }
134
+
135
+ async function main() {
136
+ const manifestPath = process.env.ARCS_BUNDLE_RUNTIME_MANIFEST
137
+ ? resolve(repoRoot, process.env.ARCS_BUNDLE_RUNTIME_MANIFEST)
138
+ : defaultManifestPath;
139
+ const runtimeManifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
140
+ const outputRoot = process.env.ARCS_BUNDLE_OUTPUT_ROOT
141
+ ? resolve(repoRoot, process.env.ARCS_BUNDLE_OUTPUT_ROOT)
142
+ : defaultOutputRoot;
143
+
144
+ const declaredFiles = listDeclaredFiles(runtimeManifest);
145
+ const allowedOutputFiles = new Set([
146
+ ...declaredFiles.map((entry) => entry.declaredPath),
147
+ ...preservedOutputFiles,
148
+ ]);
149
+
150
+ defaultOutputRootCurrent = outputRoot;
151
+
152
+ // Validate that every manifest-declared file already exists in the bundle.
153
+ // The bundle directory IS the source of truth — files are authored here,
154
+ // not copied from anywhere external.
155
+ for (const { declaredPath, validationRoot } of declaredFiles) {
156
+ const relativePath = validateDeclaredPath(declaredPath, outputRoot, validationRoot);
157
+ const outputPath = resolve(outputRoot, relativePath);
158
+ if (!existsSync(outputPath)) {
159
+ throw new Error(`Missing declared bundle file: ${relativePath} (${outputPath})`);
160
+ }
161
+ ensureParentDirectory(outputPath);
162
+ }
163
+
164
+ mkdirSync(outputRoot, { recursive: true });
165
+ pruneUndeclaredFiles(outputRoot, allowedOutputFiles);
166
+
167
+ // Generate orchestrator prompt mirrors after prune so they always end up
168
+ // on disk fresh from the canonical TS sources.
169
+ await generateOrchestratorPrompts(outputRoot);
170
+ }
171
+
172
+ try {
173
+ await main();
174
+ } catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ process.stderr.write(`${message}\n`);
177
+ process.exitCode = 1;
178
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+ // Deploy opencode ARCS bundle bundle from repo to user config.
3
+ //
4
+ // Direction: repo → config ONLY. Never writes config → repo.
5
+ //
6
+ // Env vars:
7
+ // DEPLOY_BUNDLE_ROOT — override bundle root (default: opencode/arcs)
8
+ // DEPLOY_CONFIG_ROOT — override config root (default: ~/.config/opencode)
9
+ // DEPLOY_DRY_RUN — "false" to actually copy; anything else = dry-run (default: dry-run)
10
+ //
11
+ // Outputs JSON to stdout: DeployResult
12
+ // Exit code: 0 on success, 1 on error.
13
+
14
+ import {
15
+ copyFileSync,
16
+ existsSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, relative, resolve } from "node:path";
26
+
27
+ const repoRoot = resolve(import.meta.dirname, "..");
28
+ const defaultBundleRoot = resolve(repoRoot, "opencode/arcs");
29
+ const defaultConfigRoot = resolve(homedir(), ".config/opencode");
30
+
31
+ const bundleRoot = process.env.DEPLOY_BUNDLE_ROOT
32
+ ? resolve(repoRoot, process.env.DEPLOY_BUNDLE_ROOT)
33
+ : defaultBundleRoot;
34
+ const configRoot = process.env.DEPLOY_CONFIG_ROOT
35
+ ? resolve(repoRoot, process.env.DEPLOY_CONFIG_ROOT)
36
+ : defaultConfigRoot;
37
+ // Dry-run by default. Only DEPLOY_DRY_RUN=false (exact string) triggers real writes.
38
+ const dryRun = process.env.DEPLOY_DRY_RUN !== "false";
39
+
40
+ function listAllFiles(rootPath, currentPath = rootPath) {
41
+ if (!existsSync(currentPath)) return [];
42
+ const entries = readdirSync(currentPath, { withFileTypes: true });
43
+ return entries.flatMap((entry) => {
44
+ const entryPath = resolve(currentPath, entry.name);
45
+ if (entry.isDirectory()) return listAllFiles(rootPath, entryPath);
46
+ return [relative(rootPath, entryPath).replace(/\\/g, "/")];
47
+ });
48
+ }
49
+
50
+ function ensureParentDir(filePath) {
51
+ mkdirSync(dirname(filePath), { recursive: true });
52
+ }
53
+
54
+ async function main() {
55
+ const manifestPath = resolve(bundleRoot, "manifest.json");
56
+ if (!existsSync(manifestPath)) {
57
+ throw new Error(`manifest.json not found at ${manifestPath}`);
58
+ }
59
+
60
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
61
+
62
+ // Build mapping: config-relative path → bundle-absolute path
63
+ const deployMap = new Map();
64
+
65
+ // Skills: bundle skills/<name>/<file> → config skills/arcs/<name>/<file>
66
+ const skillsSourceDir = resolve(bundleRoot, manifest.skills.source);
67
+ if (existsSync(skillsSourceDir)) {
68
+ const skillFiles = listAllFiles(skillsSourceDir);
69
+ for (const file of skillFiles) {
70
+ const configRelative = `${manifest.skills.destination}/${file}`;
71
+ const bundleAbsolute = resolve(skillsSourceDir, file);
72
+ deployMap.set(configRelative, bundleAbsolute);
73
+ }
74
+ }
75
+
76
+ // Plugin
77
+ if (manifest.plugin && manifest.plugin.source) {
78
+ const pluginSource = resolve(bundleRoot, manifest.plugin.source);
79
+ if (existsSync(pluginSource)) {
80
+ deployMap.set(manifest.plugin.destination, pluginSource);
81
+ }
82
+ }
83
+
84
+ // Agents (sub-agent prompts) — bundle prompts/<file> → config prompts/<file>
85
+ for (const agent of manifest.agents ?? []) {
86
+ const agentSource = resolve(bundleRoot, agent.source);
87
+ if (existsSync(agentSource)) {
88
+ deployMap.set(agent.destination, agentSource);
89
+ }
90
+ }
91
+
92
+ // Pass 1: Determine file states (detection only — no writes yet)
93
+ const filesAdded = [];
94
+ const filesChanged = [];
95
+ const filesUnchanged = [];
96
+ let restartRequired = false;
97
+
98
+ for (const [configRelative, bundleAbsolute] of deployMap) {
99
+ const configAbsolute = resolve(configRoot, configRelative);
100
+ const sourceContent = readFileSync(bundleAbsolute, "utf-8");
101
+
102
+ const isNew = !existsSync(configAbsolute);
103
+ const isChanged =
104
+ !isNew && readFileSync(configAbsolute, "utf-8") !== sourceContent;
105
+
106
+ if (isNew) {
107
+ filesAdded.push(configRelative);
108
+ } else if (isChanged) {
109
+ filesChanged.push(configRelative);
110
+ } else {
111
+ filesUnchanged.push(configRelative);
112
+ }
113
+
114
+ // Plugin change/add → restart required
115
+ if (configRelative === manifest.plugin?.destination && (isNew || isChanged)) {
116
+ restartRequired = true;
117
+ }
118
+ }
119
+
120
+ // Detect files to remove: files in owned paths in config that are NOT in deployMap
121
+ const filesRemoved = [];
122
+ for (const ownedPath of manifest.ownedPaths ?? []) {
123
+ const ownedAbsolute = resolve(configRoot, ownedPath);
124
+ if (!existsSync(ownedAbsolute)) continue;
125
+
126
+ // Owned path may be a file or directory
127
+ if (!lstatSync(ownedAbsolute).isDirectory()) {
128
+ if (!deployMap.has(ownedPath)) {
129
+ filesRemoved.push(ownedPath);
130
+ }
131
+ continue;
132
+ }
133
+
134
+ const existingFiles = listAllFiles(ownedAbsolute);
135
+ for (const file of existingFiles) {
136
+ const configRelative = `${ownedPath}/${file}`;
137
+ if (!deployMap.has(configRelative)) {
138
+ filesRemoved.push(configRelative);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Pass 2: Apply writes (only when not dry-run)
144
+ if (!dryRun) {
145
+ // Clean-delete the skills directory before copying to guarantee a fresh install.
146
+ // Prevents residual files from renamed/removed skills surviving across deploys.
147
+ const skillsDest = resolve(configRoot, manifest.skills.destination);
148
+ if (existsSync(skillsDest)) {
149
+ rmSync(skillsDest, { recursive: true, force: true });
150
+ }
151
+
152
+ // Write all files from deployMap (recreates skills dir + copies plugin + agents)
153
+ for (const [configRelative, bundleAbsolute] of deployMap) {
154
+ const configAbsolute = resolve(configRoot, configRelative);
155
+ ensureParentDir(configAbsolute);
156
+ copyFileSync(bundleAbsolute, configAbsolute);
157
+ }
158
+
159
+ // Remove orphans from other owned paths (skills dir already cleared above; force: true
160
+ // makes this a no-op for any skills paths that were already wiped)
161
+ for (const fileToRemove of filesRemoved) {
162
+ rmSync(resolve(configRoot, fileToRemove), { force: true });
163
+ }
164
+ }
165
+
166
+ // After successful deploy, ensure arcs CLI is globally registered
167
+ if (!dryRun) {
168
+ try {
169
+ const { execFileSync } = await import("node:child_process");
170
+ const initScript = resolve(repoRoot, "scripts/arcs-init.mjs");
171
+ if (existsSync(initScript)) {
172
+ execFileSync(process.execPath, [initScript], { stdio: "pipe" });
173
+ }
174
+ } catch {
175
+ // Non-fatal: CLI registration is a convenience, not a requirement
176
+ }
177
+ }
178
+
179
+ const result = {
180
+ dryRun,
181
+ source: bundleRoot,
182
+ destination: configRoot,
183
+ filesAdded,
184
+ filesChanged,
185
+ filesRemoved,
186
+ filesUnchanged,
187
+ restartRequired,
188
+ cliRegistered: !dryRun,
189
+ ...(restartRequired && {
190
+ restartGuidance: "Plugin file changed. Restart opencode for changes to take effect.",
191
+ }),
192
+ };
193
+
194
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
195
+ }
196
+
197
+ try {
198
+ main();
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ process.stderr.write(`${message}\n`);
202
+ process.exitCode = 1;
203
+ }
@@ -0,0 +1,172 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+
3
+ export function normalizeRelativePath(filePath) {
4
+ return filePath.replace(/\\/g, "/");
5
+ }
6
+
7
+ export function looksWindowsAbsolute(filePath) {
8
+ return /^[A-Za-z]:[\\/]/.test(filePath) || /^\\\\/.test(filePath);
9
+ }
10
+
11
+ export function assertNoReservedPathSegments(candidatePath, reportedPath = candidatePath) {
12
+ const normalizedPath = normalizeRelativePath(candidatePath);
13
+
14
+ if (!normalizedPath || normalizedPath === ".") {
15
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
16
+ }
17
+
18
+ if (isAbsolute(normalizedPath) || looksWindowsAbsolute(normalizedPath)) {
19
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
20
+ }
21
+
22
+ for (const segment of normalizedPath.split("/")) {
23
+ if (!segment || segment === "." || segment === "..") {
24
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
25
+ }
26
+ }
27
+
28
+ return normalizedPath;
29
+ }
30
+
31
+ export function assertSafeOutputPath(candidatePath, reportedPath = candidatePath) {
32
+ const normalizedPath = normalizeRelativePath(candidatePath);
33
+
34
+ if (!normalizedPath || normalizedPath === ".") {
35
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
36
+ }
37
+
38
+ if (isAbsolute(normalizedPath) || looksWindowsAbsolute(normalizedPath)) {
39
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
40
+ }
41
+
42
+ return normalizedPath;
43
+ }
44
+
45
+ export function assertPathWithinCategoryRoot(candidatePath, categoryRoot, reportedPath = candidatePath) {
46
+ const normalizedPath = assertSafeOutputPath(candidatePath, reportedPath);
47
+ const normalizedCategoryRoot = assertNoReservedPathSegments(categoryRoot, reportedPath);
48
+ const categoryRelativePath = normalizeRelativePath(
49
+ relative(normalizedCategoryRoot, normalizedPath),
50
+ );
51
+
52
+ if (
53
+ normalizedPath !== normalizedCategoryRoot &&
54
+ (categoryRelativePath.startsWith("../") || categoryRelativePath === "..")
55
+ ) {
56
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
57
+ }
58
+
59
+ return normalizedPath;
60
+ }
61
+
62
+ export function assertTopLevelMarkdownFile(candidatePath, categoryRoot, reportedPath = candidatePath) {
63
+ const normalizedPath = assertPathWithinCategoryRoot(candidatePath, categoryRoot, reportedPath);
64
+ const normalizedCategoryRoot = assertNoReservedPathSegments(categoryRoot, reportedPath);
65
+ const categoryRelativePath = normalizeRelativePath(
66
+ relative(normalizedCategoryRoot, normalizedPath),
67
+ );
68
+
69
+ if (
70
+ !categoryRelativePath ||
71
+ categoryRelativePath.includes("/") ||
72
+ !categoryRelativePath.endsWith(".md")
73
+ ) {
74
+ throw new Error(`Invalid declared runtime path: ${reportedPath}`);
75
+ }
76
+
77
+ return normalizedPath;
78
+ }
79
+
80
+ export function listDeclaredFiles(runtimeManifest) {
81
+ const declaredFiles = [];
82
+
83
+ for (const [skillName, skillFiles] of Object.entries(runtimeManifest.skills ?? {})) {
84
+ for (const skillFile of skillFiles) {
85
+ const normalizedSkillName = normalizeRelativePath(skillName);
86
+ const normalizedSkillFile = normalizeRelativePath(skillFile);
87
+ const declaredPath = `skills/${normalizedSkillName}/${normalizedSkillFile}`;
88
+
89
+ assertNoReservedPathSegments(normalizedSkillName, declaredPath);
90
+ assertNoReservedPathSegments(normalizedSkillFile, declaredPath);
91
+ assertSafeOutputPath(declaredPath);
92
+
93
+ declaredFiles.push({
94
+ declaredPath,
95
+ validationRoot: `skills/${normalizedSkillName}`,
96
+ });
97
+ }
98
+ }
99
+
100
+ for (const agentFile of runtimeManifest.agents ?? []) {
101
+ const declaredPath = assertTopLevelMarkdownFile(
102
+ assertNoReservedPathSegments(normalizeRelativePath(agentFile)),
103
+ "agents",
104
+ agentFile,
105
+ );
106
+
107
+ declaredFiles.push({
108
+ declaredPath,
109
+ validationRoot: "agents",
110
+ });
111
+ }
112
+
113
+ for (const pluginFile of runtimeManifest.plugin ?? []) {
114
+ const declaredPath = assertPathWithinCategoryRoot(
115
+ assertNoReservedPathSegments(normalizeRelativePath(pluginFile)),
116
+ ".opencode/plugins",
117
+ pluginFile,
118
+ );
119
+
120
+ declaredFiles.push({
121
+ declaredPath,
122
+ validationRoot: ".opencode/plugins",
123
+ });
124
+ }
125
+
126
+ return declaredFiles;
127
+ }
128
+
129
+ export function listSourceSkillNames(sourceRoot) {
130
+ // DEPRECATED: source-root mirroring removed. The repo bundle directory is
131
+ // the source of truth. This export is retained as a no-op for any external
132
+ // consumer (e.g. ad-hoc scripts) and can be deleted once no callers remain.
133
+ void sourceRoot;
134
+ return [];
135
+ }
136
+
137
+ export function listSourceAgentPaths(sourceRoot) {
138
+ // DEPRECATED: see listSourceSkillNames.
139
+ void sourceRoot;
140
+ return [];
141
+ }
142
+
143
+ export function assertSourceParity(runtimeManifest, sourceRoot, arcsNativeSkillNames) {
144
+ // DEPRECATED: source-root mirroring removed. The repo bundle directory is
145
+ // the source of truth — there is no external mirror to assert parity with.
146
+ // Retained as a no-op so any external caller continues to import cleanly.
147
+ void runtimeManifest;
148
+ void sourceRoot;
149
+ void arcsNativeSkillNames;
150
+ }
151
+
152
+ export function validateDeclaredPath(relativePath, outputRoot, validationRoot) {
153
+ const normalizedPath = assertSafeOutputPath(relativePath);
154
+ const normalizedValidationRoot = assertSafeOutputPath(validationRoot, relativePath);
155
+
156
+ const outputPath = resolve(outputRoot, normalizedPath);
157
+ const outputRelativePath = normalizeRelativePath(relative(outputRoot, outputPath));
158
+
159
+ if (outputRelativePath.startsWith("../") || outputRelativePath === "..") {
160
+ throw new Error(`Invalid declared runtime path: ${relativePath}`);
161
+ }
162
+
163
+ const validationRelativePath = normalizeRelativePath(
164
+ relative(normalizedValidationRoot, outputRelativePath),
165
+ );
166
+
167
+ if (validationRelativePath.startsWith("../") || validationRelativePath === "..") {
168
+ throw new Error(`Invalid declared runtime path: ${relativePath}`);
169
+ }
170
+
171
+ return normalizedPath;
172
+ }