@snarkipus/opencode-beads 0.8.0 → 0.9.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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  [Beads](https://github.com/gastownhall/beads) issue tracker integration for [OpenCode](https://opencode.ai).
4
4
 
5
- This maintained fork provides the OpenCode-facing product layer around Beads: full `bd prime` context injection, vendored workflows, an autonomous task agent, project-scoped runtime integration, and an explicit managed companion skill lifecycle. It is deliberately an adapter rather than a second issue tracker: the `bd` CLI remains authoritative for issues, Dolt synchronization, migrations, and general Beads behavior.
5
+ This maintained fork provides the OpenCode-facing product layer around Beads: full `bd prime` context injection, vendored workflows, an autonomous task agent, and project-scoped runtime integration. It is deliberately an adapter rather than a second issue tracker: the `bd` CLI remains authoritative for issues, project initialization, skills, Dolt synchronization, migrations, and general Beads behavior.
6
6
 
7
7
  The project originated as [Josh Thomas's `opencode-beads`](https://github.com/joshuadavidthomas/opencode-beads) and continues under the MIT license. This fork is maintained by Matt Jackson and tracks reviewed upstream Beads releases while adapting their plugin artifacts to OpenCode's CLI-only execution model.
8
8
 
9
9
  ## Installation
10
10
 
11
- Install the beads CLI:
11
+ Install the `bd` CLI once on the host:
12
12
 
13
13
  ```bash
14
14
  curl -fsSL https://raw.githubusercontent.com/gastownhall/beads/main/scripts/install.sh | bash
@@ -20,65 +20,48 @@ Add the version-pinned plugin to your OpenCode config (`~/.config/opencode/openc
20
20
 
21
21
  ```json
22
22
  {
23
- "plugin": ["@snarkipus/opencode-beads@0.8.0"]
23
+ "plugin": ["@snarkipus/opencode-beads@0.9.1"]
24
24
  }
25
25
  ```
26
26
 
27
27
  Restart OpenCode. OpenCode caches pinned plugins, so update this version explicitly when upgrading. An unpinned `@snarkipus/opencode-beads` entry follows npm updates on startup but is less reproducible.
28
28
 
29
- In a project without a Beads database, run `/beads:init` or `bd init` before using issue commands. This initializes Beads data only; it does not install the companion OpenCode skill.
30
-
31
- ### Optional skill
32
-
33
- Install the companion OpenCode skill for durable Beads guidance in the current Git worktree:
29
+ Initialize every new project separately. Create a Git worktree first if necessary, then run `bd init`:
34
30
 
35
31
  ```bash
36
- bunx @snarkipus/opencode-beads@0.8.0 init
32
+ git init
33
+ bd init
37
34
  ```
38
35
 
39
- The project target is `<worktree>/.opencode/skills/beads`, even when the command runs from a nested directory. Use `init --global` for `$XDG_CONFIG_HOME/opencode/skills/beads` when `XDG_CONFIG_HOME` is absolute, otherwise `~/.config/opencode/skills/beads`.
36
+ Skip `git init` when the project is already a Git worktree. `bd init` performs the per-project Beads initialization and its automatic Codex project integration creates the canonical shared skill at `.agents/skills/beads`, which OpenCode also discovers. `/beads:init` invokes the same project initialization workflow; this plugin has no separate skill lifecycle or companion CLI.
37
+
38
+ ### Upgrading from 0.8.0
40
39
 
41
- The package CLI is the canonical lifecycle because OpenCode does not guarantee that plugin npm bins are on `PATH`:
40
+ Version 0.8.0 installed a fork-managed skill under `.opencode/skills/beads`. Remove that old managed skill with the version-pinned 0.8.0 CLI before changing the plugin pin to 0.9.1:
42
41
 
43
42
  ```bash
44
- bunx @snarkipus/opencode-beads@0.8.0 init [--global]
45
- bunx @snarkipus/opencode-beads@0.8.0 check [--global]
46
- bunx @snarkipus/opencode-beads@0.8.0 update [--global]
47
- bunx @snarkipus/opencode-beads@0.8.0 remove [--global]
43
+ bunx @snarkipus/opencode-beads@0.8.0 remove
48
44
  ```
49
45
 
50
- Every command supports `--dry-run` for an exact non-mutating plan and `--json` for one deterministic result object. Add `--global` to select the global scope. `/beads:setup` prints commands for the running package version; it does not write files. `/beads:init` remains DB-only.
51
-
52
- Lifecycle states are:
53
-
54
- | State | Meaning |
55
- | --- | --- |
56
- | `missing` | The selected target does not exist. |
57
- | `current` | The ownership manifest, package metadata, inventory, and file hashes match. |
58
- | `stale` | The target is recognized and unmodified, but its managed package or provenance metadata differs from the running package. |
59
- | `modified` | The target has a recognized manifest, but a managed file, hash, symlink, or inventory differs. |
60
- | `conflicting` | The target is unmanaged or differently managed, another discovered Beads skill blocks the operation, or stale transaction residue exists. |
61
-
62
- `init` installs a missing target or safely refreshes a recognized stale target. `update` changes only a recognized stale target whose old managed hashes still match. `init` and `update` are no-ops for current targets. `remove` deletes only a recognized, unmodified current or stale target. Modified and conflicting targets are never overwritten or removed, and there is no force option.
46
+ If the old skill was installed globally, append `--global` to that exact command. Do not substitute 0.9.1 in the removal command: the companion CLI no longer exists in 0.9.1.
63
47
 
64
- Before `init` or `update`, discovery checks `.opencode/skills/beads`, `.agents/skills/beads`, and `.claude/skills/beads` from the current directory through the Git worktree root, plus their global equivalents. Unrelated discovered skills block writes. `remove` may remove the selected verified-owned target despite an unrelated skill elsewhere, but stale transaction residue still blocks it.
48
+ For a repository that has not been initialized with Beads, run `bd init`; it creates the canonical shared skill as part of normal initialization. For an already-initialized repository that is missing the skill, upstream currently has no skill-only setup command. Do not run `bd setup codex` as a substitute because it also installs Codex hooks and generated instructions. Instead, initialize a temporary Git repository with the same `bd` version and copy only its canonical skill directory into the target repository:
65
49
 
66
- The lifecycle is fully offline from immutable package artifacts. Updates use sibling staging, backup, and recovery directories so caught filesystem errors restore the prior installation; this does not claim crash-proof atomicity across multiple renames. OpenCode does not discover skills inside npm packages, so passive npm discovery is unsupported. Plugin startup is read-only and never installs, updates, or removes skill files automatically.
67
-
68
- CLI exit codes are stable:
69
-
70
- | Exit | Meaning |
71
- | --- | --- |
72
- | `0` | Lifecycle command succeeded, or `check` found the current managed skill. |
73
- | `1` | `check` found a missing, stale, modified, or conflicting skill. |
74
- | `2` | Invalid usage, lifecycle refusal, package validation failure, git discovery failure, or operational error. |
50
+ ```bash
51
+ scratch="$(mktemp -d)"
52
+ git -C "$scratch" init
53
+ (cd "$scratch" && bd init)
54
+ mkdir -p .agents/skills
55
+ cp -R "$scratch/.agents/skills/beads" .agents/skills/beads
56
+ rm -rf "$scratch"
57
+ ```
75
58
 
76
- With `--json`, every exit writes exactly one JSON object plus a trailing newline to stdout and writes nothing to stderr. The stable `code` and `message` fields classify success, non-current checks, refusals, usage errors, discovery failures, package failures, and other operational failures.
59
+ This bounded migration copies only `.agents/skills/beads`; it does not copy `.codex` artifacts or generated instruction files.
77
60
 
78
61
  ## Features
79
62
 
80
63
  - **Context injection** - Loads the canonical Beads workflow and persistent memories on session start and after compaction
81
- - **Commands** - Vendored Beads workflows plus native `/beads:setup`, available under the `/beads:*` namespace
64
+ - **Commands** - Vendored Beads workflows available under the `/beads:*` namespace
82
65
  - **Task agent** - Autonomous issue completion via `beads-task-agent` subagent
83
66
 
84
67
  ## Usage
@@ -97,13 +80,11 @@ Commands are available as `/beads:<name>`, for example `/beads:ready`, `/beads:c
97
80
 
98
81
  Explicit command and agent definitions in your OpenCode configuration take precedence over plugin-provided definitions with the same name. The plugin emits a grouped, rate-limited warning for exact `beads:*` or `beads-task-agent` collisions while continuing to register every non-conflicting definition.
99
82
 
100
- This configuration precedence is separate from skill-file collision safety. User-defined commands and agents win without blocking plugin startup; unmanaged, differently managed, or modified skill files instead cause the explicit companion CLI operation to refuse without mutation.
101
-
102
83
  ## Agent
103
84
 
104
85
  ### beads-task-agent
105
86
 
106
- A subagent for read-only status and graph analysis or one-bead task completion. Analysis requests never mutate beads; completion requests inspect and process one caller-selected or highest-priority ready bead, quarantine work created during that invocation, and return after closing or blocking the selected bead. Its configured prompt is role-specific; session injection supplies the shared CLI and lifecycle safety rules once.
87
+ A subagent for read-only status and graph analysis or one-bead task completion. Analysis requests never mutate beads; completion requests inspect and process one caller-selected or highest-priority ready bead, quarantine work created during that invocation, validate before closure, and return after closing or blocking the selected bead. Its configured prompt is role-specific; session injection supplies compact OpenCode-specific CLI, validation, delegation, and conservative commit/push/sync policy without duplicating `bd prime`.
107
88
 
108
89
  ## Compatibility
109
90
 
@@ -115,25 +96,19 @@ The compatibility ranges and validated baselines for this release are:
115
96
  | `bd` CLI | 1.0.5 through 1.x | 1.1.0 |
116
97
  | Bun | >=1.3.14 | 1.3.14 |
117
98
 
118
- The OpenCode adapter builds against the exact paired `@opencode-ai/plugin` and `@opencode-ai/sdk` `1.18.4` releases and declares compatible optional peers from `1.18.3` through the stable `1.x` line. Both imports are type-only; the package does not install a second OpenCode runtime. The command, agent, and managed-skill provenance is currently synced from Beads v1.1.0. Newer compatible releases may work but are not guaranteed until validated; when diagnosing a regression, reproduce it with the baselines above.
99
+ The OpenCode adapter builds against the exact paired `@opencode-ai/plugin` and `@opencode-ai/sdk` `1.18.4` releases and declares compatible optional peers from `1.18.3` through the stable `1.x` line. Both imports are type-only; the package does not install a second OpenCode runtime. Command and agent provenance is currently synced from Beads v1.1.0. Newer compatible releases may work but are not guaranteed until validated; when diagnosing a regression, reproduce it with the baselines above.
119
100
 
120
101
  ## Vendored Content
121
102
 
122
103
  Files under `vendor/` are copied from the upstream Beads plugin by [`scripts/sync-beads.sh`](scripts/sync-beads.sh). The current inventory includes the complete upstream command-template directory and task agent, rather than a duplicate of the much larger `bd` CLI. [`vendor/manifest.json`](vendor/manifest.json) records the stable upstream tag and commit, source paths, sorted inventory, byte lengths, and SHA-256 checksums. A deterministic adaptation layer translates known MCP- or Claude-specific instructions to OpenCode's CLI-only model when prompts load; sync fails if provenance, checksums, inventory, or reviewed transformations differ. Do not edit vendored files directly: the next sync replaces them. Adapter behavior lives in `src/`, while general Beads behavior and documentation remain upstream.
123
104
 
124
- The vendor manifest, task agent, and every recorded command file are required package content. The separately reviewed skill fork records exact upstream source paths and hashes mapped to each adapted output in `dist/init/manifest.json`; these mappings, the output inventory, and output hashes are all strict package inputs. Initialization fails with the artifact path and validation reason if required content is missing or malformed. Command frontmatter supports `description`, `argument-hint`, `agent`, `model`, and `subtask`; task-agent frontmatter supports `description`, `mode: subagent`, `model`, `temperature`, `top_p`, `disable`, `color`, and `maxSteps`. Unsupported fields or shapes fail validation so upstream changes receive explicit review. Reinstall the package or rerun the sync script from a source checkout rather than repairing generated files by hand.
105
+ The vendor manifest, task agent, and every recorded command file are required package content. Their exact upstream source paths, inventory, byte lengths, and hashes are strict package inputs. Plugin loading fails with the artifact path and validation reason if required content is missing or malformed. Command frontmatter supports `description`, `argument-hint`, `agent`, `model`, and `subtask`; task-agent frontmatter supports `description`, `mode: subagent`, `model`, `temperature`, `top_p`, `disable`, `color`, and `maxSteps`. Unsupported fields or shapes fail validation so upstream changes receive explicit review. Reinstall the package or rerun the sync script from a source checkout rather than repairing generated files by hand.
125
106
 
126
107
  ## Troubleshooting
127
108
 
128
109
  - **No Beads context:** Run `bd prime` in the project. Install `bd` if the command is missing, or run `bd init` if the project has no Beads workspace.
129
- - **Managed skill is missing:** Run `bunx @snarkipus/opencode-beads@0.8.0 init [--global]`. Use the same scope for later lifecycle commands.
130
- - **Managed skill is stale:** Run `bunx @snarkipus/opencode-beads@0.8.0 update [--global]`. The command proceeds only if every old managed hash still matches.
131
- - **Managed skill is modified:** Preserve or revert local edits before retrying. To discard an installation deliberately, move the entire target out of every OpenCode discovery root, then run `init`; no force mode exists.
132
- - **Managed skill is conflicting:** Run `check --json` with the intended scope and inspect `target` and `collisions`. Move or remove unmanaged or differently managed Beads skills deliberately, then retry. Do not treat another discovery root as the selected target.
133
- - **Stale transaction residue:** Preserve and inspect sibling `.beads.opencode-beads-stage-*`, `-backup-*`, or `-recovery-*` directories before manual recovery. The CLI refuses to guess which residue is authoritative.
134
- - **Global target is unexpected:** Use an absolute `XDG_CONFIG_HOME` for `$XDG_CONFIG_HOME/opencode/skills/beads`; relative or absent values intentionally fall back to `~/.config/opencode/skills/beads`. Global `.agents` and `.claude` roots remain under the home directory.
135
- - **Lifecycle reports package validation failure:** Reinstall the exact scoped package version. The CLI will not install from missing, unexpected, or checksum-invalid packaged artifacts.
136
- - **Lifecycle cannot discover Git:** Run the command inside the intended Git worktree. Project and global lifecycle operations both require a worktree boundary for collision discovery.
110
+ - **Canonical skill is missing:** For a repository without a Beads workspace, run `bd init`. For an already-initialized repository, use the bounded manual-copy migration above; upstream currently has no skill-only setup command, and `bd setup codex` installs additional Codex integration artifacts.
111
+ - **Upgrading from 0.8.0:** Run the exact version-pinned removal command in the upgrade section before updating the plugin pin, then use the appropriate fresh-initialization or existing-workspace path above.
137
112
  - **Vendor artifact initialization error:** Reinstall the package. If using a source checkout, rerun the vendor sync and validation scripts; the error names the missing or malformed manifest, command, or task-agent file.
138
113
  - **Unexpected command or agent definition:** Check the OpenCode configuration for a colliding `beads:*` or `beads-task-agent` entry. Your explicit configuration wins on an exact name collision; OpenCode logs a rate-limited warning naming the collision.
139
114
  - **A regular subagent has no Beads context:** This is intentional. Delegate Beads work to `beads-task-agent`, or run the required `bd` command explicitly.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@snarkipus/opencode-beads",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
- "description": "A maintained OpenCode integration for the Beads issue tracker, including context injection, vendored workflows, a task agent, and managed skill lifecycle tooling.",
5
+ "description": "A maintained OpenCode integration for the Beads issue tracker, including context injection, vendored workflows, and a task agent.",
6
6
  "author": "Matt Jackson",
7
7
  "contributors": [
8
8
  {
@@ -40,13 +40,9 @@
40
40
  "files": [
41
41
  "src",
42
42
  "vendor",
43
- "dist",
44
43
  "README.md",
45
44
  "LICENSE"
46
45
  ],
47
- "bin": {
48
- "opencode-beads": "src/init-cli.ts"
49
- },
50
46
  "scripts": {
51
47
  "test": "bun test",
52
48
  "test:docs": "bun test tests/docs.test.ts",
package/src/vendor.ts CHANGED
@@ -16,21 +16,6 @@ function getVendorDir(): string {
16
16
  return path.join(__dirname, "..", "vendor");
17
17
  }
18
18
 
19
- async function getPackageIdentity(): Promise<string> {
20
- const packagePath = path.join(getVendorDir(), "..", "package.json");
21
- const value = JSON.parse(await fs.readFile(packagePath, "utf8")) as {
22
- name?: unknown;
23
- version?: unknown;
24
- };
25
- if (typeof value.name !== "string") {
26
- throw new Error("Invalid package artifact package.json: name must be a string");
27
- }
28
- if (typeof value.version !== "string") {
29
- throw new Error("Invalid package artifact package.json: version must be a string");
30
- }
31
- return `${value.name}@${value.version}`;
32
- }
33
-
34
19
  interface ParsedMarkdown {
35
20
  frontmatter: Record<string, string>;
36
21
  body: string;
@@ -189,7 +174,9 @@ function rejectUnsupportedFields(
189
174
  const BEADS_HOST_GUIDANCE = `## CLI Safety
190
175
 
191
176
  There is no native \`bd\` or Beads MCP tool in OpenCode. Run \`bd\` through \`bash\`.
192
- Treat the injected \`bd prime\` output as the canonical workflow reference. Use \`bd --help\` to discover commands, \`bd <command> --help\` for current syntax, and \`--json\` when structured output improves reliability. Distinguish command failures from output parsing failures.`;
177
+ Treat the injected \`bd prime\` output as the canonical workflow reference. Use \`bd --help\` to discover commands, \`bd <command> --help\` for current syntax, and \`--json\` when structured output improves reliability. Distinguish command failures from output parsing failures.
178
+
179
+ Run relevant validation before closing an issue. Do not commit, push, or run Dolt synchronization automatically; do so only when current instructions or repository policy explicitly require it.`;
193
180
 
194
181
  const BEADS_DELEGATION_GUIDANCE = `## Delegation
195
182
 
@@ -286,12 +273,5 @@ export async function loadCommands(vendorDirectory = getVendorDir()): Promise<Co
286
273
  commands[name] = command;
287
274
  }
288
275
 
289
- const packageIdentity = await getPackageIdentity();
290
- const cli = `bunx ${packageIdentity}`;
291
- commands["beads:setup"] = {
292
- description: "Install or manage the OpenCode Beads skill",
293
- template: `The package CLI is canonical for the OpenCode Beads skill lifecycle. Use exact version-matched commands: \`${cli} init\`, \`${cli} init --global\`, \`${cli} check\`, \`${cli} update\`, and \`${cli} remove\`. Add \`--global\` to check, update, or remove for global scope. \`/beads:init\` is DB-only: it initializes a Beads database and does not install the skill.`,
294
- };
295
-
296
276
  return commands;
297
277
  }
