@snarkipus/opencode-beads 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -56
- package/package.json +2 -6
- package/src/vendor.ts +3 -23
- package/dist/init/artifacts/beads/SKILL.md +0 -16
- package/dist/init/artifacts/beads/references/DEPENDENCIES.md +0 -5
- package/dist/init/artifacts/beads/references/ISSUE_CREATION.md +0 -5
- package/dist/init/artifacts/beads/references/RESUMABILITY.md +0 -5
- package/dist/init/manifest.json +0 -54
- package/src/init-cli.ts +0 -172
- package/src/init.ts +0 -700
- package/src/skill-collision-policy.ts +0 -133
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
|
|
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
|
|
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,35 @@ 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.
|
|
23
|
+
"plugin": ["@snarkipus/opencode-beads@0.9.0"]
|
|
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
|
-
|
|
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
|
-
|
|
32
|
+
git init
|
|
33
|
+
bd init
|
|
37
34
|
```
|
|
38
35
|
|
|
39
|
-
|
|
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
|
-
|
|
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.0:
|
|
42
41
|
|
|
43
42
|
```bash
|
|
44
|
-
bunx @snarkipus/opencode-beads@0.8.0
|
|
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
|
-
|
|
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.
|
|
63
|
-
|
|
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.
|
|
65
|
-
|
|
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. |
|
|
75
|
-
|
|
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.
|
|
46
|
+
If the old skill was installed globally, append `--global` to that exact command. After removal, run `bd init` in each project that has not yet received the canonical shared skill. Do not substitute 0.9.0 in the removal command: the companion CLI no longer exists in 0.9.0.
|
|
77
47
|
|
|
78
48
|
## Features
|
|
79
49
|
|
|
80
50
|
- **Context injection** - Loads the canonical Beads workflow and persistent memories on session start and after compaction
|
|
81
|
-
- **Commands** - Vendored Beads workflows
|
|
51
|
+
- **Commands** - Vendored Beads workflows available under the `/beads:*` namespace
|
|
82
52
|
- **Task agent** - Autonomous issue completion via `beads-task-agent` subagent
|
|
83
53
|
|
|
84
54
|
## Usage
|
|
@@ -97,13 +67,11 @@ Commands are available as `/beads:<name>`, for example `/beads:ready`, `/beads:c
|
|
|
97
67
|
|
|
98
68
|
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
69
|
|
|
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
70
|
## Agent
|
|
103
71
|
|
|
104
72
|
### beads-task-agent
|
|
105
73
|
|
|
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
|
|
74
|
+
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
75
|
|
|
108
76
|
## Compatibility
|
|
109
77
|
|
|
@@ -115,25 +83,19 @@ The compatibility ranges and validated baselines for this release are:
|
|
|
115
83
|
| `bd` CLI | 1.0.5 through 1.x | 1.1.0 |
|
|
116
84
|
| Bun | >=1.3.14 | 1.3.14 |
|
|
117
85
|
|
|
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.
|
|
86
|
+
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
87
|
|
|
120
88
|
## Vendored Content
|
|
121
89
|
|
|
122
90
|
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
91
|
|
|
124
|
-
The vendor manifest, task agent, and every recorded command file are required package content.
|
|
92
|
+
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
93
|
|
|
126
94
|
## Troubleshooting
|
|
127
95
|
|
|
128
96
|
- **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
|
-
- **
|
|
130
|
-
- **
|
|
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.
|
|
97
|
+
- **Canonical skill is missing:** Confirm the project is a Git worktree, then run `bd init`. The automatic Codex project integration creates `.agents/skills/beads`.
|
|
98
|
+
- **Upgrading from 0.8.0:** Run the exact version-pinned removal command in the upgrade section before updating the plugin pin, then use `bd init` for canonical per-project setup.
|
|
137
99
|
- **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
100
|
- **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
101
|
- **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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "A maintained OpenCode integration for the Beads issue tracker, including context injection, vendored workflows, a task agent
|
|
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.
|
package/dist/init/manifest.json
DELETED
|
@@ -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
|
-
}
|