Let a coder session use the CLI it is part of

f5434ba2f82c · AtlantisPleb · · parent 7cd7a3fc6b5f

Let a coder session use the CLI it is part of

`openagents coder` is one subcommand of a CLI with eight others, and a session
knew about none of them. Asked to look at an issue it could only describe what
it would do, and a person had to explain the tool the session was running
inside.

Two halves, split by what can go stale.

The `openagents` tool runs the CLI. The entry is resolved beside this module
rather than through `PATH`, so what answers is the build this session is part of
and `--help` describes the CLI that is actually running. That makes the command
tree discoverable rather than described: the model asks, and no list here can
drift. Given a question about issue 21 it read the skill, then ran
`issue view 21 -R OpenAgentsInc/openagents --json` without being told the shape
of the command.

The `openagents-cli` skill carries what `--help` cannot: which commands work
with no credential, what the two scopes are for, why a token minted for one
alone fails a command later, and how to walk a person through a device login
including what to tell them they are approving. It deliberately lists no flags.
A written copy of them goes stale the first time one changes, and the help
output cannot.

The CLI now ships skills of its own, read after the repository's and the home
directory's, so replacing one is writing a skill of the same name and there is
nothing to uninstall. They are ordinary skills otherwise: they appear in the
catalog and `/skills` switches them off like any other.

Three commands are refused by name because the tool has no terminal to give
them. `coder` would be a session inside a session, and the refusal names
`delegate` instead. `auth login` waits for a browser, and the refusal names
`auth login --headless`, which returns a URL and a code and does not wait --
that form is allowed, because it is the one a model needs to help someone sign
in. `computer up` serves until stopped. Each says what to use instead rather
than timing out with nothing on screen.

Output is bounded and a failing command reports its exit code, because an empty
failure reads as an empty success. Writes are real and the skill says so: the
session is told to say what it is about to write before the first write, and
that reads need no ceremony.

346 tests pass, eleven new. The directory of packaged skills is a parameter for
the same reason the home directory is: a test should read what it made, not
what this machine happens to have.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/package.json
  • added packages/openagents-cli/skills/openagents-cli/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-skills.ts
  • modified packages/openagents-cli/src/coder-tools.ts
  • modified packages/openagents-cli/test/coder-skills.test.ts
  • added packages/openagents-cli/test/coder-tools-openagents.test.ts

Diff

9 files changed, +399 -30

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2433,
7
    "filesScanned": 2434,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:4850da44cf6c8cacbc5ec66d9405156d05c9008aed2fd97dbe17d9492041dec8",
4
  "sourceDigest": "sha256:148589c7a0eaf2212132813961369d469f43d46e01a7ed722b0b458abb9ca63c",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (33 tracked test files)"
1879
          "ref": "packages/openagents-cli (34 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/package.json modified +2 -1

@@ -27,7 +27,8 @@

27 27
  "files": [
28 28
    "dist",
29 29
    "contracts",
30
    "README.md"
30
    "README.md",
31
    "skills"
31 32
  ],
32 33
  "type": "module",
33 34
  "sideEffects": false,
packages/openagents-cli/skills/openagents-cli/SKILL.md added +90

@@ -0,0 +1,90 @@

1
---
2
name: openagents-cli
3
description: Use the OpenAgents CLI to read and write issues, projects, repositories, and the forum, to call any API route, and to sign a person in. Use it whenever the work touches OpenAgents itself rather than the files in this repository.
4
---
5
6
# The OpenAgents CLI
7
8
You are running inside `openagents coder`, which is one subcommand of the
9
`openagents` CLI. The `openagents` tool runs the rest of it. The same binary
10
answers, so what you see is this machine's build, not a remembered one.
11
12
## Find out what a command does by asking it
13
14
Every command documents itself. `openagents --help` lists the subcommands;
15
`openagents <command> --help` gives one command's flags and arguments;
16
`openagents <command> <subcommand> --help` goes a level deeper.
17
18
Ask before you guess. This file deliberately does not list flags: a written
19
copy of them goes stale the first time one changes, and the help output cannot.
20
21
Add `--json` to any command to get the response as JSON rather than as prose.
22
Prefer it when you are going to read a field out of the answer.
23
24
## What works with no credential
25
26
- `openagents computer probe|policy|status` — inspects this machine. The local
27
  machine controls all access; no account is involved.
