Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ uses `SYSTEM.md`, not the external-agent skill. The extension is not a qrate Lua
not write archive cells: `qrate_stage_findings` only publishes drafts that the archivist may accept
from qrate's existing Problems panel and Fixes menu.

## Use it in your own Pi

Inside qrate you install nothing: the Agent panel loads this package for you. To use the same tools
in a Pi you installed yourself, you need qrate with its `qrate` command on `PATH` (check with
`qrate version`; the [command-line guide](https://qrate.dvnl.work/docs/cli) covers each platform).
Then load this package as a Pi extension and open your project in qrate. The tools call `qrate` from
`PATH` and show up in qrate's Agent panel under the name `pi`; set `QRATE_AGENT` to use another.

## Development

```sh
Expand All @@ -20,6 +28,7 @@ npm test

The runtime contract is qrate's `qrate agent` command. qrate starts Pi with `QRATE_CLI` (the
`qrate-cli` shipped beside the app) and `QRATE_AGENT=pi`; each tool runs
`$QRATE_CLI agent <command>` with its parameters as JSON on stdin. Exit `1` is a refusal with JSON
on stdout, and any other failure reports the CLI's stderr. A change to that command's contract
`$QRATE_CLI agent <command>` with its parameters as JSON on stdin. Without those variables the tools
run `qrate` from `PATH` as `pi`. Exit `1` is a refusal with JSON on stdout, and any other failure
reports the CLI's stderr. A change to that command's contract
needs a new extension release and a new pin in qrate's `scripts/fetch-agent-runtime.*`.
19 changes: 13 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,21 @@ function refusal(stdout: string): string {
return detail ? `${code}: ${detail}` : code;
}

/** Runs `$QRATE_CLI agent <method>` with the params on stdin. qrate sets QRATE_CLI and QRATE_AGENT. */
/**
* Runs `qrate agent <method>` with the params on stdin. qrate sets QRATE_CLI and QRATE_AGENT for
* its own Pi; a Pi you installed yourself finds `qrate` on PATH and is named `pi`.
*/
export async function callQrate(
method: string,
params: Record<string, unknown> | undefined,
signal?: AbortSignal,
): Promise<unknown> {
const cli = process.env.QRATE_CLI;
if (!cli) throw new QrateError("qrate did not provide its CLI path");

const child = spawn(cli, ["agent", method.replaceAll("_", "-")], { signal, windowsHide: true });
const cli = process.env.QRATE_CLI || "qrate";
const child = spawn(cli, ["agent", method.replaceAll("_", "-")], {
signal,
windowsHide: true,
env: { ...process.env, QRATE_AGENT: process.env.QRATE_AGENT || "pi" },
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
Expand All @@ -36,7 +41,9 @@ export async function callQrate(
child.stdin.end(JSON.stringify(params ?? {}));

const code = await new Promise<number | null>((resolve, reject) => {
child.on("error", (error) => reject(new QrateError(`qrate's CLI could not run: ${String(error)}`)));
child.on("error", (error) =>
reject(new QrateError(`could not run \`${cli}\`; install qrate and put its command on PATH: ${String(error)}`)),
);
child.on("close", resolve);
});
if (code === 1) throw new QrateError(`qrate refused the request: ${refusal(stdout)}`);
Expand Down
24 changes: 21 additions & 3 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,27 @@ describe("qrate CLI client", () => {
expect(JSON.parse(child.input)).toEqual({ revision: 3, findings: [] });
});

it("refuses to run without qrate's CLI path", async () => {
await expect(callQrate("overview", undefined)).rejects.toThrow(QrateError);
expect(spawn).not.toHaveBeenCalled();
it("finds qrate on PATH and names itself when qrate did not start it", async () => {
delete process.env.QRATE_AGENT;
spawn.mockReturnValue(fakeCli(0, "{}"));
await callQrate("overview", undefined);
expect(spawn.mock.calls[0]?.[0]).toBe("qrate");
expect(spawn.mock.calls[0]?.[2].env.QRATE_AGENT).toBe("pi");

process.env.QRATE_AGENT = "pi/review-2";
spawn.mockReturnValue(fakeCli(0, "{}"));
await callQrate("overview", undefined);
expect(spawn.mock.calls[1]?.[2].env.QRATE_AGENT).toBe("pi/review-2");
delete process.env.QRATE_AGENT;
});

it("says how to fix a command that is not installed", async () => {
const child = fakeCli(0, "");
spawn.mockReturnValue(child);
const call = callQrate("overview", undefined);
child.emit("error", new Error("spawn qrate ENOENT"));
await expect(call).rejects.toThrow(QrateError);
await expect(call).rejects.toThrow("put its command on PATH");
});

it("reports qrate's structured refusal code", async () => {
Expand Down