@statista-oss/pi-beans 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -0
- package/package.json +52 -0
- package/src/commands.ts +126 -0
- package/src/env.ts +187 -0
- package/src/index.ts +29 -0
- package/src/internal.ts +162 -0
- package/src/prime.ts +77 -0
- package/src/tools/create.ts +46 -0
- package/src/tools/index.ts +28 -0
- package/src/tools/list.ts +39 -0
- package/src/tools/show.ts +26 -0
- package/src/tools/update.ts +105 -0
- package/src/tools/util.ts +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# pi-beans
|
|
2
|
+
|
|
3
|
+
A small [pi](https://github.com/badlogic/pi-mono) extension that gives the
|
|
4
|
+
coding agent first-class access to [Beans](https://github.com/hmans/beans),
|
|
5
|
+
an issue tracker that lives next to your code. The agent can list open work,
|
|
6
|
+
read a bean before starting on it, and record progress as it goes — all
|
|
7
|
+
through the existing `beans` CLI, with no extra config and no parsing of
|
|
8
|
+
`.beans/*.md` on our side.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
At runtime the extension expects:
|
|
13
|
+
|
|
14
|
+
1. The `beans` binary to be on `PATH`.
|
|
15
|
+
2. A `.beans.yml` file in the current working directory or any parent
|
|
16
|
+
(i.e. `beans init` has been run for the project).
|
|
17
|
+
|
|
18
|
+
If either is missing the extension stays loaded but disables all of its
|
|
19
|
+
tools and slash commands, and prints a single notice on session start. It
|
|
20
|
+
will not crash pi.
|
|
21
|
+
|
|
22
|
+
## Install / activation
|
|
23
|
+
|
|
24
|
+
Install with this command:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install -l npm:@statista-oss/pi-beans@0.1.0
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
You can keep the extension permanently enabled in your shell alias if you
|
|
31
|
+
want; it no-ops in projects without a `.beans.yml`.
|
|
32
|
+
|
|
33
|
+
On `session_start` the extension runs `beans prime` once and injects its
|
|
34
|
+
output into the system prompt (refreshed again on `session_before_compact`
|
|
35
|
+
so it survives compaction).
|
|
36
|
+
|
|
37
|
+
## Tools
|
|
38
|
+
|
|
39
|
+
Registered only when the prerequisites above are met. All output is
|
|
40
|
+
truncated through pi's standard `truncateHead` (50KB / 2000 lines) and
|
|
41
|
+
non-zero exits are surfaced to the LLM as errors with stderr included.
|
|
42
|
+
|
|
43
|
+
| Tool | Parameters | Description |
|
|
44
|
+
| -------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `beans_list` | `status?`, `include_archived?`, `query?` | List beans (open by default). |
|
|
46
|
+
| `beans_show` | `id` | Show one bean's full markdown + metadata. |
|
|
47
|
+
| `beans_create` | `title`, `body?`, `status?` | Create a new bean. Long bodies go via a temp file. |
|
|
48
|
+
| `beans_update` | `id`, `status?`, `title?`, `body?`, `body_append?` | Update fields. `body` and `body_append` are mutually exclusive; at least one mutating field is required. |
|
|
49
|
+
|
|
50
|
+
`beans_update` serializes its read-modify-write window through pi's
|
|
51
|
+
`withFileMutationQueue` keyed on the bean's markdown file, so it interleaves
|
|
52
|
+
correctly with built-in `edit` / `write`.
|
|
53
|
+
|
|
54
|
+
## Slash commands
|
|
55
|
+
|
|
56
|
+
Two thin commands for the human in the loop. They shell out directly and do
|
|
57
|
+
not call the LLM.
|
|
58
|
+
|
|
59
|
+
- `/beans` — runs `beans list` and shows the result via `ctx.ui.notify`.
|
|
60
|
+
- `/bean <id>` — runs `beans show <id>` and pastes the result into the
|
|
61
|
+
editor as context for your next message.
|
|
62
|
+
|
|
63
|
+
When the prerequisites are not met both commands print a clear
|
|
64
|
+
`beans not configured` message instead.
|
|
65
|
+
|
|
66
|
+
## Footer status line
|
|
67
|
+
|
|
68
|
+
When the extension is active the footer shows a single line like:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
beans: 12 open
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The count is parsed best-effort from `beans list` on session start. It is
|
|
75
|
+
cleared on `session_shutdown` and is intentionally not refreshed after every
|
|
76
|
+
tool call (beans rarely change that fast; the agent can re-list explicitly).
|
|
77
|
+
|
|
78
|
+
## Troubleshooting
|
|
79
|
+
|
|
80
|
+
**`beans: binary not found on PATH`** — install the `beans` CLI and make
|
|
81
|
+
sure `beans --version` works in the same shell you launch pi from. Tools
|
|
82
|
+
will stay disabled until then.
|
|
83
|
+
|
|
84
|
+
**`beans: no .beans.yml found`** — run `beans init` in your project root,
|
|
85
|
+
or `cd` into a directory that has one above it. The extension walks up
|
|
86
|
+
from the current directory looking for `.beans.yml`; the directory
|
|
87
|
+
containing it is treated as the project root for every command.
|
|
88
|
+
|
|
89
|
+
**`beans prime failed`** — the extension logs this to the status line and
|
|
90
|
+
continues with tools enabled but without the prime injection. Run
|
|
91
|
+
`beans prime` manually to see the underlying error; common causes are a
|
|
92
|
+
corrupted `.beans/` directory or a CLI version mismatch.
|
|
93
|
+
|
|
94
|
+
**A tool call returns a CLI error verbatim** — that is intentional. The
|
|
95
|
+
extension forwards `beans`' own stderr (e.g. unknown bean ID, invalid
|
|
96
|
+
status) so the LLM can react to it directly.
|
|
97
|
+
|
|
98
|
+
## Releasing
|
|
99
|
+
|
|
100
|
+
This repo uses [Changesets](https://github.com/changesets/changesets) for
|
|
101
|
+
versioning and publishing to npm.
|
|
102
|
+
|
|
103
|
+
1. After making user-facing changes, add a changeset:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm changeset
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pick a bump type (`patch`, `minor`, `major`) and write a short summary.
|
|
110
|
+
Commit the generated `.changeset/*.md` file alongside your change.
|
|
111
|
+
|
|
112
|
+
2. When changesets land on `main`, the `Release` GitHub Actions workflow
|
|
113
|
+
opens (or updates) a “Version Packages” PR that bumps the version and
|
|
114
|
+
updates `CHANGELOG.md`.
|
|
115
|
+
|
|
116
|
+
3. Merging that PR triggers the same workflow to run `pnpm release`
|
|
117
|
+
(`changeset publish`), which publishes to npm and pushes the git tag.
|
|
118
|
+
|
|
119
|
+
A repository secret `NPM_TOKEN` with publish rights is required for the
|
|
120
|
+
publish step. The workflow requests `id-token: write` so npm provenance is
|
|
121
|
+
attached automatically.
|
|
122
|
+
|
|
123
|
+
## Status
|
|
124
|
+
|
|
125
|
+
v1. No `archive` / `delete` tools, no GraphQL tool, no autocomplete, no
|
|
126
|
+
custom config flags. See [`SPEC.md`](./SPEC.md) for the full design and the
|
|
127
|
+
explicit out-of-scope list.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statista-oss/pi-beans",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "pi extension that integrates the Beans CLI into the coding agent.",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"README.md"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "src/index.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@changesets/cli": "^2.31.0",
|
|
16
|
+
"@mariozechner/pi-ai": "^0.73.0",
|
|
17
|
+
"@mariozechner/pi-coding-agent": "^0.73.0",
|
|
18
|
+
"@mariozechner/pi-tui": "^0.73.0",
|
|
19
|
+
"@types/node": "^25.6.0",
|
|
20
|
+
"oxfmt": "^0.48.0",
|
|
21
|
+
"oxlint": "^1.63.0",
|
|
22
|
+
"oxlint-tsgolint": "^0.22.1",
|
|
23
|
+
"typebox": "^1.1.38",
|
|
24
|
+
"typescript": "^6.0.0",
|
|
25
|
+
"node": "runtime:^24"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"devEngines": {
|
|
31
|
+
"runtime": {
|
|
32
|
+
"name": "node",
|
|
33
|
+
"version": "^24",
|
|
34
|
+
"onFail": "download"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./src/index.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"start": "pi -e ./src/index.ts",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"lint": "oxlint",
|
|
46
|
+
"fmt": "oxfmt",
|
|
47
|
+
"fmt:check": "oxfmt --check",
|
|
48
|
+
"changeset": "changeset",
|
|
49
|
+
"version": "changeset version",
|
|
50
|
+
"release": "changeset publish"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track D — slash commands and footer status line for pi-beans.
|
|
3
|
+
*
|
|
4
|
+
* Registers two user-facing slash commands and maintains a small footer
|
|
5
|
+
* status entry that reflects the number of currently open beans. All work
|
|
6
|
+
* goes through the shared accessors in `./internal.js`, so this module has
|
|
7
|
+
* no direct dependency on Track A's implementation file.
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* /beans — list open beans (notification).
|
|
11
|
+
* /bean <id> — show one bean and paste its content into the editor.
|
|
12
|
+
*
|
|
13
|
+
* Status line:
|
|
14
|
+
* key "beans" — "beans: N open" when available, "beans: ?" on error,
|
|
15
|
+
* cleared when beans is unavailable or the session shuts down.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import { beansEvents, getEnv, getRunner } from "./internal.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wire up Track D into the given pi extension API.
|
|
23
|
+
*
|
|
24
|
+
* Safe to call exactly once per extension load. Subscribes to a few pi and
|
|
25
|
+
* beans events to keep the footer status entry in sync.
|
|
26
|
+
*/
|
|
27
|
+
export function registerCommands(pi: ExtensionAPI): void {
|
|
28
|
+
// Cache the most recent ExtensionContext so beans event listeners (which
|
|
29
|
+
// fire outside of a pi event handler) can still reach ctx.ui.setStatus.
|
|
30
|
+
let lastCtx: ExtensionContext | undefined;
|
|
31
|
+
|
|
32
|
+
pi.registerCommand("beans", {
|
|
33
|
+
description: "List open beans",
|
|
34
|
+
handler: async (_args, ctx) => {
|
|
35
|
+
const env = getEnv();
|
|
36
|
+
const runner = getRunner();
|
|
37
|
+
if (!env.available || !runner) {
|
|
38
|
+
ctx.ui.notify("beans not configured", "warning");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const out = await runner.run(["list"]);
|
|
43
|
+
ctx.ui.notify(out.trim() || "(no beans)", "info");
|
|
44
|
+
} catch (e) {
|
|
45
|
+
ctx.ui.notify(`beans list failed: ${(e as Error).message}`, "error");
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.registerCommand("bean", {
|
|
51
|
+
description: "Show a bean and paste it into the editor",
|
|
52
|
+
handler: async (args, ctx) => {
|
|
53
|
+
const id = args.trim();
|
|
54
|
+
if (!id) {
|
|
55
|
+
ctx.ui.notify("usage: /bean <id>", "warning");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const env = getEnv();
|
|
59
|
+
const runner = getRunner();
|
|
60
|
+
if (!env.available || !runner) {
|
|
61
|
+
ctx.ui.notify("beans not configured", "warning");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const out = await runner.run(["show", id]);
|
|
66
|
+
ctx.ui.pasteToEditor(out);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
ctx.ui.notify(`beans show failed: ${(e as Error).message}`, "error");
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Refresh the "beans" footer entry from the current env + runner.
|
|
75
|
+
*
|
|
76
|
+
* Counts open beans (excluding completed and scrapped) by running
|
|
77
|
+
* `beans list --json --no-status completed --no-status scrapped` and
|
|
78
|
+
* measuring the length of the returned JSON array — exact and format-independent.
|
|
79
|
+
*/
|
|
80
|
+
async function refreshStatus(ctx: ExtensionContext): Promise<void> {
|
|
81
|
+
const env = getEnv();
|
|
82
|
+
const runner = getRunner();
|
|
83
|
+
if (!env.available || !runner) {
|
|
84
|
+
ctx.ui.setStatus("beans", undefined);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const out = await runner.run([
|
|
89
|
+
"list",
|
|
90
|
+
"--json",
|
|
91
|
+
"--no-status",
|
|
92
|
+
"completed",
|
|
93
|
+
"--no-status",
|
|
94
|
+
"scrapped",
|
|
95
|
+
]);
|
|
96
|
+
const raw: unknown = JSON.parse(out);
|
|
97
|
+
// The CLI returns `null` (not `[]`) when no beans match the filter.
|
|
98
|
+
const parsed: unknown = raw === null ? [] : raw;
|
|
99
|
+
if (!Array.isArray(parsed)) throw new Error("beans list --json: expected array");
|
|
100
|
+
const count = parsed.length;
|
|
101
|
+
ctx.ui.setStatus("beans", ctx.ui.theme.fg("dim", `beans: ${count} open`));
|
|
102
|
+
} catch {
|
|
103
|
+
ctx.ui.setStatus("beans", ctx.ui.theme.fg("warning", "beans: ?"));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pi.on("session_start", (_e, ctx) => {
|
|
108
|
+
lastCtx = ctx;
|
|
109
|
+
// env_ready fires from Track A inside session_start; we also kick off
|
|
110
|
+
// an initial refresh so the footer reflects whatever state is cached.
|
|
111
|
+
void refreshStatus(ctx);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
beansEvents.on("env_ready", () => {
|
|
115
|
+
if (lastCtx) void refreshStatus(lastCtx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
beansEvents.on("beans_mutated", () => {
|
|
119
|
+
if (lastCtx) void refreshStatus(lastCtx);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("session_shutdown", (_e, ctx) => {
|
|
123
|
+
ctx.ui.setStatus("beans", undefined);
|
|
124
|
+
lastCtx = undefined;
|
|
125
|
+
});
|
|
126
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track A — Environment detection, runner, and lifecycle wiring.
|
|
3
|
+
*
|
|
4
|
+
* This module is the only place that touches the host filesystem to find the
|
|
5
|
+
* `beans` binary and the project's `.beans.yml`. Other tracks talk to it
|
|
6
|
+
* exclusively through the contracts in `./internal.ts` (events + cached state).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { constants as fsConstants } from "node:fs";
|
|
10
|
+
import { access, stat } from "node:fs/promises";
|
|
11
|
+
import { delimiter, dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import type { ExecOptions, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
beansEvents,
|
|
17
|
+
getEnv,
|
|
18
|
+
setEnv,
|
|
19
|
+
type BeansEnv,
|
|
20
|
+
type BeansRunOptions,
|
|
21
|
+
type BeansRunner,
|
|
22
|
+
} from "./internal.js";
|
|
23
|
+
|
|
24
|
+
/** Default per-call timeout for `beans` invocations (ms). */
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
26
|
+
|
|
27
|
+
/** `pi.exec`-shaped function we use here and in `createRunner`. */
|
|
28
|
+
type ExecFn = (
|
|
29
|
+
command: string,
|
|
30
|
+
args: string[],
|
|
31
|
+
options?: ExecOptions,
|
|
32
|
+
) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect whether beans is usable from `cwd`.
|
|
36
|
+
*
|
|
37
|
+
* Walks up the directory tree from `cwd` looking for `.beans.yml`; the
|
|
38
|
+
* directory containing it becomes the project root used for every CLI call.
|
|
39
|
+
* Then resolves the `beans` binary on `PATH` (honoring `PATHEXT` on Windows).
|
|
40
|
+
*
|
|
41
|
+
* Never throws — returns `{ available: false, reason }` on any failure.
|
|
42
|
+
*/
|
|
43
|
+
export async function detectBeansEnv(cwd: string): Promise<BeansEnv> {
|
|
44
|
+
const projectRoot = await findProjectRoot(cwd);
|
|
45
|
+
if (!projectRoot) {
|
|
46
|
+
return { available: false, reason: `no .beans.yml found above ${cwd}`, cwd, binary: "" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const binary = await resolveBeansBinary();
|
|
50
|
+
if (!binary) {
|
|
51
|
+
return {
|
|
52
|
+
available: false,
|
|
53
|
+
reason: "`beans` binary not found on PATH",
|
|
54
|
+
cwd: projectRoot,
|
|
55
|
+
binary: "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { available: true, cwd: projectRoot, binary };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a {@link BeansRunner} bound to a detected env.
|
|
64
|
+
*
|
|
65
|
+
* The runner forwards to `exec(env.binary, args, { cwd, signal, timeout })`,
|
|
66
|
+
* defaults `cwd` to `env.cwd` and `timeout` to {@link DEFAULT_TIMEOUT_MS}, and
|
|
67
|
+
* throws an Error on non-zero exit (with stderr — or stdout if stderr is
|
|
68
|
+
* empty — in the message).
|
|
69
|
+
*/
|
|
70
|
+
export function createRunner(env: BeansEnv, exec: ExecFn): BeansRunner {
|
|
71
|
+
return {
|
|
72
|
+
async run(args: string[], opts?: BeansRunOptions): Promise<string> {
|
|
73
|
+
const result = await exec(env.binary, args, {
|
|
74
|
+
cwd: opts?.cwd ?? env.cwd,
|
|
75
|
+
signal: opts?.signal,
|
|
76
|
+
timeout: opts?.timeout ?? DEFAULT_TIMEOUT_MS,
|
|
77
|
+
});
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
80
|
+
throw new Error(`beans ${args.join(" ")} failed (exit ${result.code}): ${detail}`);
|
|
81
|
+
}
|
|
82
|
+
return result.stdout;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wire env detection and lifecycle hooks into the extension API.
|
|
89
|
+
*
|
|
90
|
+
* - `session_start`: detect env, build runner, publish via `setEnv` (which
|
|
91
|
+
* emits `env_ready`). On failure, surface a single warning notice. Track B
|
|
92
|
+
* subscribes to `env_ready` itself to run `beans prime`.
|
|
93
|
+
* - `session_before_compact`: re-emit `env_ready` so Track B treats it as a
|
|
94
|
+
* refresh signal and re-injects the prime output post-compaction. Track A
|
|
95
|
+
* intentionally doesn't import Track B directly to keep tracks decoupled.
|
|
96
|
+
* - `session_shutdown`: clear our footer status and emit `shutdown` so other
|
|
97
|
+
* tracks can release any per-session state.
|
|
98
|
+
*/
|
|
99
|
+
export function registerEnv(pi: ExtensionAPI): void {
|
|
100
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
101
|
+
const env = await detectBeansEnv(ctx.cwd);
|
|
102
|
+
const runner = env.available ? createRunner(env, (...args) => pi.exec(...args)) : undefined;
|
|
103
|
+
setEnv(env, runner);
|
|
104
|
+
if (!env.available && env.reason) {
|
|
105
|
+
ctx.ui.notify(`pi-beans: ${env.reason}`, "warning");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
pi.on("session_before_compact", async () => {
|
|
110
|
+
// Track B owns prime. We just re-emit env_ready so it refreshes its cache
|
|
111
|
+
// before the compacted system prompt is built.
|
|
112
|
+
beansEvents.emit("env_ready", getEnv());
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
116
|
+
ctx.ui.setStatus("beans", undefined);
|
|
117
|
+
beansEvents.emit("shutdown");
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Helpers
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/** Walk up from `cwd` until a directory containing `.beans.yml` is found. */
|
|
126
|
+
async function findProjectRoot(cwd: string): Promise<string | undefined> {
|
|
127
|
+
let dir = resolve(cwd);
|
|
128
|
+
const root = parse(dir).root;
|
|
129
|
+
// Bound the walk by filesystem root; `dirname(root) === root` terminates.
|
|
130
|
+
while (true) {
|
|
131
|
+
if (await isFile(join(dir, ".beans.yml"))) return dir;
|
|
132
|
+
if (dir === root) return undefined;
|
|
133
|
+
const parent = dirname(dir);
|
|
134
|
+
if (parent === dir) return undefined;
|
|
135
|
+
dir = parent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve the `beans` executable on `PATH`, honoring `PATHEXT` on Windows. */
|
|
140
|
+
async function resolveBeansBinary(): Promise<string | undefined> {
|
|
141
|
+
const pathEnv = process.env.PATH ?? "";
|
|
142
|
+
if (!pathEnv) return undefined;
|
|
143
|
+
|
|
144
|
+
const isWindows = process.platform === "win32";
|
|
145
|
+
const exts = isWindows ? windowsPathExt() : [""];
|
|
146
|
+
const entries = pathEnv.split(delimiter).filter((p) => p.length > 0);
|
|
147
|
+
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const dir = isAbsolute(entry) ? entry : resolve(entry);
|
|
150
|
+
for (const ext of exts) {
|
|
151
|
+
const candidate = join(dir, `beans${ext}`);
|
|
152
|
+
if (await isExecutableFile(candidate)) return candidate;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parse Windows `PATHEXT`, defaulting to a sensible set if unset. */
|
|
159
|
+
function windowsPathExt(): string[] {
|
|
160
|
+
const raw = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
161
|
+
return raw
|
|
162
|
+
.split(";")
|
|
163
|
+
.map((e) => e.trim())
|
|
164
|
+
.filter((e) => e.length > 0)
|
|
165
|
+
.map((e) => (e.startsWith(".") ? e : `.${e}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function isFile(path: string): Promise<boolean> {
|
|
169
|
+
try {
|
|
170
|
+
const s = await stat(path);
|
|
171
|
+
return s.isFile();
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function isExecutableFile(path: string): Promise<boolean> {
|
|
178
|
+
try {
|
|
179
|
+
const s = await stat(path);
|
|
180
|
+
if (!s.isFile()) return false;
|
|
181
|
+
if (process.platform === "win32") return true; // PATHEXT match is sufficient
|
|
182
|
+
await access(path, fsConstants.X_OK);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-beans — Beans CLI integration for pi.
|
|
3
|
+
*
|
|
4
|
+
* This file is the single entry point pi loads via `pi -e ./src/index.ts`.
|
|
5
|
+
* Concrete behavior lives in the per-track modules below; this file just
|
|
6
|
+
* wires their `register*` functions into the ExtensionAPI.
|
|
7
|
+
*
|
|
8
|
+
* Track ownership (see PLAN.md):
|
|
9
|
+
* - Track A → ./env.ts (detection, lifecycle, runner)
|
|
10
|
+
* - Track B → ./prime.ts (`beans prime` injection)
|
|
11
|
+
* - Track C → ./tools/*.ts (LLM-callable tools)
|
|
12
|
+
* - Track D → ./commands.ts (slash commands + footer status)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { registerEnv } from "./env.js";
|
|
18
|
+
import { registerPrime } from "./prime.js";
|
|
19
|
+
import { registerTools } from "./tools/index.js";
|
|
20
|
+
import { registerCommands } from "./commands.js";
|
|
21
|
+
|
|
22
|
+
export default function piBeans(pi: ExtensionAPI): void {
|
|
23
|
+
// Order matters only for readability — each register* call is independent
|
|
24
|
+
// and coordinates with the others through src/internal.ts (events + state).
|
|
25
|
+
registerEnv(pi);
|
|
26
|
+
registerPrime(pi);
|
|
27
|
+
registerTools(pi);
|
|
28
|
+
registerCommands(pi);
|
|
29
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared internal contracts for pi-beans.
|
|
3
|
+
*
|
|
4
|
+
* Each parallel track only depends on this module (and pi's SDK), so the
|
|
5
|
+
* tracks can be implemented in isolation without stepping on each other.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExecOptions } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Result of detecting whether beans is usable in the current project.
|
|
12
|
+
*
|
|
13
|
+
* Detection never throws — if beans is unavailable for any reason we return
|
|
14
|
+
* `available: false` plus a human-readable `reason` so the extension can show
|
|
15
|
+
* a one-line notice and stay loaded.
|
|
16
|
+
*/
|
|
17
|
+
export interface BeansEnv {
|
|
18
|
+
/** True when `beans` is on PATH AND a `.beans.yml` was found. */
|
|
19
|
+
available: boolean;
|
|
20
|
+
/** Why beans isn't available, for the session_start notice. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Project root used for every CLI invocation. This is the directory that
|
|
24
|
+
* contains `.beans.yml`. When `available` is false this falls back to the
|
|
25
|
+
* pi cwd.
|
|
26
|
+
*/
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Resolved absolute path to the `beans` executable (may be empty when unavailable). */
|
|
29
|
+
binary: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Thin wrapper around `pi.exec("beans", args, { cwd })`.
|
|
34
|
+
*
|
|
35
|
+
* - Always runs from `env.cwd` (the project root).
|
|
36
|
+
* - Returns stdout on success.
|
|
37
|
+
* - Throws on non-zero exit; the thrown Error's message includes stderr
|
|
38
|
+
* (or stdout if stderr was empty) so tools can surface it to the LLM.
|
|
39
|
+
*/
|
|
40
|
+
export interface BeansRunner {
|
|
41
|
+
run(args: string[], opts?: BeansRunOptions): Promise<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BeansRunOptions {
|
|
45
|
+
/** Optional stdin. Currently unused by v1 tools; reserved for future use. */
|
|
46
|
+
stdin?: string;
|
|
47
|
+
/** Forwarded to pi.exec. */
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** Forwarded to pi.exec. Defaults to a sensible per-call timeout. */
|
|
50
|
+
timeout?: number;
|
|
51
|
+
/** Override cwd; defaults to `env.cwd`. */
|
|
52
|
+
cwd?: string;
|
|
53
|
+
/** Extra exec options (rarely needed). */
|
|
54
|
+
exec?: ExecOptions;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Stubs implemented by Track A (src/env.ts)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect whether beans is usable from `cwd`.
|
|
63
|
+
*
|
|
64
|
+
* Implemented in `./env.ts` (Track A). Declared here so the other tracks can
|
|
65
|
+
* import the type without introducing a build-time cycle.
|
|
66
|
+
*/
|
|
67
|
+
export type DetectBeansEnv = (cwd: string) => Promise<BeansEnv>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a runner bound to a detected env.
|
|
71
|
+
*
|
|
72
|
+
* Implemented in `./env.ts` (Track A).
|
|
73
|
+
*/
|
|
74
|
+
export type CreateRunner = (
|
|
75
|
+
env: BeansEnv,
|
|
76
|
+
exec: (
|
|
77
|
+
command: string,
|
|
78
|
+
args: string[],
|
|
79
|
+
options?: ExecOptions,
|
|
80
|
+
) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>,
|
|
81
|
+
) => BeansRunner;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Tiny pub/sub so tracks can react to env / prime / mutation events without
|
|
85
|
+
// importing each other directly.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export type BeansEventName =
|
|
89
|
+
/** Fires when env detection finishes (whether or not beans is available). */
|
|
90
|
+
| "env_ready"
|
|
91
|
+
/** Fires when `beans prime` output is refreshed (or cleared on failure). */
|
|
92
|
+
| "prime_updated"
|
|
93
|
+
/** Fires when one of our tools mutated beans state (create/update). */
|
|
94
|
+
| "beans_mutated"
|
|
95
|
+
/** Fires on session shutdown so subscribers can clear status, etc. */
|
|
96
|
+
| "shutdown";
|
|
97
|
+
|
|
98
|
+
type Listener = (...args: any[]) => void;
|
|
99
|
+
|
|
100
|
+
class BeansEventBus {
|
|
101
|
+
private readonly listeners = new Map<BeansEventName, Set<Listener>>();
|
|
102
|
+
|
|
103
|
+
on(event: BeansEventName, fn: Listener): () => void {
|
|
104
|
+
let set = this.listeners.get(event);
|
|
105
|
+
if (!set) {
|
|
106
|
+
set = new Set();
|
|
107
|
+
this.listeners.set(event, set);
|
|
108
|
+
}
|
|
109
|
+
set.add(fn);
|
|
110
|
+
return () => set!.delete(fn);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
emit(event: BeansEventName, ...args: any[]): void {
|
|
114
|
+
const set = this.listeners.get(event);
|
|
115
|
+
if (!set) return;
|
|
116
|
+
for (const fn of set) {
|
|
117
|
+
try {
|
|
118
|
+
fn(...args);
|
|
119
|
+
} catch {
|
|
120
|
+
// listeners must not break each other
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
clear(): void {
|
|
126
|
+
this.listeners.clear();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const beansEvents = new BeansEventBus();
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Shared module state. Tracks read/write through these accessors so they
|
|
134
|
+
// don't need to import each other's modules.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
let cachedEnv: BeansEnv | undefined;
|
|
138
|
+
let cachedRunner: BeansRunner | undefined;
|
|
139
|
+
|
|
140
|
+
/** Set by Track A after detection. */
|
|
141
|
+
export function setEnv(env: BeansEnv, runner: BeansRunner | undefined): void {
|
|
142
|
+
cachedEnv = env;
|
|
143
|
+
cachedRunner = runner;
|
|
144
|
+
beansEvents.emit("env_ready", env);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read the cached env (returns a disabled fallback before detection completes). */
|
|
148
|
+
export function getEnv(): BeansEnv {
|
|
149
|
+
return (
|
|
150
|
+
cachedEnv ?? {
|
|
151
|
+
available: false,
|
|
152
|
+
reason: "beans env not yet detected",
|
|
153
|
+
cwd: process.cwd(),
|
|
154
|
+
binary: "",
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read the cached runner (or undefined before detection / when unavailable). */
|
|
160
|
+
export function getRunner(): BeansRunner | undefined {
|
|
161
|
+
return cachedRunner;
|
|
162
|
+
}
|
package/src/prime.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track B — `beans prime` injection.
|
|
3
|
+
*
|
|
4
|
+
* Runs `beans prime` once after env detection (and again when Track A asks
|
|
5
|
+
* us to, e.g. on `session_before_compact`), caches the output in module
|
|
6
|
+
* state, and appends it to the system prompt via `before_agent_start`.
|
|
7
|
+
*
|
|
8
|
+
* All failures are non-fatal: if `beans prime` errors out we drop the cache
|
|
9
|
+
* and skip injection, but the rest of the extension keeps working.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { beansEvents, getRunner, type BeansEnv, type BeansRunner } from "./internal.js";
|
|
15
|
+
|
|
16
|
+
/** Latest `beans prime` output, or null when unavailable / failed / not yet run. */
|
|
17
|
+
let cachedPrime: string | null = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run `beans prime` and update the module-level cache.
|
|
21
|
+
*
|
|
22
|
+
* Returns the new cached value (or null on failure / when env is unavailable).
|
|
23
|
+
* Emits `prime_updated` on success so subscribers can react.
|
|
24
|
+
*/
|
|
25
|
+
export async function runPrime(
|
|
26
|
+
env: BeansEnv,
|
|
27
|
+
runner: BeansRunner | undefined,
|
|
28
|
+
): Promise<string | null> {
|
|
29
|
+
if (!env.available || !runner) {
|
|
30
|
+
cachedPrime = null;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const stdout = await runner.run(["prime"]);
|
|
35
|
+
const trimmed = stdout.trim();
|
|
36
|
+
cachedPrime = trimmed.length > 0 ? trimmed : null;
|
|
37
|
+
beansEvents.emit("prime_updated", cachedPrime);
|
|
38
|
+
return cachedPrime;
|
|
39
|
+
} catch {
|
|
40
|
+
// Non-fatal: surface via status line is Track A's responsibility.
|
|
41
|
+
cachedPrime = null;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the cached prime output (null when unavailable / failed). */
|
|
47
|
+
export function getCachedPrime(): string | null {
|
|
48
|
+
return cachedPrime;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wire prime injection into pi.
|
|
53
|
+
*
|
|
54
|
+
* - Refreshes the prime cache whenever env detection finishes.
|
|
55
|
+
* - Appends the cached prime to the system prompt on `before_agent_start`.
|
|
56
|
+
* - Clears the cache on `session_shutdown`.
|
|
57
|
+
*/
|
|
58
|
+
export function registerPrime(pi: ExtensionAPI): void {
|
|
59
|
+
beansEvents.on("env_ready", async (env: BeansEnv) => {
|
|
60
|
+
const runner = getRunner();
|
|
61
|
+
if (!env.available || !runner) {
|
|
62
|
+
cachedPrime = null;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await runPrime(env, runner);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
pi.on("before_agent_start", async (event) => {
|
|
69
|
+
const prime = getCachedPrime();
|
|
70
|
+
if (!prime) return undefined;
|
|
71
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${prime}` };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.on("session_shutdown", () => {
|
|
75
|
+
cachedPrime = null;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beans_create` — file a new bean (task, bug, etc.).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
6
|
+
import { defineTool } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { beansEvents } from "../internal.js";
|
|
9
|
+
import { buildToolResult, requireRunner, withTempBodyFile } from "./util.js";
|
|
10
|
+
|
|
11
|
+
export const beansCreateTool = defineTool({
|
|
12
|
+
name: "beans_create",
|
|
13
|
+
label: "beans create",
|
|
14
|
+
description:
|
|
15
|
+
"Create a new Beans issue in this project. Returns the CLI output, which includes the new bean id.",
|
|
16
|
+
promptSnippet: "Create a new Beans issue.",
|
|
17
|
+
promptGuidelines: [
|
|
18
|
+
"Use `beans_create` when the user asks you to track a task, when turning a spec into work items, or when you discover a bug worth filing. Do not create duplicate beans — call `beans_list` first if you are unsure.",
|
|
19
|
+
],
|
|
20
|
+
parameters: Type.Object({
|
|
21
|
+
title: Type.String({ description: "Short, human-readable title for the bean." }),
|
|
22
|
+
body: Type.Optional(
|
|
23
|
+
Type.String({ description: "Optional initial markdown body / description." }),
|
|
24
|
+
),
|
|
25
|
+
status: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description: "Optional initial status (defaults to the CLI's default, typically 'todo').",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
async execute(_id, params) {
|
|
32
|
+
const runner = requireRunner();
|
|
33
|
+
const baseArgs: string[] = ["create", "--title", params.title];
|
|
34
|
+
if (params.status) baseArgs.push("--status", params.status);
|
|
35
|
+
|
|
36
|
+
const out =
|
|
37
|
+
params.body && params.body.length > 0
|
|
38
|
+
? await withTempBodyFile(params.body, (path) =>
|
|
39
|
+
runner.run([...baseArgs, "--body-file", path]),
|
|
40
|
+
)
|
|
41
|
+
: await runner.run(baseArgs);
|
|
42
|
+
|
|
43
|
+
beansEvents.emit("beans_mutated");
|
|
44
|
+
return buildToolResult(out);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects and registers all pi-beans LLM-callable tools.
|
|
3
|
+
*
|
|
4
|
+
* Registration is eager: all four tools are visible to the LLM as soon as
|
|
5
|
+
* the extension loads. Each tool's `execute()` calls {@link requireRunner}
|
|
6
|
+
* up-front, so a call made before env detection completes — or in a project
|
|
7
|
+
* where beans is unavailable — fails fast with a clear error message rather
|
|
8
|
+
* than silently doing nothing.
|
|
9
|
+
*
|
|
10
|
+
* (pi's ExtensionAPI does not expose unregisterTool, so eager registration
|
|
11
|
+
* plus a runtime guard is the simplest correct behavior here.)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
import { beansCreateTool } from "./create.js";
|
|
17
|
+
import { beansListTool } from "./list.js";
|
|
18
|
+
import { beansShowTool } from "./show.js";
|
|
19
|
+
import { beansUpdateTool } from "./update.js";
|
|
20
|
+
|
|
21
|
+
export function registerTools(pi: ExtensionAPI): void {
|
|
22
|
+
pi.registerTool(beansListTool);
|
|
23
|
+
pi.registerTool(beansShowTool);
|
|
24
|
+
pi.registerTool(beansCreateTool);
|
|
25
|
+
pi.registerTool(beansUpdateTool);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { beansCreateTool, beansListTool, beansShowTool, beansUpdateTool };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beans_list` — discover open work in the current project.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
6
|
+
import { defineTool } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { buildToolResult, requireRunner } from "./util.js";
|
|
9
|
+
|
|
10
|
+
export const beansListTool = defineTool({
|
|
11
|
+
name: "beans_list",
|
|
12
|
+
label: "beans list",
|
|
13
|
+
description:
|
|
14
|
+
"List Beans issues in this project. By default only open (non-archived) beans are returned.",
|
|
15
|
+
promptSnippet: "List Beans issues (open by default; supports status/archived/query filters)",
|
|
16
|
+
promptGuidelines: [
|
|
17
|
+
"Use `beans_list` to discover open work in this project before proposing new changes from scratch.",
|
|
18
|
+
],
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
status: Type.Optional(
|
|
21
|
+
Type.String({ description: "Filter by status, e.g. 'todo', 'doing', 'done'." }),
|
|
22
|
+
),
|
|
23
|
+
include_archived: Type.Optional(
|
|
24
|
+
Type.Boolean({ description: "Include archived beans (default false)." }),
|
|
25
|
+
),
|
|
26
|
+
query: Type.Optional(
|
|
27
|
+
Type.String({ description: "Optional free-text filter forwarded to the CLI." }),
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
async execute(_id, params) {
|
|
31
|
+
const runner = requireRunner();
|
|
32
|
+
const args: string[] = ["list"];
|
|
33
|
+
if (params.status) args.push("--status", params.status);
|
|
34
|
+
if (params.include_archived) args.push("--archived");
|
|
35
|
+
if (params.query) args.push(params.query);
|
|
36
|
+
const out = await runner.run(args);
|
|
37
|
+
return buildToolResult(out);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beans_show` — read a single bean by id.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
6
|
+
import { defineTool } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { buildToolResult, requireRunner } from "./util.js";
|
|
9
|
+
|
|
10
|
+
export const beansShowTool = defineTool({
|
|
11
|
+
name: "beans_show",
|
|
12
|
+
label: "beans show",
|
|
13
|
+
description: "Read a single Beans issue by id, including its full markdown body and metadata.",
|
|
14
|
+
promptSnippet: "Read a single Beans issue by id.",
|
|
15
|
+
promptGuidelines: [
|
|
16
|
+
"Use `beans_show` to read the full description of a bean before starting work on it.",
|
|
17
|
+
],
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
id: Type.String({ description: "The bean id, e.g. 'myproj-1a2b'." }),
|
|
20
|
+
}),
|
|
21
|
+
async execute(_id, params) {
|
|
22
|
+
const runner = requireRunner();
|
|
23
|
+
const out = await runner.run(["show", params.id]);
|
|
24
|
+
return buildToolResult(out);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beans_update` — record progress on an existing bean.
|
|
3
|
+
*
|
|
4
|
+
* Mutations against `.beans/<id>.md` go through {@link withFileMutationQueue}
|
|
5
|
+
* so they interleave correctly with built-in `edit` / `write` calls (per the
|
|
6
|
+
* SPEC "File mutation discipline" requirement).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
12
|
+
import { defineTool, withFileMutationQueue } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
import { beansEvents, getEnv } from "../internal.js";
|
|
15
|
+
import type { BeansRunner } from "../internal.js";
|
|
16
|
+
import { buildToolResult, requireRunner, withTempBodyFile } from "./util.js";
|
|
17
|
+
|
|
18
|
+
/** Build the optional `--status` / `--title` flag list shared by every code path. */
|
|
19
|
+
function optStatusTitle(params: { status?: string; title?: string }): string[] {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
if (params.status) out.push("--status", params.status);
|
|
22
|
+
if (params.title) out.push("--title", params.title);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve the on-disk path of a bean's markdown file under the project root. */
|
|
27
|
+
function beanFilePath(id: string): string {
|
|
28
|
+
const env = getEnv();
|
|
29
|
+
return path.resolve(env.cwd, ".beans", `${id}.md`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Run `beans update` with a body file plus optional status/title flags. */
|
|
33
|
+
async function runUpdateWithBody(
|
|
34
|
+
runner: BeansRunner,
|
|
35
|
+
id: string,
|
|
36
|
+
body: string,
|
|
37
|
+
params: { status?: string; title?: string },
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
return withTempBodyFile(body, (p) =>
|
|
40
|
+
runner.run(["update", id, "--body-file", p, ...optStatusTitle(params)]),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const beansUpdateTool = defineTool({
|
|
45
|
+
name: "beans_update",
|
|
46
|
+
label: "beans update",
|
|
47
|
+
description:
|
|
48
|
+
"Update an existing Beans issue: change status/title, replace the body, or append to it.",
|
|
49
|
+
promptSnippet: "Update a Beans issue (status/title/body, or append to body).",
|
|
50
|
+
promptGuidelines: [
|
|
51
|
+
"Use `beans_update` to record progress: set status to `doing` when you start, to `done` when finished, and append notes with `body_append` instead of rewriting the body.",
|
|
52
|
+
],
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
id: Type.String({ description: "The bean id to update, e.g. 'myproj-1a2b'." }),
|
|
55
|
+
status: Type.Optional(
|
|
56
|
+
Type.String({ description: "New status (e.g. 'todo', 'doing', 'done')." }),
|
|
57
|
+
),
|
|
58
|
+
title: Type.Optional(Type.String({ description: "Replace the bean's title." })),
|
|
59
|
+
body: Type.Optional(
|
|
60
|
+
Type.String({
|
|
61
|
+
description:
|
|
62
|
+
"Replace the bean's full markdown body. Mutually exclusive with `body_append`.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
body_append: Type.Optional(
|
|
66
|
+
Type.String({
|
|
67
|
+
description: "Append text to the bean's existing body. Mutually exclusive with `body`.",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
}),
|
|
71
|
+
async execute(_id, params) {
|
|
72
|
+
if (params.body !== undefined && params.body_append !== undefined) {
|
|
73
|
+
throw new Error("`body` and `body_append` are mutually exclusive");
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
params.status === undefined &&
|
|
77
|
+
params.title === undefined &&
|
|
78
|
+
params.body === undefined &&
|
|
79
|
+
params.body_append === undefined
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("beans_update requires at least one of: status, title, body, body_append");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const runner = requireRunner();
|
|
85
|
+
let stdout: string;
|
|
86
|
+
|
|
87
|
+
if (params.body_append !== undefined) {
|
|
88
|
+
stdout = await withFileMutationQueue(beanFilePath(params.id), async () => {
|
|
89
|
+
// v1: the CLI has no body-only output, so we use the full `beans show`
|
|
90
|
+
// dump as the working body context. Appending is best-effort but
|
|
91
|
+
// preserves all existing content.
|
|
92
|
+
const current = await runner.run(["show", params.id]);
|
|
93
|
+
const newBody = `${current.trim()}\n\n${params.body_append}`;
|
|
94
|
+
return runUpdateWithBody(runner, params.id, newBody, params);
|
|
95
|
+
});
|
|
96
|
+
} else if (params.body !== undefined) {
|
|
97
|
+
stdout = await runUpdateWithBody(runner, params.id, params.body, params);
|
|
98
|
+
} else {
|
|
99
|
+
stdout = await runner.run(["update", params.id, ...optStatusTitle(params)]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beansEvents.emit("beans_mutated");
|
|
103
|
+
return buildToolResult(stdout);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the pi-beans tool implementations.
|
|
3
|
+
*
|
|
4
|
+
* These helpers exist so each tool file stays small and focused on its
|
|
5
|
+
* CLI-specific surface (argument shape, prompt copy). Anything that touches
|
|
6
|
+
* the filesystem, formats output, or performs cross-tool validation lives
|
|
7
|
+
* here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { truncateHead } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { getRunner } from "../internal.js";
|
|
18
|
+
import type { BeansRunner } from "../internal.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Write `text` to a unique temporary file, invoke `fn` with its absolute
|
|
22
|
+
* path, then best-effort delete the file. The promise from `fn` is awaited
|
|
23
|
+
* (and its result returned/forwarded) before cleanup runs.
|
|
24
|
+
*
|
|
25
|
+
* The temp file lives under {@link tmpdir} as `pi-beans-body-<rand>.md`.
|
|
26
|
+
* Cleanup errors are swallowed because they are not actionable for callers.
|
|
27
|
+
*/
|
|
28
|
+
export async function withTempBodyFile<T>(
|
|
29
|
+
text: string,
|
|
30
|
+
fn: (path: string) => Promise<T>,
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
const path = join(tmpdir(), `pi-beans-body-${randomBytes(8).toString("hex")}.md`);
|
|
33
|
+
await fs.writeFile(path, text, "utf8");
|
|
34
|
+
try {
|
|
35
|
+
return await fn(path);
|
|
36
|
+
} finally {
|
|
37
|
+
try {
|
|
38
|
+
await fs.unlink(path);
|
|
39
|
+
} catch {
|
|
40
|
+
// best-effort cleanup
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `truncateHead` with default 50KB / 2000-line limits and append a
|
|
47
|
+
* one-line notice when truncation occurred so the LLM knows output was
|
|
48
|
+
* dropped (per SPEC "Output > 50KB" handling).
|
|
49
|
+
*/
|
|
50
|
+
export function formatToolText(stdout: string): string {
|
|
51
|
+
const result = truncateHead(stdout);
|
|
52
|
+
if (!result.truncated) return result.content;
|
|
53
|
+
const reason =
|
|
54
|
+
result.truncatedBy === "lines" ? `${result.totalLines} lines` : `${result.totalBytes} bytes`;
|
|
55
|
+
return `${result.content}\n\n[pi-beans: output truncated; ${reason} total. Re-run a more specific query to see the rest.]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Standard tool result envelope: a single text block with truncated stdout.
|
|
60
|
+
*/
|
|
61
|
+
export interface BeansToolResult {
|
|
62
|
+
content: [{ type: "text"; text: string }];
|
|
63
|
+
details: undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildToolResult(stdout: string): BeansToolResult {
|
|
67
|
+
return { content: [{ type: "text", text: formatToolText(stdout) }], details: undefined };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the active {@link BeansRunner}, or throw a clear error so the LLM
|
|
72
|
+
* sees a useful message when env detection failed (e.g. missing CLI or
|
|
73
|
+
* `.beans.yml`). Tools call this at the top of `execute()`.
|
|
74
|
+
*/
|
|
75
|
+
export function requireRunner(): BeansRunner {
|
|
76
|
+
const runner = getRunner();
|
|
77
|
+
if (!runner) throw new Error("beans not configured for this project");
|
|
78
|
+
return runner;
|
|
79
|
+
}
|