28
- `openagents coder --offline` — answers from a built-in stand-in.
29
- `--help` anywhere.
30
31
Everything else reaches the API and needs a token. Without one you get:
32
33
```
34
openagents: No OpenAgents token is available for https://openagents.com.
35
Set OPENAGENTS_TOKEN.
36
```
37
38
Read that as "this person is not signed in", not as a broken command.
39
40
## What signing in involves
41
42
A token is per API origin, stored in the OS credential store, and carries
43
scopes. Two matter:
44
45
- `chat:account` — open a thread and talk to a model.
46
- `forge:write` — push to the forge and write to issues, projects, and the
47
  forum.
48
49
An ordinary sign-in mints both. Do not ask for one scope on its own: a token
50
minted for `chat:account` alone cannot push, and one minted for `forge:write`
51
alone cannot open a thread, and each failure arrives a command later where it
52
reads as the product being broken.
53
54
Check with `openagents auth status`, which names the account, the eligible
55
namespaces, and the expiry without printing the token.
56
57
### Signing a person in
58
59
You cannot complete this for them; it needs a browser and their approval.
60
61
1. Run `openagents auth login --headless`. It returns a URL and a short code
62
   and does not block.
63
2. Give the person the URL and the code, and say what they are approving: this
64
   CLI, on this machine, for their OpenAgents account.
65
3. When they say they have approved it, run `openagents auth login --resume`.
66
4. Confirm with `openagents auth status`.
67
68
Tell them what it is for in a sentence — "so the CLI can push to the forge and
69
open a thread" — rather than only handing over a link. A person asked to
70
approve something unexplained is right to refuse.
71
72
Never print a token, and never paste one into a file, a commit message, or an
73
issue.
74
75
## Reaching a route with no command
76
77
`openagents api <path>` sends an authenticated request to any API route and
78
writes the body as JSON. A path without a leading slash resolves under
79
`/api/v3/`. Use it when no named command covers what you need — several routes
80
have no command of their own.
81
82
## Two cautions
83
84
**You are already a coder session.** Do not start another one. To run work in
85
parallel, use the `delegate` tool, which is what it is for.
86
87
**Writes are real.** Closing an issue, posting to the forum, or pushing to a
88
repository is visible to other people immediately and is not yours to undo. Say
89
what you are about to write and get agreement before a first write in a
90
session. Reads need no ceremony.
packages/openagents-cli/src/cli.ts modified +2 -1

@@ -35,7 +35,7 @@ import {

35 35
  parseOllamaModelFlag,
36 36
} from "./coder-ollama.js";
37 37
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
38
import { delegateTool, skillTool } from "./coder-tools.js";
38
import { delegateTool, openagentsTool, skillTool } from "./coder-tools.js";
39 39
import { spawnSync } from "node:child_process";
40 40
41 41
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";

