@tachikomagundam/abathur 0.1.0 → 0.2.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 +67 -4
- package/dist/cli.js +2 -0
- package/dist/commands/opencode.js +152 -0
- package/dist/test/opencode.test.js +237 -0
- package/package.json +2 -2
- package/plugin/abathur-command.md +14 -0
- package/plugin/abathur.ts +156 -0
- package/.github/workflows/ci.yml +0 -29
- package/.github/workflows/publish.yml +0 -74
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ instance learn from another's evidence; nothing in v1 talks to a network.
|
|
|
38
38
|
```bash
|
|
39
39
|
npm i -g @tachikomagundam/abathur # from the npm registry
|
|
40
40
|
npm pack # in a checkout; prepack runs the build
|
|
41
|
-
npm i -g ./abathur-0.1.0.tgz
|
|
41
|
+
npm i -g ./tachikomagundam-abathur-0.1.0.tgz
|
|
42
42
|
abathur --help
|
|
43
43
|
```
|
|
44
44
|
|
|
@@ -71,7 +71,7 @@ No models, no network: the `toy-smoke` genome ships inside the package
|
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
mkdir -p ~/abathur-demo && cd ~/abathur-demo
|
|
74
|
-
export ABATHUR_PKG="$(npm root -g)/abathur"
|
|
74
|
+
export ABATHUR_PKG="$(npm root -g)/@tachikomagundam/abathur"
|
|
75
75
|
|
|
76
76
|
# 1. materialize an independent toy genome repo (git init + repoPath rewritten)
|
|
77
77
|
node "$ABATHUR_PKG/dist/genomes/toy-smoke/init.mjs" ./genome
|
|
@@ -136,6 +136,40 @@ abathur graft ./bundles/*.bundle.tgz --genome toy-smoke # local re-bench o
|
|
|
136
136
|
abathur run --genome toy-smoke --dry-run # plan + requires[] probes, zero spawns
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
### Inside opencode
|
|
140
|
+
|
|
141
|
+
Abathur ships an official opencode plugin adapter. The harness remains the
|
|
142
|
+
orchestrator, but once installed an opencode session can also drive the CLI
|
|
143
|
+
directly:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npm i -g @tachikomagundam/abathur
|
|
147
|
+
abathur opencode install # copies plugin assets into ~/.config/opencode/
|
|
148
|
+
# restart opencode
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
After a restart the session gets two things:
|
|
152
|
+
|
|
153
|
+
- the agent tool **`abathur`** — spawns the `abathur` CLI argv-only (never a
|
|
154
|
+
shell), restricted to the nine top-level commands plus `--help`
|
|
155
|
+
(`genome`, `run`, `status`, `promote`, `tombstone`, `bundle`, `graft`,
|
|
156
|
+
`self-eval`, `kernel`), with a 120 s timeout and a 64 KB output cap. The
|
|
157
|
+
binary resolves from `PATH` unless `ABATHUR_BIN` overrides it.
|
|
158
|
+
- the user command **`/abathur <args…>`** — a slash command that tells the
|
|
159
|
+
agent to translate its arguments into a tool call and report the exit code.
|
|
160
|
+
|
|
161
|
+
`abathur opencode status` shows each target's path, installed/packaged sha256,
|
|
162
|
+
and state (up-to-date / outdated / foreign / absent).
|
|
163
|
+
`abathur opencode uninstall` removes only files carrying the abathur marker; a
|
|
164
|
+
foreign file at a target path makes both commands refuse with exit 2 and name
|
|
165
|
+
the path — nothing is ever overwritten or deleted behind your back (there is
|
|
166
|
+
still no `--force` anywhere).
|
|
167
|
+
|
|
168
|
+
Caveat: fixture benches mirror the real `~/.config/opencode` (plugins and
|
|
169
|
+
commands included) into their sandboxed HOMEs, so an installed plugin also
|
|
170
|
+
loads inside bench sessions. Uninstall first if a bench needs a clean plugin
|
|
171
|
+
environment.
|
|
172
|
+
|
|
139
173
|
### The historian genome and the operator workflow
|
|
140
174
|
|
|
141
175
|
`historian` is the first real genome: OpenCode agents work wiki scenarios
|
|
@@ -273,7 +307,7 @@ MIT, see [LICENSE](LICENSE).
|
|
|
273
307
|
```bash
|
|
274
308
|
npm i -g @tachikomagundam/abathur # 从 npm registry 安装
|
|
275
309
|
npm pack # 在 checkout 里执行;prepack 会先构建
|
|
276
|
-
npm i -g ./abathur-0.1.0.tgz
|
|
310
|
+
npm i -g ./tachikomagundam-abathur-0.1.0.tgz
|
|
277
311
|
abathur --help
|
|
278
312
|
```
|
|
279
313
|
|
|
@@ -304,7 +338,7 @@ gitignored 的 `*.local.jsonc` 会深合并覆盖其上。未知键直接 exit 2
|
|
|
304
338
|
|
|
305
339
|
```bash
|
|
306
340
|
mkdir -p ~/abathur-demo && cd ~/abathur-demo
|
|
307
|
-
export ABATHUR_PKG="$(npm root -g)/abathur"
|
|
341
|
+
export ABATHUR_PKG="$(npm root -g)/@tachikomagundam/abathur"
|
|
308
342
|
|
|
309
343
|
# 1. 物化一个独立的玩具基因组仓库(git init + 重写 repoPath)
|
|
310
344
|
node "$ABATHUR_PKG/dist/genomes/toy-smoke/init.mjs" ./genome
|
|
@@ -368,6 +402,35 @@ abathur graft ./bundles/*.bundle.tgz --genome toy-smoke # 对端血缘包
|
|
|
368
402
|
abathur run --genome toy-smoke --dry-run # 计划 + requires[] 探针,零 spawn
|
|
369
403
|
```
|
|
370
404
|
|
|
405
|
+
### 在 opencode 里调用
|
|
406
|
+
|
|
407
|
+
Abathur 自带官方 opencode 插件适配器。工装仍是编排者,但安装之后,opencode
|
|
408
|
+
会话也可以直接驱动 CLI:
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
npm i -g @tachikomagundam/abathur
|
|
412
|
+
abathur opencode install # 把插件资产复制进 ~/.config/opencode/
|
|
413
|
+
# 重启 opencode
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
重启后会话获得两样东西:
|
|
417
|
+
|
|
418
|
+
- 智能体工具 **`abathur`**——以纯 argv 方式 spawn `abathur` CLI(绝不经过
|
|
419
|
+
shell),顶层命令限定为九个真命令加 `--help`(`genome`、`run`、`status`、
|
|
420
|
+
`promote`、`tombstone`、`bundle`、`graft`、`self-eval`、`kernel`),120 秒
|
|
421
|
+
超时、64 KB 输出封顶。二进制从 `PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。
|
|
422
|
+
- 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数翻译成工具调用
|
|
423
|
+
并回报退出码。
|
|
424
|
+
|
|
425
|
+
`abathur opencode status` 逐目标打印路径、已安装/随包 sha256 与状态
|
|
426
|
+
(up-to-date / outdated / foreign / absent)。
|
|
427
|
+
`abathur opencode uninstall` 只删除带有 abathur 标记的文件;若目标路径上躺着
|
|
428
|
+
别人的文件,两条命令都会 exit 2 并点名路径——绝不会背着你覆盖或删除任何东西
|
|
429
|
+
(整个二进制依然没有 `--force`)。
|
|
430
|
+
|
|
431
|
+
注意:夹具基准会把真实的 `~/.config/opencode`(含插件与命令)镜像进沙箱 HOME,
|
|
432
|
+
因此装好的插件在基准会话里同样会加载。若基准需要干净的插件环境,请先 uninstall。
|
|
433
|
+
|
|
371
434
|
### 历史学家基因组与操作员工作流
|
|
372
435
|
|
|
373
436
|
`historian` 是第一个真实基因组:OpenCode 智能体对着在线 Wiki.js 夹具完成 wiki
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { bundleCommand } from "./commands/bundle.js";
|
|
|
10
10
|
import { genomeCommand } from "./commands/genome.js";
|
|
11
11
|
import { graftCommand } from "./commands/graft.js";
|
|
12
12
|
import { kernelCommand } from "./commands/kernel.js";
|
|
13
|
+
import { opencodeCommand } from "./commands/opencode.js";
|
|
13
14
|
import { promoteCommand } from "./commands/promote.js";
|
|
14
15
|
import { runCommand } from "./commands/run.js";
|
|
15
16
|
import { selfEvalCommand } from "./commands/self-eval.js";
|
|
@@ -28,6 +29,7 @@ export const COMMANDS = [
|
|
|
28
29
|
graftCommand,
|
|
29
30
|
selfEvalCommand,
|
|
30
31
|
kernelCommand,
|
|
32
|
+
opencodeCommand,
|
|
31
33
|
];
|
|
32
34
|
export function usage() {
|
|
33
35
|
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `abathur opencode` — installer for the official opencode plugin adapter
|
|
2
|
+
// (todo: opencode-plugin 1). Pure file management: byte-copies the packaged
|
|
3
|
+
// V1 plugin (`<package>/plugin/abathur.ts`) and slash-command template
|
|
4
|
+
// (`<package>/plugin/abathur-command.md`) into <HOME>/.config/opencode/
|
|
5
|
+
// {plugins,commands}/. No network, no shell, no opencode process contact.
|
|
6
|
+
// Identity rule: a target is ours only if its FIRST LINE carries our marker;
|
|
7
|
+
// a foreign file at a target path is refused (exit 2), never overwritten,
|
|
8
|
+
// never deleted — there is no --force anywhere.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { EXIT_OK, cannotAnswer } from "../exit.js";
|
|
15
|
+
import { writeStdout } from "../out.js";
|
|
16
|
+
/** First-line identity markers (the asset files must start with exactly these prefixes). */
|
|
17
|
+
export const PLUGIN_MARKER = "// abathur-opencode-plugin v";
|
|
18
|
+
export const COMMAND_MARKER = "<!-- abathur-opencode-command -->";
|
|
19
|
+
// Packaged assets live at <package>/plugin/ — two levels above dist/commands/
|
|
20
|
+
// (same import.meta.url-relative style as the shipped-config path in src/config.ts).
|
|
21
|
+
const PLUGIN_TS_ASSET = fileURLToPath(new URL("../../plugin/abathur.ts", import.meta.url));
|
|
22
|
+
const COMMAND_MD_ASSET = fileURLToPath(new URL("../../plugin/abathur-command.md", import.meta.url));
|
|
23
|
+
/** <HOME>/.config/opencode — HOME env-driven (os.homedir() fallback), never a literal. */
|
|
24
|
+
export function resolveOpencodeConfigDir(env = process.env) {
|
|
25
|
+
const home = env.HOME === undefined || env.HOME.length === 0 ? os.homedir() : env.HOME;
|
|
26
|
+
return path.join(home, ".config", "opencode");
|
|
27
|
+
}
|
|
28
|
+
export function pluginTargets(env = process.env) {
|
|
29
|
+
const dir = resolveOpencodeConfigDir(env);
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
label: "plugin",
|
|
33
|
+
source: PLUGIN_TS_ASSET,
|
|
34
|
+
destination: path.join(dir, "plugins", "abathur.ts"),
|
|
35
|
+
marker: PLUGIN_MARKER,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: "command",
|
|
39
|
+
source: COMMAND_MD_ASSET,
|
|
40
|
+
destination: path.join(dir, "commands", "abathur.md"),
|
|
41
|
+
marker: COMMAND_MARKER,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
function readPackagedAsset(target) {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(target.source);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return cannotAnswer(`opencode: packaged ${target.label} asset is missing (${target.source})`, "the abathur install looks broken — re-run 'npm i -g @tachikomagundam/abathur'");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readTarget(target) {
|
|
54
|
+
try {
|
|
55
|
+
return readFileSync(target.destination);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function sha256(bytes) {
|
|
62
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
function carriesMarker(bytes, marker) {
|
|
65
|
+
const text = bytes.toString("utf8");
|
|
66
|
+
const newline = text.indexOf("\n");
|
|
67
|
+
const firstLine = newline < 0 ? text : text.slice(0, newline);
|
|
68
|
+
return firstLine.startsWith(marker);
|
|
69
|
+
}
|
|
70
|
+
function classify(target, packaged) {
|
|
71
|
+
const current = readTarget(target);
|
|
72
|
+
if (current === null)
|
|
73
|
+
return { state: "absent", installed: null };
|
|
74
|
+
if (!carriesMarker(current, target.marker))
|
|
75
|
+
return { state: "foreign", installed: current };
|
|
76
|
+
return { state: current.equals(packaged) ? "up-to-date" : "outdated", installed: current };
|
|
77
|
+
}
|
|
78
|
+
function refuseForeign(target) {
|
|
79
|
+
return cannotAnswer(`opencode: refusing to touch ${target.destination} — the file exists but is not ours ` +
|
|
80
|
+
`(its first line lacks the marker '${target.marker}')`, "resolve it manually: move the foreign file aside (or adopt the marker), then re-run 'abathur opencode install'");
|
|
81
|
+
}
|
|
82
|
+
/** Atomicity rule shared by install/uninstall: validate EVERY target before writing to ANY. */
|
|
83
|
+
function requireNoForeign(targets) {
|
|
84
|
+
for (const target of targets) {
|
|
85
|
+
const current = readTarget(target);
|
|
86
|
+
if (current !== null && !carriesMarker(current, target.marker))
|
|
87
|
+
refuseForeign(target);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function opencodeInstall(env) {
|
|
91
|
+
const targets = pluginTargets(env);
|
|
92
|
+
requireNoForeign(targets);
|
|
93
|
+
for (const target of targets) {
|
|
94
|
+
const packaged = readPackagedAsset(target);
|
|
95
|
+
const { state } = classify(target, packaged);
|
|
96
|
+
if (state === "up-to-date") {
|
|
97
|
+
writeStdout(`${target.destination}: up to date`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
mkdirSync(path.dirname(target.destination), { recursive: true }); // precedent: src/core/genome.ts registry dirs
|
|
101
|
+
writeFileSync(target.destination, packaged);
|
|
102
|
+
writeStdout(`${target.destination}: ${state === "outdated" ? "updated" : "installed"}`);
|
|
103
|
+
}
|
|
104
|
+
writeStdout("note: restart opencode to load the plugin — tools and commands are scanned at startup.");
|
|
105
|
+
writeStdout("note: abathur fixture benches mirror the real ~/.config/opencode (plugins and commands " +
|
|
106
|
+
"included) into sandboxed HOMEs, so this plugin will also load inside bench sessions.");
|
|
107
|
+
return EXIT_OK;
|
|
108
|
+
}
|
|
109
|
+
function opencodeStatus(env) {
|
|
110
|
+
for (const target of pluginTargets(env)) {
|
|
111
|
+
const packaged = readPackagedAsset(target);
|
|
112
|
+
const { state, installed } = classify(target, packaged);
|
|
113
|
+
const installedSha = installed === null ? "-" : sha256(installed);
|
|
114
|
+
writeStdout(`${target.destination}: ${state} installed=${installedSha} packaged=${sha256(packaged)}`);
|
|
115
|
+
}
|
|
116
|
+
return EXIT_OK;
|
|
117
|
+
}
|
|
118
|
+
function opencodeUninstall(env) {
|
|
119
|
+
const targets = pluginTargets(env);
|
|
120
|
+
requireNoForeign(targets);
|
|
121
|
+
for (const target of targets) {
|
|
122
|
+
if (!existsSync(target.destination)) {
|
|
123
|
+
writeStdout(`${target.destination}: absent (nothing to remove)`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
rmSync(target.destination);
|
|
127
|
+
writeStdout(`${target.destination}: removed`);
|
|
128
|
+
}
|
|
129
|
+
writeStdout("note: restart opencode for the tool and command to disappear.");
|
|
130
|
+
return EXIT_OK;
|
|
131
|
+
}
|
|
132
|
+
function runOpencode(args) {
|
|
133
|
+
const [sub, ...rest] = args;
|
|
134
|
+
if (rest.length > 0) {
|
|
135
|
+
cannotAnswer(`opencode ${sub}: unexpected argument '${rest[0]}'`, "usage: abathur opencode install | status | uninstall");
|
|
136
|
+
}
|
|
137
|
+
switch (sub) {
|
|
138
|
+
case "install":
|
|
139
|
+
return opencodeInstall(process.env);
|
|
140
|
+
case "status":
|
|
141
|
+
return opencodeStatus(process.env);
|
|
142
|
+
case "uninstall":
|
|
143
|
+
return opencodeUninstall(process.env);
|
|
144
|
+
default:
|
|
145
|
+
return cannotAnswer(`opencode: unknown subcommand '${sub ?? "<none>"}'`, "usage: abathur opencode install | status | uninstall");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export const opencodeCommand = {
|
|
149
|
+
name: "opencode",
|
|
150
|
+
summary: "install/status/uninstall the official opencode plugin adapter",
|
|
151
|
+
run: ({ args }) => runOpencode(args),
|
|
152
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Opencode plugin-adapter acceptance pins (plan abathur-opencode-plugin, todo 1).
|
|
2
|
+
// CLI convention per genome.test.ts: spawn the BUILT dist/cli.js with a sandboxed
|
|
3
|
+
// HOME (tmpdir) + ABATHUR_CONFIG, so install/status/uninstall never touch the
|
|
4
|
+
// real ~/.config/opencode. The packaged plugin assets are additionally pinned
|
|
5
|
+
// here as plain text (marker first line, no shell, version parity) — the real
|
|
6
|
+
// opencode runtime consumes them uncompiled, so these string gates are the CI.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
const cliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url));
|
|
16
|
+
/** Repo root as seen from dist/test/: package.json and plugin/ sit one level up each. */
|
|
17
|
+
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
18
|
+
const pluginAssetPath = path.join(repoRoot, "plugin", "abathur.ts");
|
|
19
|
+
const commandAssetPath = path.join(repoRoot, "plugin", "abathur-command.md");
|
|
20
|
+
const PLUGIN_MARKER = "// abathur-opencode-plugin v";
|
|
21
|
+
const COMMAND_MARKER = "<!-- abathur-opencode-command -->";
|
|
22
|
+
/** D7 discipline extends to the shipped plugin/ dir even though the gate walks only src/. */
|
|
23
|
+
const FORBIDDEN_NEEDLES = ["/home/lab", "historian"];
|
|
24
|
+
// ------------------------------------------------------------------- fixtures
|
|
25
|
+
async function freshDir(prefix) {
|
|
26
|
+
return mkdtemp(path.join(os.tmpdir(), `abathur-${prefix}-`));
|
|
27
|
+
}
|
|
28
|
+
function keep(t, dir) {
|
|
29
|
+
t.after(() => rm(dir, { recursive: true, force: true }));
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
/** Sandboxed HOME: opencode targets resolve under <home>/.config/opencode, config under <home>/.config/abathur. */
|
|
33
|
+
async function makeEnv(t) {
|
|
34
|
+
const home = keep(t, await freshDir("oc-home"));
|
|
35
|
+
const configDir = path.join(home, ".config", "abathur");
|
|
36
|
+
await mkdir(configDir, { recursive: true });
|
|
37
|
+
const configPath = path.join(configDir, "config.jsonc");
|
|
38
|
+
await writeFile(configPath, "// isolated test config\n{}\n", "utf8");
|
|
39
|
+
return { home, env: { ...process.env, ABATHUR_CONFIG: configPath, HOME: home } };
|
|
40
|
+
}
|
|
41
|
+
function abathur(env, ...args) {
|
|
42
|
+
const run = spawnSync(process.execPath, [cliPath, ...args], { env, encoding: "utf8" });
|
|
43
|
+
return { status: run.status ?? -1, stdout: run.stdout, stderr: run.stderr };
|
|
44
|
+
}
|
|
45
|
+
function sha256(bytes) {
|
|
46
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
47
|
+
}
|
|
48
|
+
function targetPaths(home) {
|
|
49
|
+
const dir = path.join(home, ".config", "opencode");
|
|
50
|
+
return {
|
|
51
|
+
pluginTs: path.join(dir, "plugins", "abathur.ts"),
|
|
52
|
+
commandMd: path.join(dir, "commands", "abathur.md"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function packagedBytes() {
|
|
56
|
+
return {
|
|
57
|
+
ts: await readFile(pluginAssetPath),
|
|
58
|
+
md: await readFile(commandAssetPath),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// --------------------------------------------------------------- install
|
|
62
|
+
test("install: writes both targets byte-identical to packaged assets + restart/bench notes", async (t) => {
|
|
63
|
+
const { home, env } = await makeEnv(t);
|
|
64
|
+
const packaged = await packagedBytes();
|
|
65
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
66
|
+
const run = abathur(env, "opencode", "install");
|
|
67
|
+
assert.equal(run.status, 0, `${run.stdout}${run.stderr}`);
|
|
68
|
+
assert.ok(run.stdout.includes(pluginTs), `output must name ${pluginTs}: ${run.stdout}`);
|
|
69
|
+
assert.ok(run.stdout.includes(commandMd), `output must name ${commandMd}: ${run.stdout}`);
|
|
70
|
+
assert.ok(/restart opencode/.test(run.stdout), `restart note missing: ${run.stdout}`);
|
|
71
|
+
assert.ok(/mirror/.test(run.stdout), `bench-mirror warning missing: ${run.stdout}`);
|
|
72
|
+
assert.deepEqual(await readFile(pluginTs), packaged.ts);
|
|
73
|
+
assert.deepEqual(await readFile(commandMd), packaged.md);
|
|
74
|
+
});
|
|
75
|
+
test("install: idempotent — second run says up to date, bytes untouched", async (t) => {
|
|
76
|
+
const { home, env } = await makeEnv(t);
|
|
77
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
78
|
+
assert.equal(abathur(env, "opencode", "install").status, 0);
|
|
79
|
+
const second = abathur(env, "opencode", "install");
|
|
80
|
+
assert.equal(second.status, 0, `${second.stdout}${second.stderr}`);
|
|
81
|
+
const upToDate = second.stdout.split("\n").filter((line) => line.includes("up to date"));
|
|
82
|
+
assert.equal(upToDate.length, 2, `both targets must report up to date: ${second.stdout}`);
|
|
83
|
+
const after = await packagedBytes();
|
|
84
|
+
assert.deepEqual(await readFile(pluginTs), after.ts);
|
|
85
|
+
assert.deepEqual(await readFile(commandMd), after.md);
|
|
86
|
+
});
|
|
87
|
+
test("install: foreign target without our marker ⇒ exit 2 naming the path, NOTHING written", async (t) => {
|
|
88
|
+
const { home, env } = await makeEnv(t);
|
|
89
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
90
|
+
const foreign = "// someone else's plugin, no marker\nexport default { id: 'other' };\n";
|
|
91
|
+
await mkdir(path.dirname(pluginTs), { recursive: true });
|
|
92
|
+
await writeFile(pluginTs, foreign, "utf8");
|
|
93
|
+
const run = abathur(env, "opencode", "install");
|
|
94
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
95
|
+
const combined = `${run.stdout}${run.stderr}`;
|
|
96
|
+
assert.ok(combined.includes(pluginTs), `refusal must name the path: ${combined}`);
|
|
97
|
+
assert.equal(await readFile(pluginTs, "utf8"), foreign, "foreign bytes must survive");
|
|
98
|
+
// Atomicity: the refusal must happen before ANY write, so the command target stays absent.
|
|
99
|
+
await assert.rejects(readFile(commandMd), (error) => error.code === "ENOENT");
|
|
100
|
+
});
|
|
101
|
+
test("install: our marker'd older file is overwritten with packaged bytes", async (t) => {
|
|
102
|
+
const { home, env } = await makeEnv(t);
|
|
103
|
+
const { pluginTs } = targetPaths(home);
|
|
104
|
+
await mkdir(path.dirname(pluginTs), { recursive: true });
|
|
105
|
+
await writeFile(pluginTs, `${PLUGIN_MARKER} 0.0.1\n// ancient draft\n`, "utf8");
|
|
106
|
+
const run = abathur(env, "opencode", "install");
|
|
107
|
+
assert.equal(run.status, 0, `${run.stdout}${run.stderr}`);
|
|
108
|
+
assert.ok(run.stdout.includes(pluginTs));
|
|
109
|
+
const packaged = await packagedBytes();
|
|
110
|
+
assert.deepEqual(await readFile(pluginTs), packaged.ts);
|
|
111
|
+
});
|
|
112
|
+
test("install: mkdir -p creates the plugins/ and commands/ dirs in a virgin HOME", async (t) => {
|
|
113
|
+
const { home, env } = await makeEnv(t);
|
|
114
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
115
|
+
assert.equal(abathur(env, "opencode", "install").status, 0);
|
|
116
|
+
// Reading succeeded only because the directories were created on demand.
|
|
117
|
+
assert.ok((await readFile(pluginTs, "utf8")).startsWith(PLUGIN_MARKER));
|
|
118
|
+
assert.ok((await readFile(commandMd, "utf8")).startsWith(COMMAND_MARKER));
|
|
119
|
+
});
|
|
120
|
+
test("opencode: unknown subcommand and stray args exit 2 with usage", async (t) => {
|
|
121
|
+
const { env } = await makeEnv(t);
|
|
122
|
+
const unknown = abathur(env, "opencode", "frobnicate");
|
|
123
|
+
assert.equal(unknown.status, 2);
|
|
124
|
+
assert.ok(`${unknown.stdout}${unknown.stderr}`.includes("unknown subcommand"));
|
|
125
|
+
const none = abathur(env, "opencode");
|
|
126
|
+
assert.equal(none.status, 2);
|
|
127
|
+
const stray = abathur(env, "opencode", "install", "--force");
|
|
128
|
+
assert.equal(stray.status, 2);
|
|
129
|
+
assert.ok(`${stray.stdout}${stray.stderr}`.includes("unexpected argument"));
|
|
130
|
+
});
|
|
131
|
+
// ---------------------------------------------------------------- status
|
|
132
|
+
test("status: virgin HOME ⇒ absent; after install ⇒ up-to-date with packaged sha256", async (t) => {
|
|
133
|
+
const { home, env } = await makeEnv(t);
|
|
134
|
+
const packaged = await packagedBytes();
|
|
135
|
+
const fresh = abathur(env, "opencode", "status");
|
|
136
|
+
assert.equal(fresh.status, 0, fresh.stderr);
|
|
137
|
+
const absent = fresh.stdout.split("\n").filter((line) => line.includes(": absent"));
|
|
138
|
+
assert.equal(absent.length, 2, `both targets absent initially: ${fresh.stdout}`);
|
|
139
|
+
assert.equal(abathur(env, "opencode", "install").status, 0);
|
|
140
|
+
const ok = abathur(env, "opencode", "status");
|
|
141
|
+
assert.equal(ok.status, 0);
|
|
142
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
143
|
+
const pluginRow = ok.stdout.split("\n").find((line) => line.startsWith(pluginTs));
|
|
144
|
+
const commandRow = ok.stdout.split("\n").find((line) => line.startsWith(commandMd));
|
|
145
|
+
assert.ok(pluginRow !== undefined && commandRow !== undefined, `both rows present: ${ok.stdout}`);
|
|
146
|
+
assert.ok(pluginRow.includes(": up-to-date"), `plugin up-to-date: ${pluginRow}`);
|
|
147
|
+
assert.ok(commandRow.includes(": up-to-date"), `command up-to-date: ${commandRow}`);
|
|
148
|
+
assert.ok(pluginRow.includes(`installed=${sha256(packaged.ts)} packaged=${sha256(packaged.ts)}`), pluginRow);
|
|
149
|
+
assert.ok(commandRow.includes(`installed=${sha256(packaged.md)} packaged=${sha256(packaged.md)}`), commandRow);
|
|
150
|
+
});
|
|
151
|
+
test("status: outdated (our marker, different bytes) and foreign (no marker) are distinct", async (t) => {
|
|
152
|
+
const { home, env } = await makeEnv(t);
|
|
153
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
154
|
+
await mkdir(path.dirname(pluginTs), { recursive: true });
|
|
155
|
+
await writeFile(pluginTs, `${PLUGIN_MARKER} 0.0.1\n// stale\n`, "utf8");
|
|
156
|
+
await mkdir(path.dirname(commandMd), { recursive: true });
|
|
157
|
+
await writeFile(commandMd, "not ours at all\n", "utf8");
|
|
158
|
+
const run = abathur(env, "opencode", "status");
|
|
159
|
+
assert.equal(run.status, 0);
|
|
160
|
+
assert.ok(run.stdout.includes(": outdated"), `stale ours → outdated: ${run.stdout}`);
|
|
161
|
+
assert.ok(run.stdout.includes(": foreign"), `unmarked → foreign: ${run.stdout}`);
|
|
162
|
+
});
|
|
163
|
+
// ------------------------------------------------------------- uninstall
|
|
164
|
+
test("uninstall: removes both of ours; second run reports absent and still exits 0", async (t) => {
|
|
165
|
+
const { home, env } = await makeEnv(t);
|
|
166
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
167
|
+
assert.equal(abathur(env, "opencode", "install").status, 0);
|
|
168
|
+
const off = abathur(env, "opencode", "uninstall");
|
|
169
|
+
assert.equal(off.status, 0, `${off.stdout}${off.stderr}`);
|
|
170
|
+
await assert.rejects(readFile(pluginTs), (e) => e.code === "ENOENT");
|
|
171
|
+
await assert.rejects(readFile(commandMd), (e) => e.code === "ENOENT");
|
|
172
|
+
const again = abathur(env, "opencode", "uninstall");
|
|
173
|
+
assert.equal(again.status, 0);
|
|
174
|
+
assert.equal(again.stdout.split("\n").filter((l) => l.includes("absent")).length, 2);
|
|
175
|
+
});
|
|
176
|
+
test("uninstall: refuses foreign file and leaves the sibling of ours in place", async (t) => {
|
|
177
|
+
const { home, env } = await makeEnv(t);
|
|
178
|
+
const { pluginTs, commandMd } = targetPaths(home);
|
|
179
|
+
assert.equal(abathur(env, "opencode", "install").status, 0);
|
|
180
|
+
await writeFile(commandMd, "hijacked, marker gone\n", "utf8");
|
|
181
|
+
const run = abathur(env, "opencode", "uninstall");
|
|
182
|
+
assert.equal(run.status, 2, `${run.stdout}${run.stderr}`);
|
|
183
|
+
assert.ok(`${run.stdout}${run.stderr}`.includes(commandMd), "refusal names the foreign path");
|
|
184
|
+
// Atomicity: nothing removed — our plugin file must still be installed.
|
|
185
|
+
assert.ok((await readFile(pluginTs, "utf8")).startsWith(PLUGIN_MARKER));
|
|
186
|
+
assert.ok((await readFile(commandMd, "utf8")).includes("hijacked"));
|
|
187
|
+
});
|
|
188
|
+
// --------------------------------------------------- packaged asset sanity
|
|
189
|
+
test("plugin/abathur.ts: V1 shape — marker, default {id, server}, tool allowlist, argv-only spawn", async () => {
|
|
190
|
+
const bytes = await readFile(pluginAssetPath, "utf8");
|
|
191
|
+
const firstLine = bytes.split("\n")[0] ?? "";
|
|
192
|
+
assert.ok(firstLine.startsWith(PLUGIN_MARKER), `first line must be the marker: ${firstLine}`);
|
|
193
|
+
const version = firstLine.slice(PLUGIN_MARKER.length).trim();
|
|
194
|
+
const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
|
|
195
|
+
assert.equal(version, pkg.version, "plugin marker version must equal package.json version");
|
|
196
|
+
assert.ok(/export default\s*\{/.test(bytes), "V1 default export object required");
|
|
197
|
+
assert.ok(bytes.includes('id: "abathur"'), "file plugins must export id (resolvePluginId)");
|
|
198
|
+
assert.ok(/server:\s*async\s*\(\)/.test(bytes), "server must be an async function (readV1Plugin)");
|
|
199
|
+
assert.ok(bytes.includes("tool.schema"), "args must be built via tool.schema (no local zod dep)");
|
|
200
|
+
for (const command of [
|
|
201
|
+
"genome",
|
|
202
|
+
"run",
|
|
203
|
+
"status",
|
|
204
|
+
"promote",
|
|
205
|
+
"tombstone",
|
|
206
|
+
"bundle",
|
|
207
|
+
"graft",
|
|
208
|
+
"self-eval",
|
|
209
|
+
"kernel",
|
|
210
|
+
"--help",
|
|
211
|
+
]) {
|
|
212
|
+
assert.ok(bytes.includes(`"${command}"`), `allowlist must contain ${command}`);
|
|
213
|
+
}
|
|
214
|
+
assert.ok(bytes.includes("execFile"), "must spawn via execFile argv-only");
|
|
215
|
+
assert.ok(!/shell\s*:\s*true/.test(bytes), "shell:true is banned");
|
|
216
|
+
assert.ok(bytes.includes("ABATHUR_BIN"), "bin overridable via ABATHUR_BIN");
|
|
217
|
+
});
|
|
218
|
+
test("plugin/abathur-command.md: first-line marker, $ARGUMENTS, points at the abathur tool", async () => {
|
|
219
|
+
const text = await readFile(commandAssetPath, "utf8");
|
|
220
|
+
assert.equal(text.split("\n")[0], COMMAND_MARKER, "first line must be the command marker");
|
|
221
|
+
assert.ok(text.includes("$ARGUMENTS"), "template must forward $ARGUMENTS");
|
|
222
|
+
assert.ok(text.includes("`abathur` tool"), "must instruct the agent to use the abathur tool");
|
|
223
|
+
});
|
|
224
|
+
test("shipped plugin assets carry zero machine literals (D7 discipline extended to plugin/)", async () => {
|
|
225
|
+
for (const file of [pluginAssetPath, commandAssetPath]) {
|
|
226
|
+
const text = await readFile(file, "utf8");
|
|
227
|
+
for (const needle of FORBIDDEN_NEEDLES) {
|
|
228
|
+
assert.ok(!text.includes(needle), `${path.relative(repoRoot, file)} must not contain '${needle}'`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
test("CLI help: opencode is a registered top-level command", async (t) => {
|
|
233
|
+
const { env } = await makeEnv(t);
|
|
234
|
+
const help = abathur(env, "--help");
|
|
235
|
+
assert.equal(help.status, 0);
|
|
236
|
+
assert.ok(help.stdout.includes("opencode"), `usage must list opencode: ${help.stdout}`);
|
|
237
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tachikomagundam/abathur",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Evolution harness: observe failures, mutate, re-bench, select — human-gated promotion, offline lineage bundles.",
|
|
5
5
|
"author": "TachikomaGundam",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"config",
|
|
26
26
|
"graders",
|
|
27
27
|
"docs",
|
|
28
|
-
"
|
|
28
|
+
"plugin"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc && node scripts/copy-assets.mjs",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!-- abathur-opencode-command -->
|
|
2
|
+
Drive the abathur evolution harness through the `abathur` tool on this machine.
|
|
3
|
+
|
|
4
|
+
User request: $ARGUMENTS
|
|
5
|
+
|
|
6
|
+
Interpret the request as one `abathur` CLI invocation: the first word is the
|
|
7
|
+
top-level command (`genome`, `run`, `status`, `promote`, `tombstone`, `bundle`,
|
|
8
|
+
`graft`, `self-eval`, `kernel`, or `--help`) and the rest are argv tokens. Call
|
|
9
|
+
the `abathur` tool with `command` set to the first word and `extra` set to the
|
|
10
|
+
remaining tokens, then report the CLI exit code (0 ok / 1 blocked decision /
|
|
11
|
+
2 cannot-answer) and the relevant lines of its output. If no request was given,
|
|
12
|
+
call the tool with `command: "--help"` and summarize the command list. Never
|
|
13
|
+
call `promote` or `tombstone` unless the user asked for exactly that — they are
|
|
14
|
+
human gates.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// abathur-opencode-plugin v0.2.0
|
|
2
|
+
// Official opencode plugin adapter for the abathur evolution harness.
|
|
3
|
+
// Registers ONE agent tool, `abathur`, that shells out to the abathur CLI —
|
|
4
|
+
// argv-only (node:child_process execFile, never a shell), top-level commands
|
|
5
|
+
// restricted to an allowlist, hard timeout, capped output. The CLI itself
|
|
6
|
+
// remains the human gate; this adapter only lets a session *ask* abathur.
|
|
7
|
+
//
|
|
8
|
+
// V1 plugin format (opencode >= 1.14): default export { id, server }, where
|
|
9
|
+
// server(input, options) resolves to Hooks; Hooks.tool is a
|
|
10
|
+
// { [name]: ToolDefinition } record (see @opencode-ai/plugin).
|
|
11
|
+
// This file is shipped as-is (package.json "files") and copied into
|
|
12
|
+
// ~/.config/opencode/plugins/ by `abathur opencode install`. It is NOT
|
|
13
|
+
// compiled by abathur's tsc; "@opencode-ai/plugin" resolves inside opencode's
|
|
14
|
+
// own config-directory install.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { tool } from "@opencode-ai/plugin";
|
|
18
|
+
|
|
19
|
+
/** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
|
|
20
|
+
const TIMEOUT_MS = 120_000;
|
|
21
|
+
/** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
|
|
22
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
23
|
+
|
|
24
|
+
/** The nine real top-level commands plus --help. Anything else is refused locally. */
|
|
25
|
+
const ALLOWED_COMMANDS: readonly string[] = [
|
|
26
|
+
"genome",
|
|
27
|
+
"run",
|
|
28
|
+
"status",
|
|
29
|
+
"promote",
|
|
30
|
+
"tombstone",
|
|
31
|
+
"bundle",
|
|
32
|
+
"graft",
|
|
33
|
+
"self-eval",
|
|
34
|
+
"kernel",
|
|
35
|
+
"--help",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function resolveBin(): string {
|
|
39
|
+
const configured = process.env["ABATHUR_BIN"];
|
|
40
|
+
return configured === undefined || configured.length === 0 ? "abathur" : configured;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CliResult {
|
|
44
|
+
readonly status: number;
|
|
45
|
+
readonly stdout: string;
|
|
46
|
+
readonly stderr: string;
|
|
47
|
+
readonly note: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The subset of the execFile error shape this adapter reads (node sets `code` to the numeric exit status on non-zero exits). */
|
|
51
|
+
interface SpawnError extends Error {
|
|
52
|
+
readonly code?: string | number | null;
|
|
53
|
+
readonly killed?: boolean;
|
|
54
|
+
readonly signal?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Never rejects: every spawn failure becomes a structured result the agent can read. */
|
|
58
|
+
function runCli(bin: string, argv: readonly string[]): Promise<CliResult> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
execFile(
|
|
61
|
+
bin,
|
|
62
|
+
[...argv],
|
|
63
|
+
{ timeout: TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
|
|
64
|
+
(error, stdout, stderr) => {
|
|
65
|
+
const out = stdout.slice(0, MAX_OUTPUT_BYTES);
|
|
66
|
+
const errOut = stderr.slice(0, MAX_OUTPUT_BYTES);
|
|
67
|
+
if (error === null) {
|
|
68
|
+
resolve({ status: 0, stdout: out, stderr: errOut, note: "" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const failure = error as SpawnError;
|
|
72
|
+
if (failure.code === "ENOENT") {
|
|
73
|
+
resolve({
|
|
74
|
+
status: 127,
|
|
75
|
+
stdout: out,
|
|
76
|
+
stderr: errOut,
|
|
77
|
+
note: `binary '${bin}' not found — install abathur globally or point ABATHUR_BIN at it`,
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
82
|
+
resolve({
|
|
83
|
+
status: -1,
|
|
84
|
+
stdout: out,
|
|
85
|
+
stderr: errOut,
|
|
86
|
+
note: `output truncated at ${String(MAX_OUTPUT_BYTES)} bytes (cap per stream)`,
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (typeof failure.code !== "number") {
|
|
91
|
+
const timedOut = failure.killed === true || (failure.signal !== undefined && failure.signal !== null);
|
|
92
|
+
resolve({
|
|
93
|
+
status: timedOut ? 124 : -1,
|
|
94
|
+
stdout: out,
|
|
95
|
+
stderr: errOut,
|
|
96
|
+
note: timedOut
|
|
97
|
+
? `killed after ${String(TIMEOUT_MS / 1000)}s timeout (signal: ${String(failure.signal ?? "SIGTERM")})`
|
|
98
|
+
: `spawn failed: ${failure.message}`,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Ordinary non-zero exit: the CLI's own verdict (blocked / cannot-answer).
|
|
103
|
+
resolve({ status: failure.code, stdout: out, stderr: errOut, note: "" });
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default {
|
|
110
|
+
id: "abathur",
|
|
111
|
+
server: async () => ({
|
|
112
|
+
tool: {
|
|
113
|
+
abathur: tool({
|
|
114
|
+
description:
|
|
115
|
+
"Run the abathur evolution-harness CLI on this machine. " +
|
|
116
|
+
"Pass the top-level command word in `command` (one of: genome, run, status, " +
|
|
117
|
+
"promote, tombstone, bundle, graft, self-eval, kernel, --help) and every " +
|
|
118
|
+
"remaining argv token in `extra`. The call is spawned argv-only (no shell) " +
|
|
119
|
+
"with a 120s timeout; the result text always ends with the CLI exit code " +
|
|
120
|
+
"(0 ok, 1 blocked decision, 2 cannot-answer). Promotion is a human gate — " +
|
|
121
|
+
"never call `promote` without the user explicitly asking.",
|
|
122
|
+
args: {
|
|
123
|
+
command: tool.schema
|
|
124
|
+
.string()
|
|
125
|
+
.describe("top-level abathur command, e.g. 'genome' or 'status' or '--help'"),
|
|
126
|
+
extra: tool.schema
|
|
127
|
+
.array(tool.schema.string())
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("remaining argv tokens, e.g. ['add', '/path/to/genome.jsonc']"),
|
|
130
|
+
},
|
|
131
|
+
execute: async (args, context) => {
|
|
132
|
+
const command = args.command.trim();
|
|
133
|
+
if (!ALLOWED_COMMANDS.includes(command)) {
|
|
134
|
+
return (
|
|
135
|
+
`abathur: refused '${command}' — not an allowed top-level command. ` +
|
|
136
|
+
`Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
|
|
137
|
+
`and every other token in 'extra'.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const argv: readonly string[] = [command, ...(args.extra ?? [])];
|
|
141
|
+
context.metadata({ title: `abathur ${argv.join(" ")}` });
|
|
142
|
+
const bin = resolveBin();
|
|
143
|
+
const result = await runCli(bin, argv);
|
|
144
|
+
const sections = [
|
|
145
|
+
`$ abathur ${argv.join(" ")}`,
|
|
146
|
+
`exit: ${String(result.status)}`,
|
|
147
|
+
];
|
|
148
|
+
if (result.note.length > 0) sections.push(`note: ${result.note}`);
|
|
149
|
+
sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
|
|
150
|
+
sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
|
|
151
|
+
return sections.join("\n");
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
};
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
branches: [main]
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: read
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
build-test:
|
|
14
|
-
name: Build & test (Node 22)
|
|
15
|
-
runs-on: ubuntu-latest
|
|
16
|
-
steps:
|
|
17
|
-
- uses: actions/checkout@v4
|
|
18
|
-
|
|
19
|
-
- uses: actions/setup-node@v4
|
|
20
|
-
with:
|
|
21
|
-
node-version: 22
|
|
22
|
-
cache: npm
|
|
23
|
-
# NOTE: deliberately NO registry-url here. Setting registry-url makes
|
|
24
|
-
# setup-node inject NODE_AUTH_TOKEN, which switches npm to authenticated
|
|
25
|
-
# legacy auth and defeats Trusted Publishing (OIDC) downstream.
|
|
26
|
-
|
|
27
|
-
- run: npm ci
|
|
28
|
-
- run: npm run build
|
|
29
|
-
- run: npm test
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# npm Trusted Publishing (OIDC) — zero long-lived credentials on GitHub.
|
|
2
|
-
#
|
|
3
|
-
# One-time setup required in the npmjs.com UI (Publishing access →
|
|
4
|
-
# "Trusted publisher" → Add publisher → GitHub Actions):
|
|
5
|
-
# Owner / user: TachikomaGundam
|
|
6
|
-
# Package name: @tachikomagundam/abathur
|
|
7
|
-
# Repository: Abathur
|
|
8
|
-
# Workflow name: publish.yml
|
|
9
|
-
# Environment: (leave empty unless you gate it)
|
|
10
|
-
# Release type: progressive
|
|
11
|
-
# The event this workflow publishes on is `push` (of a v* tag);
|
|
12
|
-
# the action is `npm publish --provenance --access public`.
|
|
13
|
-
#
|
|
14
|
-
# If the trusted publisher is not registered, `npm publish` fails with
|
|
15
|
-
# a 401/EPUBLISHCONFLICT — that is expected until the UI step is done.
|
|
16
|
-
name: Publish
|
|
17
|
-
|
|
18
|
-
on:
|
|
19
|
-
push:
|
|
20
|
-
tags:
|
|
21
|
-
- 'v*'
|
|
22
|
-
workflow_dispatch:
|
|
23
|
-
|
|
24
|
-
permissions:
|
|
25
|
-
contents: read
|
|
26
|
-
id-token: write # required for npm Trusted Publishing (OIDC provenance)
|
|
27
|
-
|
|
28
|
-
jobs:
|
|
29
|
-
publish:
|
|
30
|
-
name: Publish to npm
|
|
31
|
-
runs-on: ubuntu-latest
|
|
32
|
-
steps:
|
|
33
|
-
- uses: actions/checkout@v4
|
|
34
|
-
|
|
35
|
-
- uses: actions/setup-node@v4
|
|
36
|
-
with:
|
|
37
|
-
# Node 24 ships npm >= 11.5.1, the minimum version that supports
|
|
38
|
-
# Trusted Publishing. Older npm silently falls back to token auth.
|
|
39
|
-
# NOTE: deliberately NO registry-url here — setting it injects
|
|
40
|
-
# NODE_AUTH_TOKEN and defeats OIDC (documented pitfall).
|
|
41
|
-
node-version: 24
|
|
42
|
-
cache: npm
|
|
43
|
-
|
|
44
|
-
- name: Enforce tag == package.json version
|
|
45
|
-
env:
|
|
46
|
-
GH_REF: ${{ github.ref }}
|
|
47
|
-
GH_EVENT: ${{ github.event_name }}
|
|
48
|
-
run: |
|
|
49
|
-
set -euo pipefail
|
|
50
|
-
if [ "${GH_EVENT}" = "workflow_dispatch" ]; then
|
|
51
|
-
# A dispatch lands on the default-branch tip, which carries no tag,
|
|
52
|
-
# so the describe-based check below would ALWAYS fail there. Skip it:
|
|
53
|
-
# the npm-side trusted-publisher event config remains the real gate.
|
|
54
|
-
echo "::warning::manual dispatch publish — npm Trusted Publishing event config will reject this unless a workflow_dispatch publisher is registered"
|
|
55
|
-
elif [ "${GH_REF#refs/tags/}" != "${GH_REF}" ]; then
|
|
56
|
-
# push of a v* tag: HEAD must sit exactly on that tag and the tag
|
|
57
|
-
# (minus the leading v) must equal package.json version.
|
|
58
|
-
TAG="$(git describe --tags --exact-match HEAD)"
|
|
59
|
-
VERSION="$(node -p "require('./package.json').version")"
|
|
60
|
-
if [ "${TAG#v}" != "${VERSION}" ]; then
|
|
61
|
-
echo "::error::git tag '${TAG}' does not match package.json version '${VERSION}'"
|
|
62
|
-
exit 1
|
|
63
|
-
fi
|
|
64
|
-
echo "Publishing ${VERSION} (tag ${TAG})"
|
|
65
|
-
else
|
|
66
|
-
echo "::error::unexpected event '${GH_EVENT}' on ref '${GH_REF}' — refusing to publish"
|
|
67
|
-
exit 1
|
|
68
|
-
fi
|
|
69
|
-
|
|
70
|
-
- run: npm ci
|
|
71
|
-
|
|
72
|
-
- run: npm run build
|
|
73
|
-
|
|
74
|
-
- run: npm publish --provenance --access public
|