@@ -1,16 +0,0 @@
1
- ---
2
- name: beads
3
- description: Use when managing work with the bd CLI, especially "create task", "what's ready", "track this work", dependencies, blockers, multi-session work, or recovery after compaction.
4
- ---
5
-
6
- # Beads
7
-
8
- Use `bd` through the shell for issue tracking. Run `bd prime` for the current project workflow and `bd <command> --help` for current command syntax.
9
-
10
- Keep issue state in Beads rather than parallel TODO files. Claim work before implementation, validate before closing it, and record discovered follow-up with its dependency relationship.
11
-
12
- ## References
13
-
14
- - [Dependencies](references/DEPENDENCIES.md)
15
- - [Issue creation](references/ISSUE_CREATION.md)
16
- - [Resumability](references/RESUMABILITY.md)
@@ -1,5 +0,0 @@
1
- # Dependencies
2
-
3
- Model ordering and discovery relationships in Beads so `bd ready` reflects genuinely unblocked work. Use `bd dep --help` for current dependency commands and types; do not infer direction from prose alone.
4
-
5
- When implementation reveals separate follow-up, create it with a `discovered-from` relationship to the issue that exposed it. Avoid dependencies between work that can proceed independently.
@@ -1,5 +0,0 @@
1
- # Issue Creation
2
-
3
- Create durable work with enough context, acceptance criteria, and priority for another session to act without reconstructing intent. Select the issue type that describes the work, not the current activity.
4
-
5
- Use `bd create --help` for current flags and `--json` when structured output improves reliability. Search existing issues before creating a duplicate.
@@ -1,5 +0,0 @@
1
- # Resumability
2
-
3
- Keep issue state useful across sessions: claim before changing code, record material findings and blockers, and close only after validation. Leave concise notes when the next step is not evident from the issue itself.
4
-
5
- Use `bd prime` for the current lifecycle policy and `bd <command> --help` for syntax. Do not make automatic commit, push, or sync behavior part of issue completion.
@@ -1,54 +0,0 @@
1
- {
2
- "schemaVersion": 1,
3
- "owner": "opencode-beads",
4
- "upstream": {
5
- "repository": "https://github.com/gastownhall/beads.git",
6
- "tag": "v1.1.0",
7
- "commit": "8e4e59d39f3459a43cf21a3236a13eca4dd874f7"
8
- },
9
- "adaptationRevision": 1,
10
- "sources": [
11
- {
12
- "source": "plugins/beads/skills/beads/SKILL.md",
13
- "sourceSha256": "01555fe65d19be401d820d9dec029cd048fb0791d433b4b575374477d6f1d827",
14
- "target": "SKILL.md"
15
- },
16
- {
17
- "source": "plugins/beads/skills/beads/resources/DEPENDENCIES.md",
18
- "sourceSha256": "9c3327611bfbdc47124736dd0cc928bfeff1c135d4ae79d4ea46cba1900df335",
19
- "target": "references/DEPENDENCIES.md"
20
- },
21
- {
22
- "source": "plugins/beads/skills/beads/resources/ISSUE_CREATION.md",
23
- "sourceSha256": "ff465ed1fb13fbb6c42b42ec15c1bd8fd677c4661237e4fc1675c179f7fca460",
24
- "target": "references/ISSUE_CREATION.md"
25
- },
26
- {
27
- "source": "plugins/beads/skills/beads/resources/RESUMABILITY.md",
28
- "sourceSha256": "8a7db4e967ace1b4f60dc85e3fb2d02f70749a18056fe90c61b2685bb172d7df",
29
- "target": "references/RESUMABILITY.md"
30
- }
31
- ],
32
- "files": [
33
- {
34
- "path": "SKILL.md",
35
- "bytes": 710,
36
- "sha256": "55a99d3f7bbfdd8dbf71097905086260455d7e14c7d133304b6645b930d16df3"
37
- },
38
- {
39
- "path": "references/DEPENDENCIES.md",
40
- "bytes": 412,
41
- "sha256": "4befbc8d17df863d3f8af7af934f7c503e66af2e3893d9fadd8c7ef156271e77"
42
- },
43
- {
44
- "path": "references/ISSUE_CREATION.md",
45
- "bytes": 376,
46
- "sha256": "5f7e2c73a2d206478c469a8777071a99c8a83f0a2970da3e8af56b2bcfcabb75"
47
- },
48
- {
49
- "path": "references/RESUMABILITY.md",
50
- "bytes": 400,
51
- "sha256": "782053cb6c091d3a87c1e4dc6b0cf3777bd1321b8a92ad38fe6351116b6fa363"
52
- }
53
- ]
54
- }
package/src/init-cli.ts DELETED
@@ -1,172 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { runInitCommand, type InitCommand, type InitScope } from "./init";
7
-
8
- const USAGE = "Usage: opencode-beads <init|check|update|remove> [--global] [--dry-run] [--json]";
9
- const VALID_COMMANDS: InitCommand[] = ["init", "check", "update", "remove"];
10
- const VALID_FLAGS = new Set(["--global", "--dry-run", "--json"]);
11
-
12
- interface CliOptions {
13
- command: InitCommand;
14
- scope: InitScope;
15
- dryRun: boolean;
16
- json: boolean;
17
- }
18
-
19
- type CliErrorCode =
20
- | "GIT_DISCOVERY_FAILED"
21
- | "OPERATION_FAILED"
22
- | "PACKAGED_ARTIFACT_INVALID"
23
- | "USAGE";
24
-
25
- class CliFailure extends Error {
26
- constructor(readonly code: CliErrorCode, message: string, readonly humanMessage = message) {
27
- super(message);
28
- }
29
- }
30
-
31
- function writeJson(value: unknown): void {
32
- process.stdout.write(`${JSON.stringify(value)}\n`);
33
- }
34
-
35
- function parseArgs(args: string[]): CliOptions {
36
- const commands = args.filter((arg) => !arg.startsWith("--"));
37
- const flags = args.filter((arg) => arg.startsWith("--"));
38
- const command = commands[0];
39
- if (
40
- commands.length !== 1 ||
41
- !VALID_COMMANDS.includes(command as InitCommand) ||
42
- flags.some((flag) => !VALID_FLAGS.has(flag)) ||
43
- new Set(flags).size !== flags.length
44
- ) {
45
- throw new Error(USAGE);
46
- }
47
- return {
48
- command: command as InitCommand,
49
- scope: flags.includes("--global") ? "global" : "project",
50
- dryRun: flags.includes("--dry-run"),
51
- json: flags.includes("--json"),
52
- };
53
- }
54
-
55
- async function discoverWorktree(cwd: string): Promise<string> {
56
- try {
57
- const process = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
58
- cwd,
59
- stdout: "pipe",
60
- stderr: "ignore",
61
- });
62
- const output = await new Response(process.stdout).text();
63
- if ((await process.exited) !== 0 || !output.trim()) throw new Error("git failed");
64
- return resolve(output.trim());
65
- } catch {
66
- throw new CliFailure("GIT_DISCOVERY_FAILED", "Unable to discover git worktree from cwd");
67
- }
68
- }
69
-
70
- /** Run the companion lifecycle CLI and return its process exit code. */
71
- export async function runCli(
72
- args = process.argv.slice(2),
73
- environment: {
74
- cwd?: string;
75
- home?: string;
76
- packageRoot?: string;
77
- xdgConfigHome?: string;
78
- } = {}
79
- ): Promise<number> {
80
- const jsonRequested = args.includes("--json");
81
- let options: CliOptions;
82
- try {
83
- options = parseArgs(args);
84
- } catch (error) {
85
- if (jsonRequested) writeJson({ ok: false, code: "USAGE", message: USAGE });
86
- else console.error(error instanceof Error ? error.message : String(error));
87
- return 2;
88
- }
89
-
90
- try {
91
- const packageRoot = environment.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
92
- let packageJson: { version?: unknown };
93
- try {
94
- packageJson = (await Bun.file(resolve(packageRoot, "package.json")).json()) as { version?: unknown };
95
- if (typeof packageJson.version !== "string") throw new Error("Package version is missing");
96
- } catch (error) {
97
- throw new CliFailure(
98
- "PACKAGED_ARTIFACT_INVALID",
99
- "Packaged artifacts failed validation",
100
- error instanceof Error ? error.message : String(error)
101
- );
102
- }
103
- const cwd = resolve(environment.cwd ?? process.cwd());
104
- const home = resolve(environment.home ?? homedir());
105
- const xdgConfigHome = environment.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
106
- const opencodeConfigDirectory =
107
- xdgConfigHome && isAbsolute(xdgConfigHome)
108
- ? resolve(xdgConfigHome, "opencode")
109
- : join(home, ".config", "opencode");
110
- let result;
111
- try {
112
- result = await runInitCommand(options.command, {
113
- cwd,
114
- worktree: await discoverWorktree(cwd),
115
- home,
116
- opencodeConfigDirectory,
117
- packageRoot,
118
- packageVersion: packageJson.version,
119
- scope: options.scope,
120
- dryRun: options.dryRun,
121
- });
122
- } catch (error) {
123
- if (error instanceof CliFailure) throw error;
124
- const humanMessage = error instanceof Error ? error.message : String(error);
125
- const packaged = humanMessage.startsWith("Invalid packaged artifact");
126
- throw new CliFailure(
127
- packaged ? "PACKAGED_ARTIFACT_INVALID" : "OPERATION_FAILED",
128
- packaged ? "Packaged artifacts failed validation" : "Lifecycle operation failed",
129
- humanMessage
130
- );
131
- }
132
- let exitCode: number;
133
- let code: "CHECK_NOT_CURRENT" | "LIFECYCLE_REFUSED" | "OK";
134
- let message: string;
135
- if (!result.ok) {
136
- exitCode = 2;
137
- code = "LIFECYCLE_REFUSED";
138
- message = result.error ?? "Lifecycle command refused";
139
- } else if (options.command === "check" && result.state !== "current") {
140
- exitCode = 1;
141
- code = "CHECK_NOT_CURRENT";
142
- message = "Managed skill is not current";
143
- } else {
144
- exitCode = 0;
145
- code = "OK";
146
- message = "Lifecycle command completed";
147
- }
148
- if (options.json) {
149
- writeJson({ ...result, code, message });
150
- } else if (result.ok) {
151
- const verb = result.changed ? (result.dryRun ? "Would change" : "Changed") : "No change";
152
- console.log(`${verb}: ${result.target} (${result.state})`);
153
- } else {
154
- console.error(`${result.error}: ${result.target}`);
155
- }
156
- return exitCode;
157
- } catch (error) {
158
- const failure =
159
- error instanceof CliFailure
160
- ? error
161
- : new CliFailure(
162
- "OPERATION_FAILED",
163
- "Lifecycle operation failed",
164
- error instanceof Error ? error.message : String(error)
165
- );
166
- if (options.json) writeJson({ ok: false, code: failure.code, message: failure.message });
167
- else console.error(failure.humanMessage);
168
- return 2;
169
- }
170
- }
171
-
172
- if (import.meta.main) process.exitCode = await runCli();
package/src/init.ts DELETED
@@ -1,700 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import * as fs from "node:fs/promises";
3
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
- import {
5
- beadsSkillLocations,
6
- inspectBeadsSkillLocations,
7
- type ManagedSkillState,
8
- type SkillLocation,
9
- } from "./skill-collision-policy";
10
-
11
- export const OWNERSHIP_MANIFEST = ".opencode-beads-manifest.json";
12
- const OWNER = "opencode-beads";
13
- const PAYLOAD_PATHS = [
14
- "SKILL.md",
15
- "references/DEPENDENCIES.md",
16
- "references/ISSUE_CREATION.md",
17
- "references/RESUMABILITY.md",
18
- ] as const;
19
-
20
- export type InitCommand = "check" | "init" | "remove" | "update";
21
- export type InitState = "conflicting" | "current" | "missing" | "modified" | "stale";
22
- export type InitScope = "global" | "project";
23
-
24
- interface ArtifactFile {
25
- path: string;
26
- bytes: number;
27
- sha256: string;
28
- }
29
-
30
- interface ArtifactSource {
31
- source: string;
32
- sourceSha256: string;
33
- target: string;
34
- }
35
-
36
- const ARTIFACT_SOURCES: ArtifactSource[] = [
37
- {
38
- source: "plugins/beads/skills/beads/SKILL.md",
39
- sourceSha256: "01555fe65d19be401d820d9dec029cd048fb0791d433b4b575374477d6f1d827",
40
- target: "SKILL.md",
41
- },
42
- {
43
- source: "plugins/beads/skills/beads/resources/DEPENDENCIES.md",
44
- sourceSha256: "9c3327611bfbdc47124736dd0cc928bfeff1c135d4ae79d4ea46cba1900df335",
45
- target: "references/DEPENDENCIES.md",
46
- },
47
- {
48
- source: "plugins/beads/skills/beads/resources/ISSUE_CREATION.md",
49
- sourceSha256: "ff465ed1fb13fbb6c42b42ec15c1bd8fd677c4661237e4fc1675c179f7fca460",
50
- target: "references/ISSUE_CREATION.md",
51
- },
52
- {
53
- source: "plugins/beads/skills/beads/resources/RESUMABILITY.md",
54
- sourceSha256: "8a7db4e967ace1b4f60dc85e3fb2d02f70749a18056fe90c61b2685bb172d7df",
55
- target: "references/RESUMABILITY.md",
56
- },
57
- ];
58
-
59
- interface ArtifactManifest {
60
- schemaVersion: 1;
61
- owner: typeof OWNER;
62
- upstream: { repository: string; tag: string; commit: string };
63
- adaptationRevision: number;
64
- sources: ArtifactSource[];
65
- files: ArtifactFile[];
66
- }
67
-
68
- export interface InitMutationFileSystem {
69
- mkdir(path: string, options?: { recursive?: boolean }): Promise<string | undefined | void>;
70
- mkdtemp(prefix: string): Promise<string>;
71
- rmdir(path: string): Promise<void>;
72
- writeFile(
73
- path: string,
74
- data: string | Uint8Array,
75
- options?: { flag?: "w" | "wx" }
76
- ): Promise<void>;
77
- rename(oldPath: string, newPath: string): Promise<void>;
78
- cp(
79
- source: string,
80
- destination: string,
81
- options: { recursive: true; errorOnExist: true; force: false }
82
- ): Promise<void>;
83
- rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
84
- }
85
-
86
- interface OwnershipManifest {
87
- schemaVersion: 1;
88
- owner: typeof OWNER;
89
- packageVersion: string;
90
- upstream: { tag: string; commit: string };
91
- adaptationRevision: number;
92
- scope: InitScope;
93
- target: string;
94
- files: Array<Pick<ArtifactFile, "path" | "sha256">>;
95
- }
96
-
97
- export interface InitInputs {
98
- cwd: string;
99
- worktree: string;
100
- home: string;
101
- opencodeConfigDirectory?: string;
102
- packageRoot: string;
103
- packageVersion: string;
104
- scope?: InitScope;
105
- dryRun?: boolean;
106
- /** Test seam for mutation failures; reads always use the real filesystem. */
107
- mutationFileSystem?: InitMutationFileSystem;
108
- }
109
-
110
- export type InitPlanOperation = {
111
- action: "mkdir" | "remove" | "rmdir" | "write-manifest" | "write-payload";
112
- path: string;
113
- };
114
-
115
- export interface InitResult {
116
- command: InitCommand;
117
- scope: InitScope;
118
- target: string;
119
- state: InitState;
120
- ok: boolean;
121
- changed: boolean;
122
- dryRun: boolean;
123
- plan: InitPlanOperation[];
124
- collisions: string[];
125
- error?: string;
126
- }
127
-
128
- function isRecord(value: unknown): value is Record<string, unknown> {
129
- return typeof value === "object" && value !== null && !Array.isArray(value);
130
- }
131
-
132
- function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
133
- return Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
134
- }
135
-
136
- function validRelativePath(path: string): boolean {
137
- return path.length > 0 && !path.startsWith("/") && !path.split(/[\\/]/).includes("..");
138
- }
139
-
140
- function sha256(content: Uint8Array): string {
141
- return createHash("sha256").update(content).digest("hex");
142
- }
143
-
144
- function isMissing(error: unknown): boolean {
145
- return isRecord(error) && error.code === "ENOENT";
146
- }
147
-
148
- async function readJson(path: string): Promise<unknown> {
149
- return JSON.parse(await fs.readFile(path, "utf8"));
150
- }
151
-
152
- function parseArtifactManifest(value: unknown): ArtifactManifest {
153
- if (
154
- !isRecord(value) ||
155
- !exactKeys(value, ["schemaVersion", "owner", "upstream", "adaptationRevision", "sources", "files"]) ||
156
- value.schemaVersion !== 1 ||
157
- value.owner !== OWNER ||
158
- typeof value.adaptationRevision !== "number" ||
159
- !Number.isInteger(value.adaptationRevision) ||
160
- !isRecord(value.upstream) ||
161
- !exactKeys(value.upstream, ["repository", "tag", "commit"]) ||
162
- typeof value.upstream.repository !== "string" ||
163
- typeof value.upstream.tag !== "string" ||
164
- typeof value.upstream.commit !== "string" ||
165
- !Array.isArray(value.sources) ||
166
- !Array.isArray(value.files)
167
- ) {
168
- throw new Error("invalid packaged manifest schema");
169
- }
170
- if (
171
- value.upstream.repository !== "https://github.com/gastownhall/beads.git" ||
172
- value.upstream.tag !== "v1.1.0" ||
173
- value.upstream.commit !== "8e4e59d39f3459a43cf21a3236a13eca4dd874f7" ||
174
- value.adaptationRevision !== 1
175
- ) {
176
- throw new Error("packaged manifest provenance is not the reviewed adaptation");
177
- }
178
-
179
- const sources = value.sources.map((source, index): ArtifactSource => {
180
- if (
181
- !isRecord(source) ||
182
- !exactKeys(source, ["source", "sourceSha256", "target"]) ||
183
- typeof source.source !== "string" ||
184
- typeof source.sourceSha256 !== "string" ||
185
- typeof source.target !== "string"
186
- ) {
187
- throw new Error(`invalid packaged manifest source ${index}`);
188
- }
189
- return {
190
- source: source.source,
191
- sourceSha256: source.sourceSha256,
192
- target: source.target,
193
- };
194
- });
195
- if (JSON.stringify(sources) !== JSON.stringify(ARTIFACT_SOURCES)) {
196
- throw new Error("packaged manifest must contain the fixed sorted source mappings");
197
- }
198
-
199
- const files = value.files.map((file, index): ArtifactFile => {
200
- if (
201
- !isRecord(file) ||
202
- !exactKeys(file, ["path", "bytes", "sha256"]) ||
203
- typeof file.path !== "string" ||
204
- !validRelativePath(file.path) ||
205
- typeof file.bytes !== "number" ||
206
- !Number.isInteger(file.bytes) ||
207
- file.bytes < 0 ||
208
- typeof file.sha256 !== "string" ||
209
- !/^[a-f0-9]{64}$/.test(file.sha256)
210
- ) {
211
- throw new Error(`invalid packaged manifest file ${index}`);
212
- }
213
- return { path: file.path, bytes: file.bytes, sha256: file.sha256 };
214
- });
215
- const paths = files.map((file) => file.path);
216
- if (
217
- paths.join("\0") !== [...paths].sort().join("\0") ||
218
- paths.join("\0") !== PAYLOAD_PATHS.join("\0")
219
- ) {
220
- throw new Error("packaged manifest must contain the fixed sorted payload inventory");
221
- }
222
- return {
223
- schemaVersion: 1,
224
- owner: OWNER,
225
- upstream: {
226
- repository: value.upstream.repository,
227
- tag: value.upstream.tag,
228
- commit: value.upstream.commit,
229
- },
230
- adaptationRevision: value.adaptationRevision,
231
- sources,
232
- files,
233
- };
234
- }
235
-
236
- function parseOwnershipManifest(value: unknown): OwnershipManifest | undefined {
237
- if (!isRecord(value) || value.owner !== OWNER) return undefined;
238
- if (
239
- !exactKeys(value, [
240
- "schemaVersion",
241
- "owner",
242
- "packageVersion",
243
- "upstream",
244
- "adaptationRevision",
245
- "scope",
246
- "target",
247
- "files",
248
- ]) ||
249
- value.schemaVersion !== 1 ||
250
- typeof value.packageVersion !== "string" ||
251
- value.packageVersion.length === 0 ||
252
- !isRecord(value.upstream) ||
253
- !exactKeys(value.upstream, ["tag", "commit"]) ||
254
- typeof value.upstream.tag !== "string" ||
255
- value.upstream.tag.length === 0 ||
256
- typeof value.upstream.commit !== "string" ||
257
- value.upstream.commit.length === 0 ||
258
- typeof value.adaptationRevision !== "number" ||
259
- !Number.isInteger(value.adaptationRevision) ||
260
- value.adaptationRevision < 1 ||
261
- (value.scope !== "project" && value.scope !== "global") ||
262
- typeof value.target !== "string" ||
263
- !Array.isArray(value.files)
264
- ) {
265
- return undefined;
266
- }
267
- const files: OwnershipManifest["files"] = [];
268
- for (const file of value.files) {
269
- if (
270
- !isRecord(file) ||
271
- !exactKeys(file, ["path", "sha256"]) ||
272
- typeof file.path !== "string" ||
273
- !validRelativePath(file.path) ||
274
- file.path === OWNERSHIP_MANIFEST ||
275
- typeof file.sha256 !== "string" ||
276
- !/^[a-f0-9]{64}$/.test(file.sha256)
277
- ) {
278
- return undefined;
279
- }
280
- files.push({ path: file.path, sha256: file.sha256 });
281
- }
282
- const paths = files.map((file) => file.path);
283
- if (new Set(paths).size !== paths.length || paths.join("\0") !== [...paths].sort().join("\0")) {
284
- return undefined;
285
- }
286
- return {
287
- schemaVersion: 1,
288
- owner: OWNER,
289
- packageVersion: value.packageVersion,
290
- upstream: { tag: value.upstream.tag, commit: value.upstream.commit },
291
- adaptationRevision: value.adaptationRevision,
292
- scope: value.scope,
293
- target: value.target,
294
- files,
295
- };
296
- }
297
-
298
- async function listTree(root: string): Promise<{ files: string[]; symlink: boolean }> {
299
- const files: string[] = [];
300
- let symlink = false;
301
- async function visit(directory: string): Promise<void> {
302
- const entries = await fs.readdir(join(root, directory), { withFileTypes: true });
303
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
304
- const path = directory ? `${directory}/${entry.name}` : entry.name;
305
- if (entry.isSymbolicLink()) {
306
- symlink = true;
307
- files.push(path);
308
- } else if (entry.isDirectory()) {
309
- await visit(path);
310
- } else {
311
- files.push(path);
312
- }
313
- }
314
- }
315
- await visit("");
316
- return { files: files.sort(), symlink };
317
- }
318
-
319
- async function loadArtifacts(packageRoot: string): Promise<{
320
- manifest: ArtifactManifest;
321
- contents: Map<string, Uint8Array>;
322
- }> {
323
- const initRoot = join(packageRoot, "dist", "init");
324
- let manifest: ArtifactManifest;
325
- try {
326
- manifest = parseArtifactManifest(await readJson(join(initRoot, "manifest.json")));
327
- } catch (error) {
328
- throw new Error(`Invalid packaged artifact manifest: ${error instanceof Error ? error.message : String(error)}`);
329
- }
330
- const contents = new Map<string, Uint8Array>();
331
- for (const file of manifest.files) {
332
- const path = join(initRoot, "artifacts", "beads", file.path);
333
- const stats = await fs.lstat(path).catch(() => undefined);
334
- if (!stats?.isFile() || stats.isSymbolicLink()) {
335
- throw new Error(`Invalid packaged artifact ${file.path}: missing, symlinked, or not a file`);
336
- }
337
- const content = await fs.readFile(path);
338
- if (content.byteLength !== file.bytes || sha256(content) !== file.sha256) {
339
- throw new Error(`Invalid packaged artifact ${file.path}: size or checksum mismatch`);
340
- }
341
- contents.set(file.path, content);
342
- }
343
- const tree = await listTree(join(initRoot, "artifacts", "beads"));
344
- if (tree.symlink || tree.files.join("\0") !== manifest.files.map((file) => file.path).join("\0")) {
345
- throw new Error("Invalid packaged artifacts: inventory differs from manifest");
346
- }
347
- return { manifest, contents };
348
- }
349
-
350
- function expectedOwnership(
351
- artifact: ArtifactManifest,
352
- packageVersion: string,
353
- scope: InitScope,
354
- target: string
355
- ): OwnershipManifest {
356
- return {
357
- schemaVersion: 1,
358
- owner: OWNER,
359
- packageVersion,
360
- upstream: { tag: artifact.upstream.tag, commit: artifact.upstream.commit },
361
- adaptationRevision: artifact.adaptationRevision,
362
- scope,
363
- target,
364
- files: artifact.files.map(({ path, sha256: hash }) => ({ path, sha256: hash })),
365
- };
366
- }
367
-
368
- async function inspectTarget(
369
- target: string,
370
- scope: InitScope,
371
- expected: OwnershipManifest
372
- ): Promise<{ state: InitState; ownership?: OwnershipManifest }> {
373
- let stats;
374
- try {
375
- stats = await fs.lstat(target);
376
- } catch (error) {
377
- if (isMissing(error)) return { state: "missing" };
378
- return { state: "conflicting" };
379
- }
380
- if (!stats.isDirectory() || stats.isSymbolicLink()) return { state: "conflicting" };
381
-
382
- const manifestPath = join(target, OWNERSHIP_MANIFEST);
383
- const manifestStats = await fs.lstat(manifestPath).catch(() => undefined);
384
- if (!manifestStats?.isFile() || manifestStats.isSymbolicLink()) return { state: "conflicting" };
385
- let ownership: OwnershipManifest | undefined;
386
- try {
387
- ownership = parseOwnershipManifest(await readJson(manifestPath));
388
- } catch {
389
- return { state: "conflicting" };
390
- }
391
- if (!ownership || resolve(ownership.target) !== target || ownership.scope !== scope) {
392
- return { state: "conflicting" };
393
- }
394
-
395
- const tree = await listTree(target);
396
- const expectedFiles = [...ownership.files.map((file) => file.path), OWNERSHIP_MANIFEST].sort();
397
- if (tree.symlink || tree.files.join("\0") !== expectedFiles.join("\0")) {
398
- return { state: "modified", ownership };
399
- }
400
- for (const file of ownership.files) {
401
- const content = await fs.readFile(join(target, file.path)).catch(() => undefined);
402
- if (!content || sha256(content) !== file.sha256) return { state: "modified", ownership };
403
- }
404
- const metadataMatches =
405
- ownership.packageVersion === expected.packageVersion &&
406
- ownership.upstream.tag === expected.upstream.tag &&
407
- ownership.upstream.commit === expected.upstream.commit &&
408
- ownership.adaptationRevision === expected.adaptationRevision &&
409
- ownership.scope === expected.scope &&
410
- ownership.target === expected.target &&
411
- JSON.stringify(ownership.files) === JSON.stringify(expected.files);
412
- return metadataMatches
413
- ? { state: "current", ownership }
414
- : { state: "stale", ownership };
415
- }
416
-
417
- function targetFor(
418
- worktree: string,
419
- opencodeConfigDirectory: string,
420
- scope: InitScope
421
- ): string {
422
- return scope === "global"
423
- ? resolve(opencodeConfigDirectory, "skills", "beads")
424
- : resolve(worktree, ".opencode", "skills", "beads");
425
- }
426
-
427
- async function assertNoTargetSymlink(target: string, boundary: string): Promise<void> {
428
- const parts = relative(resolve(boundary), target).split(sep).filter(Boolean);
429
- let current = resolve(boundary);
430
- for (const part of parts) {
431
- current = join(current, part);
432
- const stats = await fs.lstat(current).catch((error) => {
433
- if (isMissing(error)) return undefined;
434
- throw error;
435
- });
436
- if (stats?.isSymbolicLink()) throw new Error(`Refusing symlink in target path: ${current}`);
437
- }
438
- }
439
-
440
- function refusal(command: InitCommand, state: InitState): string | undefined {
441
- if (command === "check") return undefined;
442
-
443
- const isManagedState = state === "current" || state === "stale";
444
- if (command === "init") {
445
- return state === "modified" || state === "conflicting"
446
- ? `Refusing to initialize ${state} skill`
447
- : undefined;
448
- }
449
- if (command === "update") {
450
- return isManagedState ? undefined : `Refusing to update ${state} skill`;
451
- }
452
- return isManagedState ? undefined : `Refusing to remove ${state} skill`;
453
- }
454
-
455
- async function installPlan(target: string, old: OwnershipManifest | undefined, expected: OwnershipManifest): Promise<InitPlanOperation[]> {
456
- const operations: InitPlanOperation[] = [];
457
- const targetStats = await fs.lstat(target).catch(() => undefined);
458
- const references = join(target, "references");
459
- const referenceStats = await fs.lstat(references).catch(() => undefined);
460
- if (!targetStats) operations.push({ action: "mkdir", path: target });
461
- if (!referenceStats) operations.push({ action: "mkdir", path: references });
462
- const expectedPaths = new Set(expected.files.map((file) => file.path));
463
- for (const path of old?.files.map((file) => file.path).filter((path) => !expectedPaths.has(path)).sort() ?? []) {
464
- operations.push({ action: "remove", path: join(target, path) });
465
- }
466
- const expectedDirectories = new Set(expected.files.map((file) => dirname(file.path)).filter((path) => path !== "."));
467
- const obsoleteDirectories = new Set<string>();
468
- for (const file of old?.files ?? []) {
469
- for (let directory = dirname(file.path); directory !== "."; directory = dirname(directory)) {
470
- if (!expectedDirectories.has(directory)) obsoleteDirectories.add(directory);
471
- }
472
- }
473
- for (const directory of [...obsoleteDirectories].sort().reverse()) {
474
- operations.push({ action: "rmdir", path: join(target, directory) });
475
- }
476
- for (const file of expected.files) operations.push({ action: "write-payload", path: join(target, file.path) });
477
- operations.push({ action: "write-manifest", path: join(target, OWNERSHIP_MANIFEST) });
478
- return operations;
479
- }
480
-
481
- function removePlan(target: string, ownership: OwnershipManifest): InitPlanOperation[] {
482
- const directories = new Set<string>();
483
- for (const file of ownership.files) {
484
- for (let directory = dirname(file.path); directory !== "."; directory = dirname(directory)) {
485
- directories.add(directory);
486
- }
487
- }
488
- return [
489
- ...ownership.files.map((file) => ({ action: "remove" as const, path: join(target, file.path) })),
490
- { action: "remove", path: join(target, OWNERSHIP_MANIFEST) },
491
- ...[...directories]
492
- .sort((left, right) => right.split("/").length - left.split("/").length || left.localeCompare(right))
493
- .map((directory) => ({ action: "rmdir" as const, path: join(target, directory) })),
494
- { action: "rmdir", path: target },
495
- ];
496
- }
497
-
498
- function transactionPrefix(target: string, kind: "backup" | "recovery" | "stage"): string {
499
- return `.${basename(target)}.opencode-beads-${kind}-`;
500
- }
501
-
502
- async function staleTransactions(target: string): Promise<string[]> {
503
- const parent = dirname(target);
504
- const entries = await fs.readdir(parent).catch((error) => {
505
- if (isMissing(error)) return [];
506
- throw error;
507
- });
508
- const prefixes = (["backup", "recovery", "stage"] as const).map((kind) => transactionPrefix(target, kind));
509
- return entries
510
- .filter((entry) => prefixes.some((prefix) => entry.startsWith(prefix)))
511
- .sort()
512
- .map((entry) => join(parent, entry));
513
- }
514
-
515
- async function cleanup(mutationFs: InitMutationFileSystem, path: string): Promise<void> {
516
- await mutationFs.rm(path, { recursive: true, force: true }).catch(() => undefined);
517
- }
518
-
519
- async function buildStage(
520
- contents: Map<string, Uint8Array>,
521
- ownership: OwnershipManifest,
522
- target: string,
523
- mutationFs: InitMutationFileSystem
524
- ): Promise<string> {
525
- await mutationFs.mkdir(dirname(target), { recursive: true });
526
- const stage = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "stage")));
527
- try {
528
- for (const file of ownership.files) {
529
- const content = contents.get(file.path);
530
- if (!content) throw new Error(`Missing planned payload for ${file.path}`);
531
- await mutationFs.mkdir(dirname(join(stage, file.path)), { recursive: true });
532
- await mutationFs.writeFile(join(stage, file.path), content, { flag: "wx" });
533
- }
534
- await mutationFs.writeFile(
535
- join(stage, OWNERSHIP_MANIFEST),
536
- `${JSON.stringify(ownership, null, 2)}\n`,
537
- { flag: "wx" }
538
- );
539
- return stage;
540
- } catch (error) {
541
- await cleanup(mutationFs, stage);
542
- throw error;
543
- }
544
- }
545
-
546
- async function installTransaction(
547
- target: string,
548
- targetExists: boolean,
549
- contents: Map<string, Uint8Array>,
550
- ownership: OwnershipManifest,
551
- mutationFs: InitMutationFileSystem
552
- ): Promise<void> {
553
- const stage = await buildStage(contents, ownership, target, mutationFs);
554
- if (!targetExists) {
555
- try {
556
- await mutationFs.rename(stage, target);
557
- } catch (error) {
558
- await cleanup(mutationFs, stage);
559
- throw error;
560
- }
561
- return;
562
- }
563
-
564
- let backup: string | undefined;
565
- let recovery: string | undefined;
566
- let moved = false;
567
- try {
568
- backup = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "backup")));
569
- recovery = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "recovery")));
570
- await mutationFs.rmdir(backup);
571
- await mutationFs.rmdir(recovery);
572
- await mutationFs.rename(target, backup);
573
- moved = true;
574
- await mutationFs.cp(backup, recovery, { recursive: true, errorOnExist: true, force: false });
575
- await mutationFs.rename(stage, target);
576
- try {
577
- await mutationFs.rm(backup, { recursive: true });
578
- } catch (error) {
579
- await mutationFs.rename(target, stage);
580
- await mutationFs.rename(recovery, target);
581
- await cleanup(mutationFs, stage);
582
- await cleanup(mutationFs, backup);
583
- throw error;
584
- }
585
- await cleanup(mutationFs, recovery);
586
- } catch (error) {
587
- if (moved && !(await fs.exists(target))) {
588
- const restore = backup && (await fs.exists(backup)) ? backup : recovery;
589
- if (restore && (await fs.exists(restore))) await mutationFs.rename(restore, target);
590
- }
591
- await cleanup(mutationFs, stage);
592
- if (backup) await cleanup(mutationFs, backup);
593
- if (recovery) await cleanup(mutationFs, recovery);
594
- throw error;
595
- }
596
- }
597
-
598
- async function removeTransaction(target: string, mutationFs: InitMutationFileSystem): Promise<void> {
599
- let backup: string | undefined;
600
- let recovery: string | undefined;
601
- let moved = false;
602
- try {
603
- backup = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "backup")));
604
- recovery = await mutationFs.mkdtemp(join(dirname(target), transactionPrefix(target, "recovery")));
605
- await mutationFs.rmdir(backup);
606
- await mutationFs.rmdir(recovery);
607
- await mutationFs.rename(target, backup);
608
- moved = true;
609
- await mutationFs.cp(backup, recovery, { recursive: true, errorOnExist: true, force: false });
610
- await mutationFs.rm(backup, { recursive: true });
611
- await cleanup(mutationFs, recovery);
612
- } catch (error) {
613
- if (moved && !(await fs.exists(target))) {
614
- const restore = recovery && (await fs.exists(recovery)) ? recovery : backup;
615
- if (restore && (await fs.exists(restore))) await mutationFs.rename(restore, target);
616
- }
617
- if (backup) await cleanup(mutationFs, backup);
618
- if (recovery) await cleanup(mutationFs, recovery);
619
- throw error;
620
- }
621
- }
622
-
623
- /** Validate, inspect, plan, and optionally execute one managed-skill lifecycle command. */
624
- export async function runInitCommand(command: InitCommand, inputs: InitInputs): Promise<InitResult> {
625
- const scope = inputs.scope ?? "project";
626
- const cwd = resolve(inputs.cwd);
627
- const worktree = resolve(inputs.worktree);
628
- const home = resolve(inputs.home);
629
- const opencodeConfigDirectory = resolve(
630
- inputs.opencodeConfigDirectory ?? join(home, ".config", "opencode")
631
- );
632
- const target = targetFor(worktree, opencodeConfigDirectory, scope);
633
- // This call validates cwd containment even when no collision locations exist.
634
- beadsSkillLocations(cwd, worktree, home, opencodeConfigDirectory);
635
- const { manifest: artifact, contents } = await loadArtifacts(resolve(inputs.packageRoot));
636
- const expected = expectedOwnership(artifact, inputs.packageVersion, scope, target);
637
- await assertNoTargetSymlink(
638
- target,
639
- scope === "global" ? dirname(opencodeConfigDirectory) : worktree
640
- );
641
- const transactions = await staleTransactions(target);
642
- const targetInspection = await inspectTarget(target, scope, expected);
643
-
644
- const inspections = await inspectBeadsSkillLocations(
645
- cwd,
646
- worktree,
647
- home,
648
- async (location: SkillLocation): Promise<ManagedSkillState | "differently-managed" | undefined> => {
649
- const locationScope = location.scope;
650
- const locationExpected = expectedOwnership(artifact, inputs.packageVersion, locationScope, resolve(location.path));
651
- const inspection = await inspectTarget(resolve(location.path), locationScope, locationExpected);
652
- if (inspection.state === "current" || inspection.state === "stale" || inspection.state === "modified") return inspection.state;
653
- if (inspection.state === "conflicting") return "differently-managed";
654
- return undefined;
655
- },
656
- opencodeConfigDirectory
657
- );
658
- const discoveryCollisions = inspections
659
- .filter((inspection) => resolve(inspection.path) !== target && inspection.state !== "absent")
660
- .map((inspection) => resolve(inspection.path));
661
- const collisions = [...transactions, ...discoveryCollisions].sort();
662
- const blockedByCollision =
663
- transactions.length > 0 || (command !== "remove" && discoveryCollisions.length > 0);
664
- const state: InitState = blockedByCollision ? "conflicting" : targetInspection.state;
665
- const error = refusal(command, state);
666
- const canMutate = error === undefined;
667
- const shouldInstall =
668
- canMutate &&
669
- ((command === "init" && (state === "missing" || state === "stale")) ||
670
- (command === "update" && state === "stale"));
671
- const shouldRemove = canMutate && command === "remove";
672
-
673
- let plan: InitPlanOperation[] = [];
674
- if (shouldInstall) {
675
- plan = await installPlan(target, targetInspection.ownership, expected);
676
- } else if (shouldRemove && targetInspection.ownership) {
677
- plan = removePlan(target, targetInspection.ownership);
678
- }
679
- const changed = plan.length > 0;
680
- if (canMutate && changed && !inputs.dryRun) {
681
- const mutationFs = inputs.mutationFileSystem ?? fs;
682
- if (shouldInstall) {
683
- await installTransaction(target, targetInspection.state !== "missing", contents, expected, mutationFs);
684
- } else {
685
- await removeTransaction(target, mutationFs);
686
- }
687
- }
688
- return {
689
- command,
690
- scope,
691
- target,
692
- state,
693
- ok: error === undefined,
694
- changed,
695
- dryRun: inputs.dryRun ?? false,
696
- plan,
697
- collisions,
698
- ...(error ? { error } : {}),
699
- };
700
- }
@@ -1,133 +0,0 @@
1
- import { lstat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
-
4
- const SKILL_ROOTS = [".opencode", ".agents", ".claude"] as const;
5
-
6
- export type SkillScope = "global" | "project";
7
- export type ManagedSkillState = "current" | "modified" | "stale";
8
- export type SkillLocationState =
9
- | "absent"
10
- | "differently-managed"
11
- | "managed-current"
12
- | "managed-modified"
13
- | "managed-stale"
14
- | "unmanaged";
15
-
16
- export interface SkillLocation {
17
- path: string;
18
- root: (typeof SKILL_ROOTS)[number];
19
- scope: SkillScope;
20
- }
21
-
22
- export interface SkillLocationInspection extends SkillLocation {
23
- state: SkillLocationState;
24
- }
25
-
26
- export type ManagedSkillInspector = (
27
- location: SkillLocation
28
- ) => Promise<ManagedSkillState | "differently-managed" | undefined>;
29
-
30
- function isMissing(error: unknown): boolean {
31
- return (
32
- typeof error === "object" &&
33
- error !== null &&
34
- "code" in error &&
35
- error.code === "ENOENT"
36
- );
37
- }
38
-
39
- /** Resolve every project and global discovery location for a skill named beads. */
40
- export function beadsSkillLocations(
41
- cwd: string,
42
- worktree: string,
43
- homeDirectory: string,
44
- opencodeConfigDirectory = join(homeDirectory, ".config", "opencode")
45
- ): SkillLocation[] {
46
- const canonicalCwd = resolve(cwd);
47
- const canonicalWorktree = resolve(worktree);
48
- const fromWorktree = relative(canonicalWorktree, canonicalCwd);
49
- if (fromWorktree === ".." || fromWorktree.startsWith(`..${sep}`) || isAbsolute(fromWorktree)) {
50
- throw new Error(`cwd must be within worktree: ${canonicalCwd}`);
51
- }
52
-
53
- const projectDirectories: string[] = [];
54
- for (let current = canonicalCwd; ; current = dirname(current)) {
55
- projectDirectories.push(current);
56
- if (current === canonicalWorktree) break;
57
- }
58
-
59
- const locations = [
60
- ...projectDirectories.flatMap((directory) =>
61
- SKILL_ROOTS.map((root) => ({
62
- path: resolve(directory, root, "skills", "beads"),
63
- root,
64
- scope: "project" as const,
65
- }))
66
- ),
67
- ...SKILL_ROOTS.map((root) => ({
68
- path:
69
- root === ".opencode"
70
- ? join(opencodeConfigDirectory, "skills", "beads")
71
- : join(homeDirectory, root, "skills", "beads"),
72
- root,
73
- scope: "global" as const,
74
- })),
75
- ];
76
- const uniqueLocations: SkillLocation[] = [];
77
- const seenPaths = new Set<string>();
78
- for (const location of locations) {
79
- if (seenPaths.has(location.path)) continue;
80
- seenPaths.add(location.path);
81
- uniqueLocations.push(location);
82
- }
83
- return uniqueLocations;
84
- }
85
-
86
- /** Inspect all discovery locations without mutating them. */
87
- export async function inspectBeadsSkillLocations(
88
- cwd: string,
89
- worktree: string,
90
- homeDirectory: string,
91
- inspectManaged: ManagedSkillInspector,
92
- opencodeConfigDirectory = join(homeDirectory, ".config", "opencode")
93
- ): Promise<SkillLocationInspection[]> {
94
- return Promise.all(
95
- beadsSkillLocations(cwd, worktree, homeDirectory, opencodeConfigDirectory).map(
96
- async (location) => {
97
- let stats;
98
- try {
99
- stats = await lstat(location.path);
100
- } catch (error) {
101
- if (isMissing(error)) return { ...location, state: "absent" as const };
102
- return { ...location, state: "unmanaged" as const };
103
- }
104
-
105
- if (!stats.isDirectory() || stats.isSymbolicLink()) {
106
- return { ...location, state: "unmanaged" as const };
107
- }
108
-
109
- const managedState = await inspectManaged(location).catch(() => undefined);
110
- let state: SkillLocationState = "unmanaged";
111
- if (managedState === "differently-managed") {
112
- state = managedState;
113
- } else if (managedState) {
114
- state = `managed-${managedState}`;
115
- }
116
- return { ...location, state };
117
- }
118
- )
119
- );
120
- }
121
-
122
- /** Return every location that prevents a managed operation at the requested target. */
123
- export function blockingSkillCollisions(
124
- inspections: ReadonlyArray<SkillLocationInspection>,
125
- target: string
126
- ): SkillLocationInspection[] {
127
- const resolvedTarget = resolve(target);
128
- return inspections.filter((inspection) => {
129
- if (inspection.state === "absent") return false;
130
- if (resolve(inspection.path) !== resolvedTarget) return true;
131
- return inspection.state !== "managed-current" && inspection.state !== "managed-stale";
132
- });
133
- }