@@ -1771,6 +1771,7 @@ const coderCommand = Command.make(

1771 1771
        const active = skills.active();
1772 1772
        const tools = [
1773 1773
          ...(active.length === 0 ? [] : [skillTool(active)]),
1774
          openagentsTool(),
1774 1775
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
1775 1776
        ];
1776 1777
        source.useTools?.(tools);
packages/openagents-cli/src/coder-skills.ts modified +25 -5

@@ -21,13 +21,30 @@

21 21
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
22 22
import { homedir } from "node:os";
23 23
import { dirname, join } from "node:path";
24
import { fileURLToPath } from "node:url";
24 25
25
/** Where skills live, nearest first. A repository skill wins its name. */
26
const SKILL_DIRECTORIES = (cwd: string, home: string): ReadonlyArray<string> => [
26
/**
27
 * Where skills live, nearest first. The first to claim a name keeps it.
28
 *
29
 * The CLI ships its own last, so a repository or a person can replace one by
30
 * writing a skill of the same name and nothing has to be uninstalled. They are
31
 * ordinary skills otherwise: they appear in the catalog, and `/skills` switches
32
 * them off like any other.
33
 */
34
const SKILL_DIRECTORIES = (
35
  cwd: string,
36
  home: string,
37
  builtIn: string,
38
): ReadonlyArray<string> => [
27 39
  join(cwd, ".agents", "skills"),
28 40
  join(home, ".agents", "skills"),
41
  builtIn,
29 42
];
30 43
44
/** The skills packaged with this CLI, beside the compiled output. */
45
const builtInSkills = (): string =>
46
  join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
47
31 48
/** How much of one skill body is handed back. */
32 49
const BODY_LIMIT = 32_000;
33 50

@@ -103,12 +120,14 @@ export function discoverSkills(

103 120
  cwd: string = process.cwd(),
104 121
  // Taken rather than read so a test can point at a directory it made. A
105 122
  // function that always reads the real home directory can only be tested on a
106
  // machine that happens to have the right skills in it.
123
  // machine that happens to have the right skills in it, and the same goes for
124
  // the directory this package ships.
107 125
  home: string = homedir(),
126
  builtIn: string = builtInSkills(),
108 127
): ReadonlyArray<CoderSkill> {
109 128
  const found = new Map<string, CoderSkill>();
110 129
111
  for (const directory of SKILL_DIRECTORIES(cwd, home)) {
130
  for (const directory of SKILL_DIRECTORIES(cwd, home, builtIn)) {
112 131
    let entries: ReadonlyArray<string>;
113 132
    try {
114 133
      entries = readdirSync(directory);

@@ -226,8 +245,9 @@ const writeDisabled = (path: string, workspace: string, disabled: ReadonlySet<st

226 245
export function loadSkillSelection(
227 246
  cwd: string = process.cwd(),
228 247
  home: string = homedir(),
248
  builtIn?: string,
229 249
): SkillSelection {
230
  const all = discoverSkills(cwd, home);
250
  const all = discoverSkills(cwd, home, builtIn);
231 251
  const path = selectionPath(home);
232 252
  const disabled = new Set(readDisabled(path, cwd));
233 253
packages/openagents-cli/src/coder-tools.ts modified +167

@@ -14,6 +14,9 @@

14 14
 * reads, and a function that runs on this machine.
15 15
 */
16 16
17
import { existsSync } from "node:fs";
18
import { fileURLToPath } from "node:url";
19
17 20
import type { CoderDelegation } from "./coder-session.js";
18 21
import { catalogEntry, renderSkill, type CoderSkill } from "./coder-skills.js";
19 22
import { describePrompt, MAX_DELEGATE_COUNT } from "./coder-delegate.js";

@@ -218,3 +221,167 @@ export function skillTool(skills: ReadonlyArray<CoderSkill>): CoderTool {

218 221
    },
219 222
  };
220 223
}
224
225
/**
226
 * How much of one command's output the model is shown.
227
 *
228
 * Enough for a help page or a page of issues. A command that produces more than
229
 * this is one to narrow with a flag or `--json`, not one to read whole.
230
 */
231
const CLI_OUTPUT_LIMIT = 16_000;
232
233
/** How long a command may run before it is given up on. */
234
const CLI_TIMEOUT_MS = 120_000;
235
236
/**
237
 * Commands that wait for a terminal, and what to do instead.
238
 *
239
 * The tool has no terminal to give them: a session that started one would hang
240
 * with nothing on screen to say why. Refused by name, with the alternative,
241
 * rather than left to time out.
242
 */
243
const NEEDS_A_TERMINAL: ReadonlyArray<readonly [ReadonlyArray<string>, string]> = [
244
  [["coder"], "You are already a coder session. Use the `delegate` tool to run work in parallel."],
245
  [
246
    ["auth", "login"],
247
    "Run `auth login --headless` instead: it returns a URL and a code for the person to " +
248
      "approve, then `auth login --resume` completes it.",
249
  ],
250
  [["computer", "up"], "It serves until stopped, so it belongs in a terminal of its own."],
251
];
252
253
/**
254
 * The compiled entry to run, in whichever layout this module is loaded from.
255
 *
256
 * Not `PATH`, and not `process.argv[1]`: what answers has to be the build this
257
 * session is part of, or `--help` describes a different CLI than the one
258
 * running. `main.js` sits beside this module in a build and one directory over
259
 * in a source tree, and a test runs from the second.
260
 */
261
const cliEntry = (): string | undefined => {
262
  for (const candidate of ["main.js", "../dist/main.js"]) {
263
    const path = fileURLToPath(new URL(candidate, import.meta.url));
264
    if (existsSync(path)) return path;
265
  }
266
  return undefined;
267
};
268
269
const refusalFor = (args: ReadonlyArray<string>): string | undefined => {
270
  const words = args.filter((word) => !word.startsWith("-"));
271
  for (const [prefix, alternative] of NEEDS_A_TERMINAL) {
272
    const matches = prefix.every((word, at) => words[at] === word);
273
    // `auth login --headless` does not wait, so it is not the refused command.
274
    if (matches && !(prefix[0] === "auth" && args.includes("--headless"))) {
275
      return `\`openagents ${prefix.join(" ")}\` needs a terminal, which this tool has not got. ${alternative}`;
276
    }
277
  }
278
  return undefined;
279
};
280
281
/**
282
 * The openagents tool: run the CLI this session is part of.
283
 *
284
 * The same binary that is running answers, found through `process.argv` rather
285
 * than through `PATH`, so what the model reads is this machine's build and not
286
 * whichever copy happens to be installed. That also means `--help` is a live
287
 * answer: the model discovers the command tree by asking it, and no list here
288
 * can go stale.
289
 */
290
export function openagentsTool(): CoderTool {
291
  return {
292
    name: "openagents",
293
    description:
294
      "Run the OpenAgents CLI: issues, projects, repositories, the forum, authentication, and " +
295
      "any API route through `api`. Pass the arguments after `openagents` as a list, without " +
296
      "`openagents` itself. Discover what exists with `--help` on any command rather than " +
297
      "guessing at flags, and add `--json` when you are going to read a field out of the " +
298
      "answer. Reads are free; a write is visible to other people at once, so say what you are " +
299
      "about to write before the first one. Read the `openagents-cli` skill for the auth model " +
300
      "and what works with no credential.",
301
    parameters: {
302
      type: "object",
303
      properties: {
304
        args: {
305
          type: "array",
306
          items: { type: "string" },
307
          description:
308
            'The arguments after `openagents`, one per element. For example ["issue", "view", ' +
309
            '"21", "-R", "OpenAgentsInc/openagents", "--json"].',
310
        },
311
      },
312
      required: ["args"],
313
      additionalProperties: false,
314
    },
315
    run: async (rawArgs, signal) => {
316
      const args = Array.isArray(rawArgs["args"])
317
        ? rawArgs["args"].filter((word): word is string => typeof word === "string")
318
        : [];
319
      if (args.length === 0) {
320
        return "No command was run: `args` is required, such as [\"--help\"].";
321
      }
322
323
      const refusal = refusalFor(args);
324
      if (refusal !== undefined) return refusal;
325
326
      const { spawn } = await import("node:child_process");
327
      const entry = cliEntry();
328
      if (entry === undefined) return "This session cannot find the CLI it is running from.";
329
330
      return await new Promise<string>((resolve) => {
331
        const child = spawn(process.execPath, [entry, ...args], {
332
          // No terminal, so a command that would prompt gets end-of-file rather
333
          // than a wait nobody can see.
334
          stdio: ["ignore", "pipe", "pipe"],
335
        });
336
        let output = "";
337
        let done = false;
338
339
        const finish = (text: string) => {
340
          if (done) return;
341
          done = true;
342
          clearTimeout(timer);
343
          signal.removeEventListener("abort", onAbort);
344
          resolve(text);
345
        };
346
347
        const timer = setTimeout(() => {
348
          child.kill("SIGKILL");
349
          finish(`The command did not finish within ${String(CLI_TIMEOUT_MS / 1000)}s.\n\n${output}`);
350
        }, CLI_TIMEOUT_MS);
351
352
        const onAbort = () => {
353
          child.kill("SIGKILL");
354
          finish("The command was interrupted.");
355
        };
356
        signal.addEventListener("abort", onAbort, { once: true });
357
358
        const collect = (chunk: Buffer) => {
359
          if (output.length < CLI_OUTPUT_LIMIT) output += chunk.toString("utf8");
360
        };
361
        child.stdout.on("data", collect);
362
        child.stderr.on("data", collect);
363
364
        child.on("error", (cause) => {
365
          finish(`The command could not be started: ${cause.message}`);
366
        });
367
368
        child.on("close", (code) => {
369
          const bounded =
370
            output.length > CLI_OUTPUT_LIMIT
371
              ? `${output.slice(0, CLI_OUTPUT_LIMIT)}\n\n[truncated; narrow the command or use --json]`
372
              : output;
373
          const body = bounded.trim();
374
          // The exit code is reported on failure because it is what the CLI
375
          // says about itself, and an empty failure reads as an empty success.
376
          finish(
377
            code === 0
378
              ? body.length === 0
379
                ? "The command succeeded and printed nothing."
380
                : body
381
              : `The command exited with code ${String(code ?? -1)}.\n\n${body}`,
382
          );
383
        });
384
      });
385
    },
386
  };
387
}
packages/openagents-cli/test/coder-skills.test.ts modified +55 -20

@@ -20,6 +20,9 @@ const workspace = (skills: Record<string, string>): string => {

20 20
/** A home directory with no skills in it, so a test reads only its workspace. */
21 21
const EMPTY_HOME = mkdtempSync(join(tmpdir(), "coder-skills-home-"));
22 22
23
/** No packaged skills either, so a test reads only what it made. */
24
const NO_BUILT_INS = mkdtempSync(join(tmpdir(), "coder-skills-builtin-"));
25
23 26
const SKILL = `---
24 27
name: house-style
25 28
description: How this repository writes prose.

@@ -32,7 +35,7 @@ Use sentence case.

32 35
33 36
describe("discovering skills", () => {
34 37
  it("reads the name, the description, and the body without its front matter", () => {
35
    const [skill] = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME);
38
    const [skill] = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME, NO_BUILT_INS);
36 39
37 40
    expect(skill).toMatchObject({
38 41
      name: "house-style",

@@ -44,7 +47,7 @@ describe("discovering skills", () => {

44 47
  });
45 48
46 49
  it("takes the name from the front matter, not from the directory", () => {
47
    const [skill] = discoverSkills(workspace({ "some-folder": SKILL }), EMPTY_HOME);
50
    const [skill] = discoverSkills(workspace({ "some-folder": SKILL }), EMPTY_HOME, NO_BUILT_INS);
48 51
49 52
    expect(skill?.name).toBe("house-style");
50 53
  });

@@ -53,7 +56,7 @@ describe("discovering skills", () => {

53 56
    const root = workspace({ "house-style": SKILL });
54 57
    mkdirSync(join(root, ".agents", "skills", "empty"), { recursive: true });
55 58
56
    expect(discoverSkills(root, EMPTY_HOME).map((skill) => skill.name)).toEqual(["house-style"]);
59
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual(["house-style"]);
57 60
  });
58 61
59 62
  it("skips a skill missing a name or a description", () => {

@@ -64,7 +67,7 @@ describe("discovering skills", () => {

64 67
    });
65 68
66 69
    // One cannot be asked for and the other gives nothing to choose on.
67
    expect(discoverSkills(root, EMPTY_HOME).map((skill) => skill.name)).toEqual(["house-style"]);
70
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS).map((skill) => skill.name)).toEqual(["house-style"]);
68 71
  });
69 72
70 73

@@ -83,7 +86,7 @@ describe("discovering skills", () => {

83 86
    });
84 87
85 88
    // Taking `>-` as the value is how a skill came to describe itself as ">-".
86
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe(
89
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe(
87 90
      "Use when the reader wants one sentence spread over two lines.",
88 91
    );
89 92
  });

@@ -95,11 +98,11 @@ describe("discovering skills", () => {

95 98
      ),
96 99
    });
97 100
98
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe("One.\nTwo.");
101
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe("One.\nTwo.");
99 102
  });
100 103
101 104
  it("is empty for a repository with no skills directory", () => {
102
    expect(discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME)).toEqual([]);
105
    expect(discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME, NO_BUILT_INS)).toEqual([]);
103 106
  });
104 107
105 108
  it("strips the quotes from a quoted description", () => {

@@ -107,12 +110,12 @@ describe("discovering skills", () => {

107 110
      quoted: '---\nname: quoted\ndescription: "Quoted, with a comma."\n---\n\nBody.',
108 111
    });
109 112
110
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe("Quoted, with a comma.");
113
    expect(discoverSkills(root, EMPTY_HOME, NO_BUILT_INS)[0]?.description).toBe("Quoted, with a comma.");
111 114
  });
112 115
});
113 116
114 117
describe("the skill tool", () => {
115
  const skills = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME);
118
  const skills = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME, NO_BUILT_INS);
116 119
  const tool = skillTool(skills);
117 120
118 121
  it("carries the catalog in its description, so both lanes read it", () => {

@@ -156,14 +159,14 @@ describe("choosing which skills the model is offered", () => {

156 159
  };
157 160
158 161
  it("offers every skill until one is switched off", () => {
159
    const selection = loadSkillSelection(workspace(two), home());
162
    const selection = loadSkillSelection(workspace(two), home(), NO_BUILT_INS);
160 163
161 164
    expect(selection.active().map((skill) => skill.name)).toEqual(["alpha", "beta"]);
162 165
    expect(selection.isOn("alpha")).toBe(true);
163 166
  });
164 167
165 168
  it("drops a switched-off skill from what the model is offered", () => {
166
    const selection = loadSkillSelection(workspace(two), home());
169
    const selection = loadSkillSelection(workspace(two), home(), NO_BUILT_INS);
167 170
168 171
    selection.toggle("alpha");
169 172

@@ -177,19 +180,19 @@ describe("choosing which skills the model is offered", () => {

177 180
    const root = workspace(two);
178 181
    const where = home();
179 182
180
    loadSkillSelection(root, where).toggle("beta");
183
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
181 184
182
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual(["alpha"]);
185
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual(["alpha"]);
183 186
  });
184 187
185 188
  it("switches one back on", () => {
186 189
    const root = workspace(two);
187 190
    const where = home();
188 191
189
    loadSkillSelection(root, where).toggle("beta");
190
    loadSkillSelection(root, where).toggle("beta");
192
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
193
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("beta");
191 194
192
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual([
195
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
193 196
      "alpha",
194 197
      "beta",
195 198
    ]);

@@ -200,10 +203,10 @@ describe("choosing which skills the model is offered", () => {

200 203
    const one = workspace(two);
201 204
    const other = workspace(two);
202 205
203
    loadSkillSelection(one, where).toggle("alpha");
206
    loadSkillSelection(one, where, NO_BUILT_INS).toggle("alpha");
204 207
205 208
    // A skill switched off for one repository is not switched off everywhere.
206
    expect(loadSkillSelection(other, where).active().map((skill) => skill.name)).toEqual([
209
    expect(loadSkillSelection(other, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
207 210
      "alpha",
208 211
      "beta",
209 212
    ]);

@@ -212,7 +215,7 @@ describe("choosing which skills the model is offered", () => {

212 215
  it("offers a skill added after the choice was made", () => {
213 216
    const root = workspace(two);
214 217
    const where = home();
215
    loadSkillSelection(root, where).toggle("alpha");
218
    loadSkillSelection(root, where, NO_BUILT_INS).toggle("alpha");
216 219
217 220
    mkdirSync(join(root, ".agents", "skills", "gamma"), { recursive: true });
218 221
    writeFileSync(

@@ -221,9 +224,41 @@ describe("choosing which skills the model is offered", () => {

221 224
    );
222 225
223 226
    // Off is what is recorded, so something nobody has ruled on is on.
224
    expect(loadSkillSelection(root, where).active().map((skill) => skill.name)).toEqual([
227
    expect(loadSkillSelection(root, where, NO_BUILT_INS).active().map((skill) => skill.name)).toEqual([
225 228
      "beta",
226 229
      "gamma",
227 230
    ]);
228 231
  });
229 232
});
233
234
describe("the skills the CLI ships with", () => {
235
  it("offers its own, so a session knows the CLI it is part of", () => {
236
    const found = discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME);
237
238
    expect(found.map((skill) => skill.name)).toContain("openagents-cli");
239
  });
240
241
  it("lets a repository replace one by name, with nothing to uninstall", () => {
242
    const root = workspace({
243
      "openagents-cli": "---\nname: openagents-cli\ndescription: Ours.\n---\n\nOur version.",
244
    });
245
246
    const found = discoverSkills(root, EMPTY_HOME);
247
    const ours = found.find((skill) => skill.name === "openagents-cli");
248
249
    // Nearest wins, and the packaged one is furthest.
250
    expect(ours?.description).toBe("Ours.");
251
    expect(found.filter((skill) => skill.name === "openagents-cli")).toHaveLength(1);
252
  });
253
254
  it("says what works with no credential, which help output does not", () => {
255
    const found = discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME);
256
    const cli = found.find((skill) => skill.name === "openagents-cli");
257
258
    // The auth boundary is the part a model cannot discover by asking --help.
259
    expect(cli?.body).toContain("What works with no credential");
260
    expect(cli?.body).toContain("chat:account");
261
    expect(cli?.body).toContain("forge:write");
262
    expect(cli?.body).toContain("auth login --headless");
263
  });
264
});
packages/openagents-cli/test/coder-tools-openagents.test.ts added +55

@@ -0,0 +1,55 @@

1
import { describe, expect, it } from "vitest";
2
3
import { openagentsTool } from "../src/coder-tools.js";
4
5
const run = (args: ReadonlyArray<string>) =>
6
  openagentsTool().run({ args: [...args] }, new AbortController().signal);
7
8
describe("the openagents tool", () => {
9
  it("carries the catalog of what it is for, and points at --help", () => {
10
    const { description, parameters } = openagentsTool();
11
12
    expect(description).toContain("--help");
13
    expect(description).toContain("--json");
14
    expect(parameters["required"]).toEqual(["args"]);
15
  });
16
17
  it("runs the CLI this session is part of", async () => {
18
    // Resolved beside this module rather than from PATH, so what answers is
19
    // this build and not whichever copy happens to be installed.
20
    await expect(run(["--version"])).resolves.toContain("openagents v");
21
  });
22
23
  it("asks for arguments rather than running something arbitrary", async () => {
24
    await expect(run([])).resolves.toContain("`args` is required");
25
  });
26
27
  it("reports a failing command by its exit code, not as an empty answer", async () => {
28
    const output = await run(["issue", "view", "999999", "-R", "OpenAgentsInc/openagents"]);
29
30
    // An empty failure reads as an empty success.
31
    expect(output).toContain("exited with code");
32
  });
33
34
  it("refuses to start another coder session, and says what to use instead", async () => {
35
    const output = await run(["coder"]);
36
37
    expect(output).toContain("needs a terminal");
38
    expect(output).toContain("`delegate` tool");
39
  });
40
41
  it("refuses a login that would wait, and names the headless form", async () => {
42
    await expect(run(["auth", "login"])).resolves.toContain("auth login --headless");
43
  });
44
45
  it("allows the headless login, which is the one that does not wait", async () => {
46
    // The refusal is about waiting for a terminal, not about the command.
47
    const output = await run(["auth", "login", "--headless", "--help"]);
48
49
    expect(output).not.toContain("needs a terminal");
50
  });
51
52
  it("refuses a command that serves until it is stopped", async () => {
53
    await expect(run(["computer", "up"])).resolves.toContain("needs a terminal");
54
  });
55
});

This page updates live while a promote is in flight · changelog