@orlan-maker/cli 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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +89 -43
- package/bin/orlan +0 -0
- package/dist/agents.js +74 -88
- package/dist/commands.js +775 -212
- package/dist/http.js +31 -9
- package/dist/main.js +3 -4
- package/dist/skills.js +1 -1
- package/hooks/orlan-intro.txt +1 -0
- package/hooks/session-start +4 -1
- package/opencode/orlan.js +15 -3
- package/package.json +5 -14
- package/skills/orlan/SKILL.md +88 -0
- package/skills/orlan-review/SKILL.md +16 -7
- package/skills/orlan-versions/SKILL.md +9 -2
- package/.mcp.json +0 -9
- package/dist/mcp.js +0 -113
- package/monitors/monitors.json +0 -7
package/dist/commands.js
CHANGED
|
@@ -6,11 +6,10 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { agentIds, agentLabels, CLAUDE_PLUGIN, detectAgent, installClaudePlugin, orlanIntro, parseAgent, removeMcp, STOP_LOOP_LIMIT, sessionStartOutput, setHooks, setOpencodePlugin, skillsDir, uninstallClaudePlugin, } from "./agents.js";
|
|
10
10
|
import { openBrowser } from "./browser.js";
|
|
11
|
-
import { configDir,
|
|
11
|
+
import { configDir, readConfig, serverOrigin, writeConfig } from "./config.js";
|
|
12
12
|
import { AgentApi, agentTokenRefused, apiCall, OrlanError, unreachable } from "./http.js";
|
|
13
|
-
import { McpClient } from "./mcp.js";
|
|
14
13
|
import { account, deleteSecret, getSecret, setSecret } from "./secrets.js";
|
|
15
14
|
import { writeSkills } from "./skills.js";
|
|
16
15
|
const agentOption = { agent: { type: "string" } };
|
|
@@ -36,6 +35,15 @@ function namedAgent(values) {
|
|
|
36
35
|
* else the only connected agent.
|
|
37
36
|
*/
|
|
38
37
|
async function agentSecret(config, values) {
|
|
38
|
+
const agent = pickAgent(config, values);
|
|
39
|
+
const secret = await getSecret(agentAccount(config, agent));
|
|
40
|
+
if (!secret) {
|
|
41
|
+
throw new OrlanError(`The token of ${agent} is missing.`, undefined, `orlan connect --agent ${agent}`);
|
|
42
|
+
}
|
|
43
|
+
return secret;
|
|
44
|
+
}
|
|
45
|
+
/** The connected agent a daily command works as: see agentSecret. */
|
|
46
|
+
function pickAgent(config, values) {
|
|
39
47
|
const connected = Object.keys(config.agents);
|
|
40
48
|
let agent = typeof values.agent === "string" ? parseAgent(values.agent) : undefined;
|
|
41
49
|
if (!agent) {
|
|
@@ -44,19 +52,15 @@ async function agentSecret(config, values) {
|
|
|
44
52
|
}
|
|
45
53
|
if (!agent) {
|
|
46
54
|
throw connected.length === 0
|
|
47
|
-
? new OrlanError("No agent is connected.", undefined, "orlan
|
|
55
|
+
? new OrlanError("No agent is connected.", undefined, "orlan connect --agent <id>")
|
|
48
56
|
: new OrlanError(`Several agents are connected (${connected.join(", ")}). Add --agent <id>.`, undefined, `run the command again with --agent ${connected[0]}`);
|
|
49
57
|
}
|
|
50
58
|
if (!config.agents[agent]) {
|
|
51
|
-
throw new OrlanError(`${agentLabels[agent]} is not connected.`, undefined, `orlan
|
|
59
|
+
throw new OrlanError(`${agentLabels[agent]} is not connected.`, undefined, `orlan connect --agent ${agent}`);
|
|
52
60
|
}
|
|
53
|
-
|
|
54
|
-
if (!secret) {
|
|
55
|
-
throw new OrlanError(`The token of ${agent} is missing.`, undefined, `orlan mcp connect --agent ${agent}`);
|
|
56
|
-
}
|
|
57
|
-
return secret;
|
|
61
|
+
return agent;
|
|
58
62
|
}
|
|
59
|
-
/** Runs the work with the stateless agent API of the agent token (F038):
|
|
63
|
+
/** Runs the work with the stateless agent API of the agent token (F038): one plain HTTP request a call. */
|
|
60
64
|
async function withAgent(values, work) {
|
|
61
65
|
const config = await readConfig();
|
|
62
66
|
return work(new AgentApi(config.server, await agentSecret(config, values)), config);
|
|
@@ -77,6 +81,19 @@ function finish(io, values, data, lines, next) {
|
|
|
77
81
|
}
|
|
78
82
|
/** --topic as input: a topic id or a topic name. It also tells Orlan where to look up a name or #12. */
|
|
79
83
|
const topicInput = (values) => (typeof values.topic === "string" ? { topicId: values.topic } : {});
|
|
84
|
+
/**
|
|
85
|
+
* The topic of a command that needs one: --topic (an id or a name), else the topic the brief names (the
|
|
86
|
+
* one the agent used last, else its only topic).
|
|
87
|
+
*/
|
|
88
|
+
async function topicOrDefault(api, values) {
|
|
89
|
+
if (typeof values.topic === "string")
|
|
90
|
+
return values.topic;
|
|
91
|
+
const { topic } = await api.get("/api/agent/brief", {}, "the brief");
|
|
92
|
+
if (!topic) {
|
|
93
|
+
throw new OrlanError("The agent has several topics and used none yet. Add --topic <topic>.", "topic_missing");
|
|
94
|
+
}
|
|
95
|
+
return topic.id;
|
|
96
|
+
}
|
|
80
97
|
const WAIT_STEP = "orlan wait (the next request)";
|
|
81
98
|
/**
|
|
82
99
|
* A text people wrote, quoted on one line as `orlan wait` quotes it (B029): JSON quotes, with the line
|
|
@@ -106,6 +123,17 @@ function versionNumber(values, option) {
|
|
|
106
123
|
}
|
|
107
124
|
return number;
|
|
108
125
|
}
|
|
126
|
+
/** An option of numbers with commas: --at 300,200, --size 200,120, --region 0.1,0.2,0.3,0.1. */
|
|
127
|
+
function numbersOption(values, option, count) {
|
|
128
|
+
const raw = values[option];
|
|
129
|
+
if (typeof raw !== "string")
|
|
130
|
+
return undefined;
|
|
131
|
+
const parts = raw.split(",").map((part) => Number(part.trim()));
|
|
132
|
+
if (parts.length !== count || parts.some((part) => !Number.isFinite(part))) {
|
|
133
|
+
throw new OrlanError(`--${option} takes ${count} numbers with commas.`, undefined, `run the command again with --${option} ${count === 2 ? "300,200" : "0.1,0.2,0.3,0.1"}`);
|
|
134
|
+
}
|
|
135
|
+
return parts;
|
|
136
|
+
}
|
|
109
137
|
const baseOption = (values) => {
|
|
110
138
|
const number = versionNumber(values, "base");
|
|
111
139
|
return number === undefined ? {} : { baseVersion: number };
|
|
@@ -299,20 +327,19 @@ async function* sseData(body) {
|
|
|
299
327
|
}
|
|
300
328
|
}
|
|
301
329
|
}
|
|
302
|
-
/**
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
"As JSON, for an mcp.json file:",
|
|
310
|
-
JSON.stringify({ mcpServers: { [MCP_NAME]: { url, headers: { Authorization: `Bearer ${secret}` } } } }, null, 2),
|
|
311
|
-
];
|
|
312
|
-
}
|
|
313
|
-
/** Makes an agent token for the agent, keeps it, and writes the agent's MCP settings. */
|
|
314
|
-
async function connect(config, agent, values, io) {
|
|
330
|
+
/**
|
|
331
|
+
* Connects the agent to Orlan with the orlan command (D088): makes an agent token and keeps it, installs
|
|
332
|
+
* the skills and the agent's plugin or hooks, takes out the Orlan server entry that an older CLI wrote in
|
|
333
|
+
* the agent's settings, and tests the token with one plain HTTP call.
|
|
334
|
+
*/
|
|
335
|
+
async function connect(agent, values, io) {
|
|
336
|
+
const config = await readConfig();
|
|
315
337
|
const token = await cliToken(config);
|
|
338
|
+
// First, before a token or a file is made: an old entry goes also when the plan refuses a new agent, and
|
|
339
|
+
// a settings file the CLI cannot edit stops the connect before it changes anything.
|
|
340
|
+
if (agent !== "other" && (await removeMcp(agent))) {
|
|
341
|
+
io.out("Removed the old Orlan server entry from the agent's settings. The agent uses the orlan command.");
|
|
342
|
+
}
|
|
316
343
|
const topicIds = values.topic;
|
|
317
344
|
const made = await apiCall(config.server, "/api/cli/agents", {
|
|
318
345
|
token,
|
|
@@ -324,22 +351,19 @@ async function connect(config, agent, values, io) {
|
|
|
324
351
|
const role = made.token.role[0]?.toUpperCase() + made.token.role.slice(1);
|
|
325
352
|
io.out(`Made an agent token for ${agentLabels[agent]}: ${made.token.prefix}..., ${role} on ` +
|
|
326
353
|
`${made.token.topics.map((topic) => quote(topic.name)).join(", ")}. It is in the ${where === "keychain" ? "keychain" : "credentials file"}.`);
|
|
354
|
+
// The Claude Code plugin carries the skills: a second copy would show each skill twice.
|
|
355
|
+
let plugin = false;
|
|
327
356
|
if (agent === "claude-code" && !values["no-plugin"]) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
io.out(`Claude Code did not install the Orlan plugin (${CLAUDE_PLUGIN}). The MCP server works without it, ` +
|
|
341
|
-
"but no monitor wakes the session. Run `claude plugin list` to see why.");
|
|
342
|
-
}
|
|
357
|
+
plugin = await installClaudePlugin();
|
|
358
|
+
io.out(plugin
|
|
359
|
+
? "Installed the Orlan plugin in Claude Code: the orlan command, the hooks that show the session's state " +
|
|
360
|
+
"on the board, and the skills."
|
|
361
|
+
: `Claude Code did not install the Orlan plugin (${CLAUDE_PLUGIN}). The orlan command and the skills work ` +
|
|
362
|
+
"without it, but the board does not show the session's state. Run `claude plugin list` to see why.");
|
|
363
|
+
}
|
|
364
|
+
if (!plugin) {
|
|
365
|
+
await writeSkills(agent);
|
|
366
|
+
io.out(`Installed the Orlan skills orlan, orlan-review and orlan-versions in ${skillsDir(agent)}.`);
|
|
343
367
|
}
|
|
344
368
|
if (agent === "codex" || agent === "cursor") {
|
|
345
369
|
io.out(`Added the Orlan hooks to ${await setHooks(agent, true)}: at the session start the brief goes into the ` +
|
|
@@ -357,10 +381,62 @@ async function connect(config, agent, values, io) {
|
|
|
357
381
|
io.out("A request wakes a session only while OpenCode runs (the TUI or opencode serve). Restart a running " +
|
|
358
382
|
"OpenCode to load the plugin.");
|
|
359
383
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
384
|
+
const { topics } = await new AgentApi(config.server, made.secret).call("inbox", {
|
|
385
|
+
brief: true,
|
|
386
|
+
});
|
|
387
|
+
io.out(`Test: ok. The agent can reach ${topics.map((topic) => quote(topic.name)).join(", ")}.`);
|
|
388
|
+
io.out(agent === "other"
|
|
389
|
+
? "next_step: tell your agent to read the orlan skill in .agents/skills, then run orlan brief"
|
|
390
|
+
: `next_step: start a new ${agentLabels[agent]} session, then run orlan brief`);
|
|
391
|
+
return 0;
|
|
363
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Revokes the agent's token, and takes out what a connect installed: the plugin or the hooks, and an
|
|
395
|
+
* Orlan server entry that an older CLI wrote. The skills stay.
|
|
396
|
+
*/
|
|
397
|
+
async function disconnect(agent, io) {
|
|
398
|
+
const config = await readConfig();
|
|
399
|
+
const connected = config.agents[agent];
|
|
400
|
+
if (connected) {
|
|
401
|
+
await apiCall(config.server, `/api/cli/agents/${connected.tokenId}`, {
|
|
402
|
+
token: await cliToken(config),
|
|
403
|
+
method: "DELETE",
|
|
404
|
+
}).catch((error) => {
|
|
405
|
+
if (!(error instanceof OrlanError) || error.code !== "not_found")
|
|
406
|
+
throw error;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const removed = agent !== "other" && (await removeMcp(agent));
|
|
410
|
+
if (agent === "claude-code")
|
|
411
|
+
await uninstallClaudePlugin();
|
|
412
|
+
if (agent === "codex" || agent === "cursor")
|
|
413
|
+
await setHooks(agent, false);
|
|
414
|
+
if (agent === "opencode")
|
|
415
|
+
await setOpencodePlugin(false);
|
|
416
|
+
await deleteSecret(agentAccount(config, agent));
|
|
417
|
+
delete config.agents[agent];
|
|
418
|
+
await writeConfig(config);
|
|
419
|
+
const parts = { "claude-code": "plugin", codex: "hooks", cursor: "hooks", opencode: "plugin", other: "" }[agent];
|
|
420
|
+
io.out(`Disconnected ${agentLabels[agent]}: its token is revoked` +
|
|
421
|
+
`${parts ? `, and its Orlan ${parts} ${removed ? "and the old Orlan server entry are" : "is"} gone` : ""}. The skills stay.`);
|
|
422
|
+
io.out(`next_step: orlan connect --agent ${agent} (to connect it again)`);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
/** The agent that connect and disconnect are for: --agent, else the agent that runs the command. */
|
|
426
|
+
function agentToConnect(values) {
|
|
427
|
+
const agent = namedAgent(values);
|
|
428
|
+
if (!agent)
|
|
429
|
+
throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
|
|
430
|
+
return agent;
|
|
431
|
+
}
|
|
432
|
+
/** The notice of an old command name, on standard error: the output stays what the new name prints. */
|
|
433
|
+
const oldName = (text) => process.stderr.write(`orlan: ${text}\n`);
|
|
434
|
+
const CONNECT_OPTIONS = {
|
|
435
|
+
...agentOption,
|
|
436
|
+
topic: { type: "string", multiple: true },
|
|
437
|
+
role: { type: "string" },
|
|
438
|
+
"no-plugin": { type: "boolean" },
|
|
439
|
+
};
|
|
364
440
|
// ---------- the commands ----------
|
|
365
441
|
export const commands = {
|
|
366
442
|
"auth login": {
|
|
@@ -402,7 +478,7 @@ export const commands = {
|
|
|
402
478
|
await writeConfig(next);
|
|
403
479
|
io.out(`Signed in to ${server} as ${quote(signed.user.name)} (${signed.user.email ?? "guest account"}), organisation ${quote(signed.org.name)}.`);
|
|
404
480
|
io.out(where === "keychain" ? "The CLI token is in the OS keychain." : "The CLI token is in the credentials file.");
|
|
405
|
-
io.out("next_step: orlan
|
|
481
|
+
io.out("next_step: orlan connect --agent <id>");
|
|
406
482
|
return 0;
|
|
407
483
|
}
|
|
408
484
|
catch (error) {
|
|
@@ -418,7 +494,7 @@ export const commands = {
|
|
|
418
494
|
usage: "orlan auth logout",
|
|
419
495
|
summary: "Revoke the CLI token and forget it.",
|
|
420
496
|
details: [
|
|
421
|
-
"The agents you connected keep their own tokens. Revoke them with `orlan
|
|
497
|
+
"The agents you connected keep their own tokens. Revoke them with `orlan disconnect`,",
|
|
422
498
|
"or in Agents and tokens in the web app.",
|
|
423
499
|
],
|
|
424
500
|
options: {},
|
|
@@ -442,9 +518,9 @@ export const commands = {
|
|
|
442
518
|
},
|
|
443
519
|
status: {
|
|
444
520
|
usage: "orlan status [--json]",
|
|
445
|
-
summary: "Show who you are, the server, and the connected agents, and test each agent's
|
|
521
|
+
summary: "Show who you are, the server, and the connected agents, and test each agent's connection.",
|
|
446
522
|
details: [
|
|
447
|
-
"Each connected agent makes one
|
|
523
|
+
"Each connected agent makes one plain HTTP call (the inbox brief) with its own token, so a failed",
|
|
448
524
|
"connection shows here with its reason. Ends with exit code 1 when a check fails.",
|
|
449
525
|
"",
|
|
450
526
|
JSON_HELP,
|
|
@@ -466,19 +542,16 @@ export const commands = {
|
|
|
466
542
|
const secret = await getSecret(agentAccount(config, agent));
|
|
467
543
|
let check;
|
|
468
544
|
if (!live || !secret) {
|
|
469
|
-
check = { ok: false, error: "its token was revoked or replaced. Run `orlan
|
|
545
|
+
check = { ok: false, error: "its token was revoked or replaced. Run `orlan connect` again." };
|
|
470
546
|
}
|
|
471
547
|
else {
|
|
472
|
-
const client = new McpClient(config.server, secret, io.version);
|
|
473
548
|
try {
|
|
474
|
-
|
|
549
|
+
const api = new AgentApi(config.server, secret);
|
|
550
|
+
check = { ok: true, topics: (await api.call("inbox", { brief: true })).topics };
|
|
475
551
|
}
|
|
476
552
|
catch (error) {
|
|
477
553
|
check = { ok: false, error: error.message };
|
|
478
554
|
}
|
|
479
|
-
finally {
|
|
480
|
-
await client.close();
|
|
481
|
-
}
|
|
482
555
|
}
|
|
483
556
|
if (!check.ok)
|
|
484
557
|
failed = true;
|
|
@@ -486,9 +559,9 @@ export const commands = {
|
|
|
486
559
|
}
|
|
487
560
|
const elsewhere = status.agents.filter((each) => !Object.values(config.agents).some((local) => local.tokenId === each.id));
|
|
488
561
|
const next = agents.length === 0
|
|
489
|
-
? "orlan
|
|
562
|
+
? "orlan connect --agent <id>"
|
|
490
563
|
: failed
|
|
491
|
-
? "orlan
|
|
564
|
+
? "orlan connect --agent <id> for each failed agent"
|
|
492
565
|
: "orlan brief (where the work stands)";
|
|
493
566
|
if (values.json) {
|
|
494
567
|
printJson(io, {
|
|
@@ -510,12 +583,12 @@ export const commands = {
|
|
|
510
583
|
for (const { agent, token: local, role, check } of agents) {
|
|
511
584
|
io.out(`${agentLabels[agent]} (${agent}): token ${local?.prefix}..., ${role ?? "revoked"}`);
|
|
512
585
|
if (check.ok) {
|
|
513
|
-
io.out("
|
|
586
|
+
io.out(" Connection: ok. Topics it can reach:");
|
|
514
587
|
for (const topic of check.topics)
|
|
515
588
|
io.out(` - ${quote(topic.name)} (${topic.id})`);
|
|
516
589
|
}
|
|
517
590
|
else {
|
|
518
|
-
io.out(`
|
|
591
|
+
io.out(` Connection: failed - ${check.error}`);
|
|
519
592
|
}
|
|
520
593
|
}
|
|
521
594
|
for (const each of elsewhere)
|
|
@@ -528,8 +601,9 @@ export const commands = {
|
|
|
528
601
|
usage: "orlan skills add [--agent <id>]",
|
|
529
602
|
summary: "Install the Orlan skills where the agent reads skills.",
|
|
530
603
|
details: [
|
|
531
|
-
"Writes the skills orlan-review and orlan-versions. Without --agent, the CLI finds the agent that",
|
|
532
|
-
"runs it; when it finds none, it writes them in this project, in .agents/skills.",
|
|
604
|
+
"Writes the skills orlan, orlan-review and orlan-versions. Without --agent, the CLI finds the agent that",
|
|
605
|
+
"runs it; when it finds none, it writes them in this project, in .agents/skills. `orlan connect` does",
|
|
606
|
+
"this too.",
|
|
533
607
|
"",
|
|
534
608
|
` claude-code ${skillsDir("claude-code")}`,
|
|
535
609
|
` codex ${skillsDir("codex")}`,
|
|
@@ -547,24 +621,22 @@ export const commands = {
|
|
|
547
621
|
io.out(`Installed the Orlan skills for ${agentLabels[agent]}:`);
|
|
548
622
|
for (const folder of written)
|
|
549
623
|
io.out(` ${folder}`);
|
|
550
|
-
io.out(`next_step: orlan
|
|
624
|
+
io.out(`next_step: orlan connect --agent ${agent === "other" ? "<id>" : agent}`);
|
|
551
625
|
return 0;
|
|
552
626
|
},
|
|
553
627
|
},
|
|
554
|
-
|
|
555
|
-
usage: "orlan
|
|
556
|
-
summary: "
|
|
628
|
+
connect: {
|
|
629
|
+
usage: "orlan connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
|
|
630
|
+
summary: "Connect an agent to Orlan: an agent token, the skills, and the plugin or the hooks.",
|
|
557
631
|
details: [
|
|
558
|
-
"Makes an agent token with your CLI token
|
|
559
|
-
"
|
|
560
|
-
"
|
|
561
|
-
"
|
|
632
|
+
"Makes an agent token with your CLI token and keeps it in the keychain. The agent then works on",
|
|
633
|
+
"Orlan with the orlan command. It installs the skills (as `orlan skills add` does), and removes the",
|
|
634
|
+
"Orlan server entry that an older CLI wrote. Every other entry in the agent's settings stays. A second",
|
|
635
|
+
"connect for the same agent replaces its token. Then it makes one plain HTTP call to test the token.",
|
|
562
636
|
"Only an admin of the organisation connects agents.",
|
|
563
637
|
"",
|
|
564
638
|
`For Claude Code it installs the Orlan plugin (${CLAUDE_PLUGIN}) from this package: the orlan command,`,
|
|
565
|
-
"
|
|
566
|
-
"show the session's state on the board, the skills, and the MCP server. Then the MCP entry is not",
|
|
567
|
-
"needed. On a server other than https://orlan.app, the entry stays until you set ORLAN_SERVER.",
|
|
639
|
+
"the hooks that show the session's state on the board, and the skills.",
|
|
568
640
|
"",
|
|
569
641
|
"For Codex ($CODEX_HOME/hooks.json) and Cursor (~/.cursor/hooks.json) it writes the Orlan hooks: the",
|
|
570
642
|
"session start gets the brief (`orlan hook start`), a turn that ends gets a waiting request",
|
|
@@ -575,125 +647,61 @@ export const commands = {
|
|
|
575
647
|
"turn ends, it runs `orlan hook wait` and sends the request to the session as a new prompt, so the",
|
|
576
648
|
"session wakes while OpenCode runs. The board shows the session's state.",
|
|
577
649
|
"",
|
|
650
|
+
"For other it writes the skills in this project, in .agents/skills.",
|
|
651
|
+
"",
|
|
578
652
|
AGENT_HELP,
|
|
579
653
|
"--topic <id> A topic the agent can use. Repeat it for more. Default: the topics you chose at sign-in.",
|
|
580
654
|
"--role <role> editor, commenter or viewer. Default: the role you chose at sign-in.",
|
|
581
|
-
"--no-plugin Claude Code:
|
|
655
|
+
"--no-plugin Claude Code: do not install the plugin. Write the skills to ~/.claude/skills.",
|
|
582
656
|
],
|
|
583
|
-
options:
|
|
584
|
-
...agentOption,
|
|
585
|
-
topic: { type: "string", multiple: true },
|
|
586
|
-
role: { type: "string" },
|
|
587
|
-
"no-plugin": { type: "boolean" },
|
|
588
|
-
},
|
|
657
|
+
options: CONNECT_OPTIONS,
|
|
589
658
|
positionals: [0, 0],
|
|
590
|
-
run:
|
|
591
|
-
const agent = namedAgent(values);
|
|
592
|
-
if (!agent)
|
|
593
|
-
throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
|
|
594
|
-
const config = await readConfig();
|
|
595
|
-
const secret = await connect(config, agent, values, io);
|
|
596
|
-
const client = new McpClient(config.server, secret, io.version);
|
|
597
|
-
try {
|
|
598
|
-
const { topics } = await client.call("inbox", { brief: true });
|
|
599
|
-
io.out(`MCP test: ok. The agent can reach ${topics.map((topic) => quote(topic.name)).join(", ")}.`);
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
await client.close();
|
|
603
|
-
}
|
|
604
|
-
if (agent === "other") {
|
|
605
|
-
for (const line of mcpSettings(config.server, secret))
|
|
606
|
-
io.out(line);
|
|
607
|
-
io.out("next_step: put the address and the header in your agent's MCP settings, then run orlan brief");
|
|
608
|
-
}
|
|
609
|
-
else {
|
|
610
|
-
io.out(`next_step: start a new ${agentLabels[agent]} session to load the Orlan MCP tools, then run orlan brief`);
|
|
611
|
-
}
|
|
612
|
-
return 0;
|
|
613
|
-
},
|
|
659
|
+
run: (values, _positionals, io) => connect(agentToConnect(values), values, io),
|
|
614
660
|
},
|
|
615
|
-
|
|
616
|
-
usage: "orlan
|
|
617
|
-
summary: "
|
|
618
|
-
details: [
|
|
619
|
-
"Uses the token of the agent (default: other). When that agent is not connected yet, it",
|
|
620
|
-
"connects it first. Put the address and the header in your agent's MCP settings.",
|
|
621
|
-
"The token is a secret: keep it out of chats, files you share, and repositories.",
|
|
622
|
-
"",
|
|
623
|
-
AGENT_HELP,
|
|
624
|
-
],
|
|
661
|
+
disconnect: {
|
|
662
|
+
usage: "orlan disconnect [--agent <id>]",
|
|
663
|
+
summary: "Revoke the agent's token, and take out its Orlan plugin or hooks and any old Orlan server entry.",
|
|
664
|
+
details: ["The skills stay. `orlan connect` connects the agent again.", "", AGENT_HELP],
|
|
625
665
|
options: agentOption,
|
|
626
666
|
positionals: [0, 0],
|
|
627
|
-
run:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
667
|
+
run: (values, _positionals, io) => disconnect(agentToConnect(values), io),
|
|
668
|
+
},
|
|
669
|
+
"mcp connect": {
|
|
670
|
+
usage: "orlan mcp connect [--agent <id>] [--topic <topic id>]... [--role editor|commenter|viewer] [--no-plugin]",
|
|
671
|
+
summary: "The old name of orlan connect. Use orlan connect: this name goes away in the next release.",
|
|
672
|
+
details: ["It does what `orlan connect` does. Orlan has no MCP entry for the agent now (D088)."],
|
|
673
|
+
options: CONNECT_OPTIONS,
|
|
674
|
+
positionals: [0, 0],
|
|
675
|
+
run: (values, _positionals, io) => {
|
|
676
|
+
oldName("Use orlan connect: orlan mcp connect goes away in the next release.");
|
|
677
|
+
return connect(agentToConnect(values), values, io);
|
|
637
678
|
},
|
|
638
679
|
},
|
|
639
|
-
"mcp
|
|
640
|
-
usage: "orlan mcp
|
|
641
|
-
summary: "
|
|
680
|
+
"mcp print": {
|
|
681
|
+
usage: "orlan mcp print [--agent <id>]",
|
|
682
|
+
summary: "The old way to connect another agent. Use orlan connect: this name goes away in the next release.",
|
|
642
683
|
details: [
|
|
643
|
-
|
|
644
|
-
"
|
|
645
|
-
"address (CLAUDE_CODE_MCP_SERVER_URL) and it is not the server you signed in to, it prints nothing",
|
|
646
|
-
"and exits with code 1.",
|
|
684
|
+
"Orlan has no MCP settings to print now (D088). It does what `orlan connect` does, for the agent",
|
|
685
|
+
"--agent names, else other: the token, and the skills in this project, in .agents/skills.",
|
|
647
686
|
"",
|
|
648
|
-
|
|
687
|
+
AGENT_HELP,
|
|
649
688
|
],
|
|
650
689
|
options: agentOption,
|
|
651
690
|
positionals: [0, 0],
|
|
652
|
-
run:
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (asked && serverOrigin(asked) !== config.server) {
|
|
656
|
-
throw new OrlanError(`The MCP server is ${serverOrigin(asked)}, but you signed in to ${config.server}. ` +
|
|
657
|
-
`Set ORLAN_SERVER=${config.server} where you start Claude Code.`);
|
|
658
|
-
}
|
|
659
|
-
io.out(JSON.stringify({ Authorization: `Bearer ${await agentSecret(config, values)}` }));
|
|
660
|
-
return 0;
|
|
691
|
+
run: (values, _positionals, io) => {
|
|
692
|
+
oldName("Use orlan connect: orlan mcp print goes away in the next release.");
|
|
693
|
+
return connect(typeof values.agent === "string" ? parseAgent(values.agent) : "other", values, io);
|
|
661
694
|
},
|
|
662
695
|
},
|
|
663
696
|
"mcp disconnect": {
|
|
664
697
|
usage: "orlan mcp disconnect [--agent <id>]",
|
|
665
|
-
summary: "
|
|
666
|
-
details: [AGENT_HELP],
|
|
698
|
+
summary: "The old name of orlan disconnect. Use orlan disconnect: this name goes away in the next release.",
|
|
699
|
+
details: ["It does what `orlan disconnect` does.", "", AGENT_HELP],
|
|
667
700
|
options: agentOption,
|
|
668
701
|
positionals: [0, 0],
|
|
669
|
-
run:
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
throw new OrlanError(`Add --agent <id>: ${agentIds.join(", ")}.`);
|
|
673
|
-
const config = await readConfig();
|
|
674
|
-
const connected = config.agents[agent];
|
|
675
|
-
if (connected) {
|
|
676
|
-
await apiCall(config.server, `/api/cli/agents/${connected.tokenId}`, {
|
|
677
|
-
token: await cliToken(config),
|
|
678
|
-
method: "DELETE",
|
|
679
|
-
}).catch((error) => {
|
|
680
|
-
if (!(error instanceof OrlanError) || error.code !== "not_found")
|
|
681
|
-
throw error;
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
if (agent !== "other")
|
|
685
|
-
await removeMcp(agent);
|
|
686
|
-
if (agent === "codex" || agent === "cursor")
|
|
687
|
-
await setHooks(agent, false);
|
|
688
|
-
if (agent === "opencode")
|
|
689
|
-
await setOpencodePlugin(false);
|
|
690
|
-
await deleteSecret(agentAccount(config, agent));
|
|
691
|
-
delete config.agents[agent];
|
|
692
|
-
await writeConfig(config);
|
|
693
|
-
io.out(`Disconnected ${agentLabels[agent]}: its token is revoked and its Orlan MCP entry` +
|
|
694
|
-
`${agent === "codex" || agent === "cursor" ? " and hooks are" : agent === "opencode" ? " and plugin are" : " is"} gone.`);
|
|
695
|
-
io.out(`next_step: orlan mcp connect --agent ${agent} (to connect it again)`);
|
|
696
|
-
return 0;
|
|
702
|
+
run: (values, _positionals, io) => {
|
|
703
|
+
oldName("Use orlan disconnect: orlan mcp disconnect goes away in the next release.");
|
|
704
|
+
return disconnect(agentToConnect(values), io);
|
|
697
705
|
},
|
|
698
706
|
},
|
|
699
707
|
"topics list": {
|
|
@@ -716,38 +724,59 @@ export const commands = {
|
|
|
716
724
|
}),
|
|
717
725
|
},
|
|
718
726
|
"files list": {
|
|
719
|
-
usage: "orlan files list --topic <topic> [--agent <id>] [--json]",
|
|
727
|
+
usage: "orlan files list [--topic <topic>] [--agent <id>] [--json]",
|
|
720
728
|
summary: "List the files of a topic with their current versions.",
|
|
721
|
-
details: [
|
|
729
|
+
details: [
|
|
730
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
731
|
+
AGENT_TOKEN_HELP,
|
|
732
|
+
JSON_HELP,
|
|
733
|
+
],
|
|
722
734
|
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
723
735
|
positionals: [0, 0],
|
|
724
736
|
run: (values, _positionals, io) => withAgent(values, async (api) => {
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
728
|
-
const { files } = await api.call("read_board", { ...topicInput(values), view: "files" });
|
|
737
|
+
const topicId = await topicOrDefault(api, values);
|
|
738
|
+
const { files } = await api.call("read_board", { topicId, view: "files" });
|
|
729
739
|
return finish(io, values, { files }, files.length === 0
|
|
730
740
|
? ["No files."]
|
|
731
741
|
: files.map((file) => `${file.id} ${quote(file.name)} ${file.kind} current v${file.current.number} (${file.current.status}), ` +
|
|
732
|
-
`${file.versions} version${file.versions === 1 ? "" : "s"}`
|
|
742
|
+
`${file.versions} version${file.versions === 1 ? "" : "s"}` +
|
|
743
|
+
(file.current.changed
|
|
744
|
+
? `, ${changedText(file.current.changed.pages)} from v${file.current.changed.from}`
|
|
745
|
+
: "")), files.length === 0
|
|
733
746
|
? "orlan files add <path> (a new file)"
|
|
734
747
|
: "orlan files pull <file> --edit --out <path> (the file id or its name)");
|
|
735
748
|
}),
|
|
736
749
|
},
|
|
737
750
|
"files add": {
|
|
738
|
-
usage: "orlan files add <path> [--topic <topic>] [--agent <id>] [--json]",
|
|
751
|
+
usage: "orlan files add <path> [--at <x,y>] [--topic <topic>] [--agent <id>] [--json]",
|
|
739
752
|
summary: "Post a local file as a new file on a topic, and put it on the board.",
|
|
740
753
|
details: [
|
|
741
754
|
"One request sends the file and places it on the board, right of the other items. The server",
|
|
742
755
|
"renders its pages. A PowerPoint, PDF, PNG, JPG or HTML file, with the right extension.",
|
|
743
756
|
"",
|
|
757
|
+
"--at <x,y> Put it at this point of the board, in board coordinates, or at the nearest free",
|
|
758
|
+
" place. `orlan board` shows where the items are.",
|
|
744
759
|
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
745
760
|
AGENT_TOKEN_HELP,
|
|
746
761
|
JSON_HELP,
|
|
747
762
|
],
|
|
748
|
-
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
763
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" }, at: { type: "string" } },
|
|
749
764
|
positionals: [1, 1],
|
|
750
765
|
run: (values, [filePath], io) => withAgent(values, async (api) => {
|
|
766
|
+
const at = numbersOption(values, "at", 2);
|
|
767
|
+
if (at) {
|
|
768
|
+
// A place on the board: the file goes up first, then the post places it.
|
|
769
|
+
const topicId = await topicOrDefault(api, values);
|
|
770
|
+
const uploadId = await api.upload(await readFile(filePath), { topic: topicId });
|
|
771
|
+
const posted = await api.call("post", { topicId, name: path.basename(filePath), uploadId, position: { x: at[0], y: at[1] } });
|
|
772
|
+
return finish(io, values, {
|
|
773
|
+
file: { id: posted.file.id, name: posted.file.name, version: posted.posted.number },
|
|
774
|
+
boardItemId: posted.boardItemId,
|
|
775
|
+
}, [
|
|
776
|
+
`Added ${quote(posted.file.name)} as file ${posted.file.id}, v${posted.posted.number}. It is on the ` +
|
|
777
|
+
`board at ${at.join(",")}, or at the nearest free place; its pages render now.`,
|
|
778
|
+
], "orlan wait (comments on the file come as requests)");
|
|
779
|
+
}
|
|
751
780
|
const { nextStep, ...answer } = await api.postFile("/api/agent/files", { name: path.basename(filePath), topic: values.topic }, await readFile(filePath), "the new file");
|
|
752
781
|
return finish(io, values, answer, [
|
|
753
782
|
`Added ${quote(answer.file.name)} to topic ${quote(answer.topic.name)} as file ` +
|
|
@@ -756,13 +785,20 @@ export const commands = {
|
|
|
756
785
|
}),
|
|
757
786
|
},
|
|
758
787
|
"files pull": {
|
|
759
|
-
usage: "orlan files pull <file> [--version <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]] [--agent <id>] [--json]",
|
|
760
|
-
summary: "Download a version of a file (the current one by default).",
|
|
788
|
+
usage: "orlan files pull <file> [--version <n>] [--pages <n,n> | --since <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]] [--agent <id>] [--json]",
|
|
789
|
+
summary: "Download a version of a file (the current one by default), or images and text of its pages.",
|
|
761
790
|
details: [
|
|
791
|
+
"With --pages or --since it gets pages, not the file: it saves the image of each page in the",
|
|
792
|
+
"--out folder as <name>-page-<n>.png, and prints the text of each page.",
|
|
793
|
+
"",
|
|
762
794
|
"<file> The file id, or its name where it is unique on the topic.",
|
|
763
795
|
"--version <n> A version number, for example 5 or v5. Default: the current version.",
|
|
796
|
+
"--pages <n,n> The pages to get, from 1, for example 3 or 2,5.",
|
|
797
|
+
"--since <n> The pages that changed since this older version, for example 4 or v4. With",
|
|
798
|
+
" --pages, only those of the pages that changed.",
|
|
764
799
|
"--topic <t> The topic of a file name, when two topics have a file with that name.",
|
|
765
|
-
"--out <path> Where to write it. Default: the file's own name, in the folder you are in.",
|
|
800
|
+
"--out <path> Where to write it. Default: the file's own name, in the folder you are in. With",
|
|
801
|
+
" --pages or --since: the folder of the page images. Default: the folder you are in.",
|
|
766
802
|
"--edit You pull it to edit it: this puts your claim on the file, which other agents and",
|
|
767
803
|
" people see. When another agent has a claim, you get it and no claim of your own.",
|
|
768
804
|
" A push or respond ends your claim, and it takes the pulled version as its base.",
|
|
@@ -775,6 +811,8 @@ export const commands = {
|
|
|
775
811
|
...agentOption,
|
|
776
812
|
...jsonOption,
|
|
777
813
|
version: { type: "string" },
|
|
814
|
+
pages: { type: "string" },
|
|
815
|
+
since: { type: "string" },
|
|
778
816
|
topic: { type: "string" },
|
|
779
817
|
out: { type: "string" },
|
|
780
818
|
edit: { type: "boolean" },
|
|
@@ -787,12 +825,17 @@ export const commands = {
|
|
|
787
825
|
throw new OrlanError("--shared and --ttl go with --edit.", undefined, "add --edit, or leave them out");
|
|
788
826
|
}
|
|
789
827
|
const version = versionNumber(values, "version");
|
|
790
|
-
|
|
828
|
+
const since = versionNumber(values, "since");
|
|
829
|
+
const pages = pageList(values);
|
|
830
|
+
const pageMode = pages !== undefined || since !== undefined;
|
|
831
|
+
// One call: the file, the version (v5), the pages, and the claim. Orlan finds a file by its name too.
|
|
791
832
|
const answer = await api.call("get_file", {
|
|
792
833
|
artifactId: fileRef,
|
|
793
834
|
...topicInput(values),
|
|
794
835
|
...(version ? { versionId: `v${version}` } : {}),
|
|
795
|
-
|
|
836
|
+
...(pages ? { pages } : {}),
|
|
837
|
+
...(since !== undefined ? { changedSince: since } : {}),
|
|
838
|
+
pageImages: pageMode ? "url" : "none",
|
|
796
839
|
...(values.edit
|
|
797
840
|
? {
|
|
798
841
|
edit: true,
|
|
@@ -801,6 +844,8 @@ export const commands = {
|
|
|
801
844
|
}
|
|
802
845
|
: {}),
|
|
803
846
|
});
|
|
847
|
+
if (pageMode)
|
|
848
|
+
return pullPages(api, config, values, io, answer, since);
|
|
804
849
|
const url = answer.originalUrl ?? answer.viewUrl;
|
|
805
850
|
if (!url)
|
|
806
851
|
throw new OrlanError("Orlan gave no address for this file.", undefined, "run the command again");
|
|
@@ -815,15 +860,10 @@ export const commands = {
|
|
|
815
860
|
}
|
|
816
861
|
const target = typeof values.out === "string" ? values.out : path.basename(answer.file.name);
|
|
817
862
|
await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
818
|
-
const lines = [
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
for (const other of answer.claims ?? []) {
|
|
823
|
-
const minutes = Math.floor((Date.now() - new Date(other.since).getTime()) / 60_000);
|
|
824
|
-
lines.push(`${quote(other.agent)} is editing it from v${other.baseVersion}${other.shared ? " (shared claim)" : ""} - ` +
|
|
825
|
-
`${minutes < 1 ? "under 1 min" : `${minutes} min`}.`);
|
|
826
|
-
}
|
|
863
|
+
const lines = [
|
|
864
|
+
`Saved version ${answer.version.number} of ${quote(answer.file.name)} to ${quote(target)}.`,
|
|
865
|
+
...claimLines(answer),
|
|
866
|
+
];
|
|
827
867
|
return finish(io, values, {
|
|
828
868
|
file: { id: answer.file.id, name: answer.file.name },
|
|
829
869
|
version: answer.version.number,
|
|
@@ -879,6 +919,44 @@ export const commands = {
|
|
|
879
919
|
], "orlan wait (comments on the version come as requests)");
|
|
880
920
|
}),
|
|
881
921
|
},
|
|
922
|
+
"files rollback": {
|
|
923
|
+
usage: 'orlan files rollback <file> --version <n> [--changelog "<why>"] [--topic <topic>] [--agent <id>] [--json]',
|
|
924
|
+
summary: "Post an older version of a file again, as its newest version.",
|
|
925
|
+
details: [
|
|
926
|
+
"The new version is a copy of the older one. It waits for a person to make it current, as a push does.",
|
|
927
|
+
"",
|
|
928
|
+
"<file> The file id, or its name where it is unique on the topic.",
|
|
929
|
+
"--version <n> The older version to post again, for example 3 or v3. Required.",
|
|
930
|
+
"--changelog <text> Why, for the people who review it.",
|
|
931
|
+
"--topic <topic> The topic of a file name, when two topics have a file with that name.",
|
|
932
|
+
AGENT_TOKEN_HELP,
|
|
933
|
+
JSON_HELP,
|
|
934
|
+
],
|
|
935
|
+
options: {
|
|
936
|
+
...agentOption,
|
|
937
|
+
...jsonOption,
|
|
938
|
+
version: { type: "string" },
|
|
939
|
+
changelog: { type: "string" },
|
|
940
|
+
topic: { type: "string" },
|
|
941
|
+
},
|
|
942
|
+
positionals: [1, 1],
|
|
943
|
+
run: (values, [fileRef], io) => withAgent(values, async (api) => {
|
|
944
|
+
const version = versionNumber(values, "version");
|
|
945
|
+
if (!version) {
|
|
946
|
+
throw new OrlanError("Add --version <n>.", undefined, "orlan files list shows the versions");
|
|
947
|
+
}
|
|
948
|
+
const posted = await api.call("post", {
|
|
949
|
+
artifactId: fileRef,
|
|
950
|
+
...topicInput(values),
|
|
951
|
+
versionId: `v${version}`,
|
|
952
|
+
...(typeof values.changelog === "string" ? { changelog: values.changelog } : {}),
|
|
953
|
+
});
|
|
954
|
+
return finish(io, values, { file: { id: posted.file.id, name: posted.file.name }, version: posted.posted.number, copyOf: version }, [
|
|
955
|
+
`Posted version ${posted.posted.number} of ${quote(posted.file.name)}, a copy of v${version}. ` +
|
|
956
|
+
"It waits for a person to make it current.",
|
|
957
|
+
], "orlan wait (comments on the version come as requests)");
|
|
958
|
+
}),
|
|
959
|
+
},
|
|
882
960
|
"comments list": {
|
|
883
961
|
usage: "orlan comments list [--topic <topic>] [--status open|resolved|all] [--file <file>] [--json]",
|
|
884
962
|
summary: "List the comment threads, newest first.",
|
|
@@ -979,16 +1057,7 @@ export const commands = {
|
|
|
979
1057
|
},
|
|
980
1058
|
positionals: [1, 2],
|
|
981
1059
|
run: (values, positionals, io) => {
|
|
982
|
-
const numbers = (option, count) =>
|
|
983
|
-
const raw = values[option];
|
|
984
|
-
if (typeof raw !== "string")
|
|
985
|
-
return undefined;
|
|
986
|
-
const parts = raw.split(",").map((part) => Number(part.trim()));
|
|
987
|
-
if (parts.length !== count || parts.some((part) => !Number.isFinite(part))) {
|
|
988
|
-
throw new OrlanError(`--${option} takes ${count} numbers with commas.`, undefined, `run the command again with --${option} ${count === 2 ? "300,200" : "0.1,0.2,0.3,0.1"}`);
|
|
989
|
-
}
|
|
990
|
-
return parts;
|
|
991
|
-
};
|
|
1060
|
+
const numbers = (option, count) => numbersOption(values, option, count);
|
|
992
1061
|
const [first, second] = positionals;
|
|
993
1062
|
const body = (second ?? first);
|
|
994
1063
|
const usage = "orlan comment --help (the three forms)";
|
|
@@ -1057,13 +1126,399 @@ export const commands = {
|
|
|
1057
1126
|
return finish(io, values, { id: thread.thread, number: thread.number, status: "resolved" }, [`Resolved #${thread.number}.`], WAIT_STEP);
|
|
1058
1127
|
}),
|
|
1059
1128
|
},
|
|
1129
|
+
board: {
|
|
1130
|
+
usage: "orlan board [--topic <topic>] [--agent <id>] [--json]",
|
|
1131
|
+
summary: "Print the board: its items with their ids and places, the timer, the votes and the open tasks.",
|
|
1132
|
+
details: [
|
|
1133
|
+
"Each item is one line: its id, its type, its place (x,y) and size (w x h) in board coordinates, and",
|
|
1134
|
+
"its text. A file shows its name and pages, an arrow the items it links. People vote; an agent reads",
|
|
1135
|
+
"the vote and its result, it does not vote.",
|
|
1136
|
+
"",
|
|
1137
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1138
|
+
AGENT_TOKEN_HELP,
|
|
1139
|
+
JSON_HELP,
|
|
1140
|
+
],
|
|
1141
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
1142
|
+
positionals: [0, 0],
|
|
1143
|
+
run: (values, _positionals, io) => withAgent(values, async (api) => {
|
|
1144
|
+
const topicId = await topicOrDefault(api, values);
|
|
1145
|
+
// Two requests at the same time: the board, and the names of its files.
|
|
1146
|
+
const [board, { files }] = await Promise.all([
|
|
1147
|
+
api.call("read_board", { topicId, view: "board" }),
|
|
1148
|
+
api.call("read_board", { topicId, view: "files" }),
|
|
1149
|
+
]);
|
|
1150
|
+
const names = new Map(files.map((file) => [file.id, file.name]));
|
|
1151
|
+
const items = board.items.map((item) => item.type === "file" ? { ...item, name: names.get(item.artifactId ?? "") ?? "" } : item);
|
|
1152
|
+
const { timer, vote, lastVote, tasks = [] } = board.session ?? {};
|
|
1153
|
+
const lines = [
|
|
1154
|
+
`Board of ${quote(board.topic.name)} (${board.topic.id}): ${items.length} item${items.length === 1 ? "" : "s"}.`,
|
|
1155
|
+
];
|
|
1156
|
+
if (items.some((item) => item.text) || vote || tasks.length > 0)
|
|
1157
|
+
lines.push(QUOTED_TEXT);
|
|
1158
|
+
const round = (value) => Math.round(value);
|
|
1159
|
+
for (const item of items) {
|
|
1160
|
+
const place = `at ${round(item.x)},${round(item.y)}, ${round(item.w)}x${round(item.h)}`;
|
|
1161
|
+
const end = (id, x, y) => id ?? `${round(x)},${round(y)}`;
|
|
1162
|
+
lines.push(item.type === "arrow"
|
|
1163
|
+
? `- ${item.id} arrow from ${end(item.from, item.x, item.y)} to ${end(item.to, item.x + item.w, item.y + item.h)}`
|
|
1164
|
+
: item.type === "file"
|
|
1165
|
+
? `- ${item.id} file ${quote(item.name ?? "")} (${item.artifactId}) ${place}, ` +
|
|
1166
|
+
`${item.pages?.length ?? 0} page${item.pages?.length === 1 ? "" : "s"}`
|
|
1167
|
+
: `- ${item.id} ${item.type} ${place}${item.text === undefined ? "" : `: ${quote(item.text)}`}`);
|
|
1168
|
+
}
|
|
1169
|
+
if (timer) {
|
|
1170
|
+
lines.push(`timer: ${timer.left}s left of ${timer.of}s${timer.paused ? ", paused" : ""}, by ${quote(timer.by)}` +
|
|
1171
|
+
(timer.for ? `, for ${quote(timer.for)}` : ""));
|
|
1172
|
+
}
|
|
1173
|
+
if (vote)
|
|
1174
|
+
lines.push(`open vote: ${voteText(vote)} (people vote, you do not)`);
|
|
1175
|
+
if (lastVote)
|
|
1176
|
+
lines.push(`last vote: ${voteText(lastVote)}`);
|
|
1177
|
+
lines.push(`open tasks: ${tasks.length === 0 ? "none" : tasks.length}`);
|
|
1178
|
+
for (const task of tasks) {
|
|
1179
|
+
lines.push(`- ${task.id} ${quote(task.text)}${task.to ? ` for ${quote(task.to)}` : ""}` +
|
|
1180
|
+
(task.thread !== undefined ? ` from #${task.thread}` : ""));
|
|
1181
|
+
}
|
|
1182
|
+
return finish(io, values, { topic: board.topic, items, ...(board.session ? { session: board.session } : {}) }, lines, 'orlan board add note "<text>" --at <x,y> (a note), orlan board change <item>, or orlan board delete <item>');
|
|
1183
|
+
}),
|
|
1184
|
+
},
|
|
1185
|
+
"board add": {
|
|
1186
|
+
usage: 'orlan board add note|text|frame ["<text>"] --at <x,y> [--size <w,h>] | arrow --from <item> --to <item> [--topic <topic>] [--agent <id>] [--json]',
|
|
1187
|
+
summary: "Add a note, a text, a frame or an arrow to the board.",
|
|
1188
|
+
details: [
|
|
1189
|
+
"A note, a text or a frame goes at --at, with its text (a frame's text is its title). An arrow",
|
|
1190
|
+
"links two items: --from and --to take their ids, from `orlan board`. People see it at once, and",
|
|
1191
|
+
"a person can undo it. To add several items in one step, use `orlan board edit`.",
|
|
1192
|
+
"",
|
|
1193
|
+
"--at <x,y> The top left corner, in board coordinates. A negative x goes with =: --at=-50,120.",
|
|
1194
|
+
"--size <w,h> The width and the height. Default: the size a person gets.",
|
|
1195
|
+
"--from <item> The item the arrow starts at.",
|
|
1196
|
+
"--to <item> The item the arrow points to.",
|
|
1197
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1198
|
+
AGENT_TOKEN_HELP,
|
|
1199
|
+
JSON_HELP,
|
|
1200
|
+
],
|
|
1201
|
+
options: {
|
|
1202
|
+
...agentOption,
|
|
1203
|
+
...jsonOption,
|
|
1204
|
+
at: { type: "string" },
|
|
1205
|
+
size: { type: "string" },
|
|
1206
|
+
from: { type: "string" },
|
|
1207
|
+
to: { type: "string" },
|
|
1208
|
+
topic: { type: "string" },
|
|
1209
|
+
},
|
|
1210
|
+
positionals: [1, 2],
|
|
1211
|
+
run: (values, [type, text], io) => {
|
|
1212
|
+
const usage = "orlan board add --help";
|
|
1213
|
+
let edit;
|
|
1214
|
+
if (type === "arrow") {
|
|
1215
|
+
if (typeof values.from !== "string" || typeof values.to !== "string") {
|
|
1216
|
+
throw new OrlanError("An arrow needs --from <item> and --to <item>.", undefined, "orlan board (the item ids)");
|
|
1217
|
+
}
|
|
1218
|
+
if (text !== undefined)
|
|
1219
|
+
throw new OrlanError("An arrow has no text.", undefined, usage);
|
|
1220
|
+
edit = { op: "create", type, from: values.from, to: values.to };
|
|
1221
|
+
}
|
|
1222
|
+
else if (type === "note" || type === "text" || type === "frame") {
|
|
1223
|
+
const at = numbersOption(values, "at", 2);
|
|
1224
|
+
if (!at)
|
|
1225
|
+
throw new OrlanError(`A ${type} needs --at <x,y>.`, undefined, "orlan board (where the items are)");
|
|
1226
|
+
const size = numbersOption(values, "size", 2);
|
|
1227
|
+
edit = {
|
|
1228
|
+
op: "create",
|
|
1229
|
+
type,
|
|
1230
|
+
x: at[0],
|
|
1231
|
+
y: at[1],
|
|
1232
|
+
...(size ? { w: size[0], h: size[1] } : {}),
|
|
1233
|
+
...(text !== undefined ? { text } : {}),
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
throw new OrlanError("The item is a note, a text, a frame or an arrow.", undefined, usage);
|
|
1238
|
+
}
|
|
1239
|
+
return withAgent(values, async (api) => {
|
|
1240
|
+
const { ids } = await editBoard(api, values, [edit]);
|
|
1241
|
+
const where = type === "arrow" ? `from ${values.from} to ${values.to}` : `at ${edit.x},${edit.y}`;
|
|
1242
|
+
return finish(io, values, { id: ids[0], type }, [`Added ${type} ${ids[0]} ${where}.`], type === "arrow"
|
|
1243
|
+
? "orlan board (the board with the arrow)"
|
|
1244
|
+
: `orlan board add arrow --from ${ids[0]} --to <item> (to link it), or orlan board`);
|
|
1245
|
+
});
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
"board change": {
|
|
1249
|
+
usage: 'orlan board change <item> [--text "<text>"] [--at <x,y>] [--size <w,h>] [--from <item>|none] [--to <item>|none] [--topic <topic>] [--agent <id>] [--json]',
|
|
1250
|
+
summary: "Change an item of the board: its text, place, size, or the ends of an arrow.",
|
|
1251
|
+
details: [
|
|
1252
|
+
"The changes are one step, which a person can undo. A file on the board only moves (--at).",
|
|
1253
|
+
"",
|
|
1254
|
+
"<item> The item id, from `orlan board`.",
|
|
1255
|
+
"--text <text> The new text of a note or a text, or the title of a frame.",
|
|
1256
|
+
"--at <x,y> Move the item: its new top left corner, in board coordinates. A negative x goes",
|
|
1257
|
+
" with =: --at=-50,120.",
|
|
1258
|
+
"--size <w,h> The new width and height.",
|
|
1259
|
+
"--from <item> The item the arrow starts at. none: the end stays where it is, on no item.",
|
|
1260
|
+
"--to <item> The item the arrow points to. none: the end stays where it is, on no item.",
|
|
1261
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1262
|
+
AGENT_TOKEN_HELP,
|
|
1263
|
+
JSON_HELP,
|
|
1264
|
+
],
|
|
1265
|
+
options: {
|
|
1266
|
+
...agentOption,
|
|
1267
|
+
...jsonOption,
|
|
1268
|
+
text: { type: "string" },
|
|
1269
|
+
at: { type: "string" },
|
|
1270
|
+
size: { type: "string" },
|
|
1271
|
+
from: { type: "string" },
|
|
1272
|
+
to: { type: "string" },
|
|
1273
|
+
topic: { type: "string" },
|
|
1274
|
+
},
|
|
1275
|
+
positionals: [1, 1],
|
|
1276
|
+
run: (values, [id], io) => {
|
|
1277
|
+
const at = numbersOption(values, "at", 2);
|
|
1278
|
+
const size = numbersOption(values, "size", 2);
|
|
1279
|
+
const end = (option) => {
|
|
1280
|
+
const value = values[option];
|
|
1281
|
+
if (typeof value !== "string")
|
|
1282
|
+
return {};
|
|
1283
|
+
return { [option]: value === "none" ? null : value };
|
|
1284
|
+
};
|
|
1285
|
+
const update = {
|
|
1286
|
+
...(typeof values.text === "string" ? { text: values.text } : {}),
|
|
1287
|
+
...(size ? { w: size[0], h: size[1] } : {}),
|
|
1288
|
+
...end("from"),
|
|
1289
|
+
...end("to"),
|
|
1290
|
+
};
|
|
1291
|
+
const edits = [
|
|
1292
|
+
...(at ? [{ op: "move", id, x: at[0], y: at[1] }] : []),
|
|
1293
|
+
...(Object.keys(update).length > 0 ? [{ op: "update", id, ...update }] : []),
|
|
1294
|
+
];
|
|
1295
|
+
if (edits.length === 0) {
|
|
1296
|
+
throw new OrlanError("Give --text, --at, --size, --from or --to.", undefined, `orlan board change ${id} --text "<text>"`);
|
|
1297
|
+
}
|
|
1298
|
+
return withAgent(values, async (api) => {
|
|
1299
|
+
await editBoard(api, values, edits);
|
|
1300
|
+
return finish(io, values, { id }, [`Changed ${id}.`], "orlan board (the board with the change)");
|
|
1301
|
+
});
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
"board delete": {
|
|
1305
|
+
usage: "orlan board delete <item>... [--topic <topic>] [--agent <id>] [--json]",
|
|
1306
|
+
summary: "Delete items from the board: notes, texts, frames and arrows.",
|
|
1307
|
+
details: [
|
|
1308
|
+
"One step for all the items, which a person can undo. A file on the board is not deleted here: a",
|
|
1309
|
+
"person does that.",
|
|
1310
|
+
"",
|
|
1311
|
+
"<item> The item id, from `orlan board`. Give several to delete several.",
|
|
1312
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1313
|
+
AGENT_TOKEN_HELP,
|
|
1314
|
+
JSON_HELP,
|
|
1315
|
+
],
|
|
1316
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
1317
|
+
positionals: [1, 100],
|
|
1318
|
+
run: (values, ids, io) => withAgent(values, async (api) => {
|
|
1319
|
+
await editBoard(api, values, ids.map((id) => ({ op: "delete", id })));
|
|
1320
|
+
return finish(io, values, { deleted: ids }, [`Deleted ${ids.length === 1 ? "1 item" : `${ids.length} items`}: ${ids.join(", ")}. A person can undo it.`], "orlan board (the board now)");
|
|
1321
|
+
}),
|
|
1322
|
+
},
|
|
1323
|
+
"board edit": {
|
|
1324
|
+
usage: "orlan board edit '<edits as JSON>' [--topic <topic>] [--agent <id>] [--json]",
|
|
1325
|
+
summary: "Make several changes to the board in one step: all of them, or none.",
|
|
1326
|
+
details: [
|
|
1327
|
+
'The edits are a JSON list of at most 100. A person can undo the step. "#n" is the item that edit n',
|
|
1328
|
+
"(from 0) made, so one step can add two notes and the arrow between them:",
|
|
1329
|
+
' [{"op":"create","type":"note","x":0,"y":0,"text":"Web"},',
|
|
1330
|
+
' {"op":"create","type":"note","x":400,"y":0,"text":"API"},',
|
|
1331
|
+
' {"op":"create","type":"arrow","from":"#0","to":"#1"}]',
|
|
1332
|
+
"",
|
|
1333
|
+
"create type note, text or frame with x, y and text (and w, h); type arrow with from and to.",
|
|
1334
|
+
"move id, x, y.",
|
|
1335
|
+
"update id, and text, w, h, or from and to (null: on no item).",
|
|
1336
|
+
"delete id. A file on the board only moves.",
|
|
1337
|
+
"",
|
|
1338
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1339
|
+
AGENT_TOKEN_HELP,
|
|
1340
|
+
JSON_HELP,
|
|
1341
|
+
],
|
|
1342
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" } },
|
|
1343
|
+
positionals: [1, 1],
|
|
1344
|
+
run: (values, [text], io) => {
|
|
1345
|
+
let edits;
|
|
1346
|
+
try {
|
|
1347
|
+
edits = JSON.parse(text);
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
edits = undefined;
|
|
1351
|
+
}
|
|
1352
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
1353
|
+
throw new OrlanError('The edits are a JSON list, for example [{"op":"delete","id":"<item>"}].', undefined, "orlan board edit --help");
|
|
1354
|
+
}
|
|
1355
|
+
return withAgent(values, async (api) => {
|
|
1356
|
+
const { ids } = await editBoard(api, values, edits);
|
|
1357
|
+
return finish(io, values, { ids }, [`Made ${ids.length} change${ids.length === 1 ? "" : "s"} in one step. The items: ${ids.join(", ")}.`], "orlan board (the board now)");
|
|
1358
|
+
});
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
"tasks add": {
|
|
1362
|
+
usage: 'orlan tasks add "<text>" [--assignee me|user:<id>|agent:<id>] [--thread <thread>] [--done] [--topic <topic>] [--agent <id>] [--json]',
|
|
1363
|
+
summary: "Add a task to the topic's task list.",
|
|
1364
|
+
details: [
|
|
1365
|
+
"People see the task on the board at once. `orlan board` lists the open tasks.",
|
|
1366
|
+
"",
|
|
1367
|
+
"--assignee <who> me (this agent), user:<id> or agent:<id>. Default: nobody.",
|
|
1368
|
+
"--thread <t> The comment thread the task comes from: #12 or the thread id.",
|
|
1369
|
+
"--done The task is done already.",
|
|
1370
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1371
|
+
AGENT_TOKEN_HELP,
|
|
1372
|
+
JSON_HELP,
|
|
1373
|
+
],
|
|
1374
|
+
options: {
|
|
1375
|
+
...agentOption,
|
|
1376
|
+
...jsonOption,
|
|
1377
|
+
assignee: { type: "string" },
|
|
1378
|
+
thread: { type: "string" },
|
|
1379
|
+
done: { type: "boolean" },
|
|
1380
|
+
topic: { type: "string" },
|
|
1381
|
+
},
|
|
1382
|
+
positionals: [1, 1],
|
|
1383
|
+
run: (values, [text], io) => withAgent(values, async (api, config) => {
|
|
1384
|
+
const assignee = assigneeOption(config, values);
|
|
1385
|
+
const task = await api.call("record", {
|
|
1386
|
+
kind: "task",
|
|
1387
|
+
topicId: await topicOrDefault(api, values),
|
|
1388
|
+
text,
|
|
1389
|
+
...(assignee ? { assignee } : {}),
|
|
1390
|
+
...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
|
|
1391
|
+
...(values.done ? { done: true } : {}),
|
|
1392
|
+
});
|
|
1393
|
+
return finish(io, values, { task }, [`Added task ${task.id}: ${taskText(task)}.`], `orlan tasks change ${task.id} --done (when it is done)`);
|
|
1394
|
+
}),
|
|
1395
|
+
},
|
|
1396
|
+
"tasks change": {
|
|
1397
|
+
usage: 'orlan tasks change <task id> [--text "<text>"] [--done | --open] [--assignee me|user:<id>|agent:<id>|none] [--agent <id>] [--json]',
|
|
1398
|
+
summary: "Change a task: its text, done or open, or its assignee.",
|
|
1399
|
+
details: [
|
|
1400
|
+
"<task id> The task id, from `orlan board` or `orlan brief`.",
|
|
1401
|
+
"--text <text> The new text.",
|
|
1402
|
+
"--done Mark it done.",
|
|
1403
|
+
"--open Mark it open again.",
|
|
1404
|
+
"--assignee <who> me (this agent), user:<id>, agent:<id>, or none.",
|
|
1405
|
+
AGENT_TOKEN_HELP,
|
|
1406
|
+
JSON_HELP,
|
|
1407
|
+
],
|
|
1408
|
+
options: {
|
|
1409
|
+
...agentOption,
|
|
1410
|
+
...jsonOption,
|
|
1411
|
+
text: { type: "string" },
|
|
1412
|
+
done: { type: "boolean" },
|
|
1413
|
+
open: { type: "boolean" },
|
|
1414
|
+
assignee: { type: "string" },
|
|
1415
|
+
},
|
|
1416
|
+
positionals: [1, 1],
|
|
1417
|
+
run: (values, [taskId], io) => withAgent(values, async (api, config) => {
|
|
1418
|
+
if (values.done && values.open)
|
|
1419
|
+
throw new OrlanError("Give --done or --open, not both.");
|
|
1420
|
+
const assignee = assigneeOption(config, values);
|
|
1421
|
+
const change = {
|
|
1422
|
+
...(typeof values.text === "string" ? { text: values.text } : {}),
|
|
1423
|
+
...(values.done ? { done: true } : values.open ? { done: false } : {}),
|
|
1424
|
+
...(assignee !== undefined ? { assignee } : {}),
|
|
1425
|
+
};
|
|
1426
|
+
if (Object.keys(change).length === 0) {
|
|
1427
|
+
throw new OrlanError("Give --text, --done, --open or --assignee.", undefined, `orlan tasks change ${taskId} --done`);
|
|
1428
|
+
}
|
|
1429
|
+
const task = await api.call("record", { kind: "task", taskId, ...change });
|
|
1430
|
+
return finish(io, values, { task }, [`Changed task ${task.id}: ${taskText(task)}.`], WAIT_STEP);
|
|
1431
|
+
}),
|
|
1432
|
+
},
|
|
1433
|
+
"decisions add": {
|
|
1434
|
+
usage: 'orlan decisions add "<title>" [--body "<text>"] [--thread <thread>] [--topic <topic>] [--agent <id>] [--json]',
|
|
1435
|
+
summary: "Record a decision in the topic's decision log.",
|
|
1436
|
+
details: [
|
|
1437
|
+
"Record a decision the people made, so it is not lost in a thread. `orlan search` finds it later.",
|
|
1438
|
+
"",
|
|
1439
|
+
"--body <text> The decision in full: what, and why.",
|
|
1440
|
+
"--thread <t> The comment thread the decision comes from: #12 or the thread id.",
|
|
1441
|
+
"--topic <topic> The topic id or its name. Default: the topic the agent used last, else its only topic.",
|
|
1442
|
+
AGENT_TOKEN_HELP,
|
|
1443
|
+
JSON_HELP,
|
|
1444
|
+
],
|
|
1445
|
+
options: {
|
|
1446
|
+
...agentOption,
|
|
1447
|
+
...jsonOption,
|
|
1448
|
+
body: { type: "string" },
|
|
1449
|
+
thread: { type: "string" },
|
|
1450
|
+
topic: { type: "string" },
|
|
1451
|
+
},
|
|
1452
|
+
positionals: [1, 1],
|
|
1453
|
+
run: (values, [title], io) => withAgent(values, async (api) => {
|
|
1454
|
+
const decision = await api.call("record", {
|
|
1455
|
+
kind: "decision",
|
|
1456
|
+
topicId: await topicOrDefault(api, values),
|
|
1457
|
+
title,
|
|
1458
|
+
...(typeof values.body === "string" ? { body: values.body } : {}),
|
|
1459
|
+
...(typeof values.thread === "string" ? { sourceThreadId: values.thread } : {}),
|
|
1460
|
+
});
|
|
1461
|
+
return finish(io, values, { decision }, [`Recorded decision ${decision.id}: ${quote(decision.title)}.`], WAIT_STEP);
|
|
1462
|
+
}),
|
|
1463
|
+
},
|
|
1464
|
+
search: {
|
|
1465
|
+
usage: 'orlan search "<words>" [--topic <topic>] [--limit <n>] [--agent <id>] [--json]',
|
|
1466
|
+
summary: "Search the topics of the agent: topic and file names, page text, comments and decisions.",
|
|
1467
|
+
details: [
|
|
1468
|
+
"The matches are between « and », with some text around them. Orlan searches the current version",
|
|
1469
|
+
"of each file, and the newest version that was sent.",
|
|
1470
|
+
"",
|
|
1471
|
+
"--topic <topic> Only this topic: its id or its name. Default: every topic of the agent.",
|
|
1472
|
+
"--limit <n> At most this many results, from 1 to 100. Default: 20.",
|
|
1473
|
+
AGENT_TOKEN_HELP,
|
|
1474
|
+
JSON_HELP,
|
|
1475
|
+
],
|
|
1476
|
+
options: { ...agentOption, ...jsonOption, topic: { type: "string" }, limit: { type: "string" } },
|
|
1477
|
+
positionals: [1, 1],
|
|
1478
|
+
run: (values, [words], io) => withAgent(values, async (api) => {
|
|
1479
|
+
const limit = values.limit === undefined ? undefined : Number(values.limit);
|
|
1480
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
|
|
1481
|
+
throw new OrlanError("--limit takes a number from 1 to 100.", undefined, "run the command again with --limit 20");
|
|
1482
|
+
}
|
|
1483
|
+
const { results } = await api.call("search", {
|
|
1484
|
+
q: words,
|
|
1485
|
+
...topicInput(values),
|
|
1486
|
+
...(limit ? { limit } : {}),
|
|
1487
|
+
});
|
|
1488
|
+
const on = (result) => `on ${quote(result.topic.name)}`;
|
|
1489
|
+
const version = (result) => (result.version ? ` v${result.version.number}` : "");
|
|
1490
|
+
const lines = results.length === 0 ? [`No match for ${quote(words)}.`] : [QUOTED_TEXT];
|
|
1491
|
+
for (const result of results) {
|
|
1492
|
+
const { ids } = result;
|
|
1493
|
+
lines.push(result.kind === "topic"
|
|
1494
|
+
? `- topic ${quote(result.title)} (${result.topic.id}): ${quote(result.snippet)}`
|
|
1495
|
+
: result.kind === "file"
|
|
1496
|
+
? `- file ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
|
|
1497
|
+
: result.kind === "page"
|
|
1498
|
+
? `- page ${ids.page} of ${quote(result.title)}${version(result)} (${ids.artifactId}) ${on(result)}: ${quote(result.snippet)}`
|
|
1499
|
+
: result.kind === "comment"
|
|
1500
|
+
? `- comment in ${result.title} (thread ${ids.threadId}) ${on(result)}: ${quote(result.snippet)}`
|
|
1501
|
+
: `- decision ${quote(result.title)} (${ids.decisionId}) ${on(result)}: ${quote(result.snippet)}`);
|
|
1502
|
+
}
|
|
1503
|
+
const page = results.find((result) => result.kind === "page");
|
|
1504
|
+
const comment = results.find((result) => result.kind === "comment");
|
|
1505
|
+
return finish(io, values, { results }, lines, page
|
|
1506
|
+
? `orlan files pull ${page.ids.artifactId} --pages ${page.ids.page} (the page of a match)`
|
|
1507
|
+
: comment
|
|
1508
|
+
? `orlan comments list --topic ${comment.topic.id} --status all (the threads)`
|
|
1509
|
+
: results.length === 0
|
|
1510
|
+
? "search again with other words"
|
|
1511
|
+
: "orlan brief (where the work stands)");
|
|
1512
|
+
}),
|
|
1513
|
+
},
|
|
1060
1514
|
brief: {
|
|
1061
1515
|
usage: "orlan brief [--topic <topic>] [--agent <id>] [--json]",
|
|
1062
1516
|
summary: "Print where the work stands: the topic, the open requests, the files, and the next step.",
|
|
1063
1517
|
details: [
|
|
1064
|
-
"For the start of a session: one short answer,
|
|
1518
|
+
"For the start of a session: one short answer, in one plain HTTP call. The topic is the one the agent",
|
|
1065
1519
|
"used last, else its only topic. It lists the open requests (`orlan inbox` prints them in full),",
|
|
1066
|
-
"the files with their current versions, and one
|
|
1520
|
+
"the files with their current versions, the open vote and the open tasks (one line each), and one",
|
|
1521
|
+
"next_step line. People vote; an agent reads the vote, it does not vote.",
|
|
1067
1522
|
"",
|
|
1068
1523
|
"--topic <topic> Another topic of the agent: its id or its name.",
|
|
1069
1524
|
AGENT_TOKEN_HELP,
|
|
@@ -1311,9 +1766,9 @@ export const commands = {
|
|
|
1311
1766
|
usage: "orlan hook start --agent codex|cursor",
|
|
1312
1767
|
summary: "The session start hook of Codex and Cursor: report working and give the session the brief.",
|
|
1313
1768
|
details: [
|
|
1314
|
-
"`orlan
|
|
1315
|
-
"request, in the JSON the harness adds to the session's context, and the board shows
|
|
1316
|
-
"as working. With the agent not connected, it prints nothing.",
|
|
1769
|
+
"`orlan connect --agent codex|cursor` installs it. It prints `orlan brief`, what Orlan is and how",
|
|
1770
|
+
"to answer a request, in the JSON the harness adds to the session's context, and the board shows",
|
|
1771
|
+
"the session as working. With the agent not connected, it prints nothing.",
|
|
1317
1772
|
"",
|
|
1318
1773
|
"--agent <id> codex or cursor.",
|
|
1319
1774
|
],
|
|
@@ -1330,16 +1785,15 @@ export const commands = {
|
|
|
1330
1785
|
apiCall(config.server, "/api/agent/brief?hook=true", { token }),
|
|
1331
1786
|
reportQuietly(config.server, token, "working", fields.session),
|
|
1332
1787
|
]);
|
|
1333
|
-
const
|
|
1788
|
+
const lines = [
|
|
1334
1789
|
...brief.lines,
|
|
1790
|
+
await orlanIntro(),
|
|
1335
1791
|
`No monitor wakes this ${agentLabels[agent]} session. A new Orlan request comes to you when your turn ends, ` +
|
|
1336
1792
|
"from the Orlan stop hook. Do not run orlan wait yourself: it blocks your turn.",
|
|
1337
1793
|
"Answer a request with the orlan command in few steps: orlan files pull <file id> --edit --out <path> && cat <path>; " +
|
|
1338
1794
|
'edit the file; orlan respond <request id> --file <path> [--to <file id>] --message "<what changed>".',
|
|
1339
|
-
]
|
|
1340
|
-
io.out(
|
|
1341
|
-
? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }
|
|
1342
|
-
: { additional_context: context }));
|
|
1795
|
+
];
|
|
1796
|
+
io.out(sessionStartOutput(agent, lines));
|
|
1343
1797
|
return 0;
|
|
1344
1798
|
},
|
|
1345
1799
|
},
|
|
@@ -1347,7 +1801,7 @@ export const commands = {
|
|
|
1347
1801
|
usage: "orlan hook stop --agent codex|cursor",
|
|
1348
1802
|
summary: "The stop hook of Codex and Cursor: give the session a waiting request, else report idle.",
|
|
1349
1803
|
details: [
|
|
1350
|
-
"`orlan
|
|
1804
|
+
"`orlan connect --agent codex|cursor` installs it. When the session's turn ends, it takes the",
|
|
1351
1805
|
"new requests of the agent (as `orlan inbox --new` does) and gives them to the session, so it works",
|
|
1352
1806
|
"on at once: Codex gets decision block with the requests, Cursor a followup_message. The board",
|
|
1353
1807
|
"shows the session as working. With no request, it prints nothing and the board shows it idle.",
|
|
@@ -1401,7 +1855,7 @@ export const commands = {
|
|
|
1401
1855
|
usage: "orlan hook wait --agent opencode",
|
|
1402
1856
|
summary: "The wait of the OpenCode plugin: wait for a request and print it as the prompt of a new turn.",
|
|
1403
1857
|
details: [
|
|
1404
|
-
"`orlan
|
|
1858
|
+
"`orlan connect --agent opencode` installs the plugin that runs it when a session's turn ends.",
|
|
1405
1859
|
'It waits as `orlan wait` does, then prints one JSON line: {"prompt"} with the request, which the',
|
|
1406
1860
|
'plugin sends to the session, or {"stopped"} when a person stopped the agent on the board. It ends',
|
|
1407
1861
|
"when its standard input closes (OpenCode exited); a request it did not print stays in the inbox.",
|
|
@@ -1463,3 +1917,112 @@ export const commands = {
|
|
|
1463
1917
|
},
|
|
1464
1918
|
},
|
|
1465
1919
|
};
|
|
1920
|
+
/** Runs edit_board on the topic of --topic, else the default topic: one step, all edits or none. */
|
|
1921
|
+
async function editBoard(api, values, edits) {
|
|
1922
|
+
return api.call("edit_board", { topicId: await topicOrDefault(api, values), edits });
|
|
1923
|
+
}
|
|
1924
|
+
/** --assignee as the tool takes it: me (the token of this agent), user:<id>, agent:<id>, or none (null). */
|
|
1925
|
+
function assigneeOption(config, values) {
|
|
1926
|
+
const raw = values.assignee;
|
|
1927
|
+
if (typeof raw !== "string")
|
|
1928
|
+
return undefined;
|
|
1929
|
+
if (raw === "none")
|
|
1930
|
+
return null;
|
|
1931
|
+
if (raw === "me") {
|
|
1932
|
+
return { kind: "agent", id: config.agents[pickAgent(config, values)]?.tokenId };
|
|
1933
|
+
}
|
|
1934
|
+
const match = /^(user|agent):(\S+)$/.exec(raw);
|
|
1935
|
+
if (!match) {
|
|
1936
|
+
throw new OrlanError("--assignee takes me, user:<id>, agent:<id> or none.", undefined, "run the command again with --assignee me");
|
|
1937
|
+
}
|
|
1938
|
+
return { kind: match[1], id: match[2] };
|
|
1939
|
+
}
|
|
1940
|
+
/** A task in one line: its text, its assignee, and done. */
|
|
1941
|
+
const taskText = (task) => `${quote(task.text)}${task.assignee ? ` for ${quote(task.assignee.name)}` : ""}${task.done ? ", done" : ", open"}`;
|
|
1942
|
+
/** A vote in one line, as the brief shows it, with its result when people can see it. */
|
|
1943
|
+
function voteText(vote) {
|
|
1944
|
+
const result = vote.result
|
|
1945
|
+
?.map((entry) => "option" in entry
|
|
1946
|
+
? `${quote(entry.option)} ${entry.count}${entry.by ? ` (${entry.by.map(quote).join(", ")})` : ""}`
|
|
1947
|
+
: `${entry.on} ${entry.count}`)
|
|
1948
|
+
.join(", ");
|
|
1949
|
+
return (`${vote.kind} ${quote(vote.question)}` +
|
|
1950
|
+
(vote.options ? `, options ${vote.options.map(quote).join(", ")}` : `, ${vote.dots} dots each`) +
|
|
1951
|
+
`, ${vote.voters} voted` +
|
|
1952
|
+
(result ? `, result: ${result}` : ""));
|
|
1953
|
+
}
|
|
1954
|
+
/** --pages as page numbers: "3" or "2,5". */
|
|
1955
|
+
function pageList(values) {
|
|
1956
|
+
if (typeof values.pages !== "string")
|
|
1957
|
+
return undefined;
|
|
1958
|
+
const pages = values.pages.split(",").map((part) => Number(part.trim()));
|
|
1959
|
+
if (pages.length === 0 || pages.some((page) => !Number.isInteger(page) || page < 1)) {
|
|
1960
|
+
throw new OrlanError("--pages takes page numbers from 1 with commas, for example 3 or 2,5.", undefined, "run the command again with --pages 1");
|
|
1961
|
+
}
|
|
1962
|
+
return pages;
|
|
1963
|
+
}
|
|
1964
|
+
/** The claim lines of a pull: the agent's own claim, and each other agent that edits the file. */
|
|
1965
|
+
function claimLines(answer) {
|
|
1966
|
+
const lines = [];
|
|
1967
|
+
if (answer.claim) {
|
|
1968
|
+
lines.push(`Your${answer.claim.shared ? " shared" : ""} claim is on the file from v${answer.claim.baseVersion}.`);
|
|
1969
|
+
}
|
|
1970
|
+
for (const other of answer.claims ?? []) {
|
|
1971
|
+
const minutes = Math.floor((Date.now() - new Date(other.since).getTime()) / 60_000);
|
|
1972
|
+
lines.push(`${quote(other.agent)} is editing it from v${other.baseVersion}${other.shared ? " (shared claim)" : ""} - ` +
|
|
1973
|
+
`${minutes < 1 ? "under 1 min" : `${minutes} min`}.`);
|
|
1974
|
+
}
|
|
1975
|
+
return lines;
|
|
1976
|
+
}
|
|
1977
|
+
/** The image types a page has, and the extension of each. */
|
|
1978
|
+
const PAGE_EXTENSIONS = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" };
|
|
1979
|
+
/** `orlan files pull --pages|--since`: saves the image of each page and prints its text. */
|
|
1980
|
+
async function pullPages(api, config, values, io, answer, since) {
|
|
1981
|
+
const folder = typeof values.out === "string" ? values.out : ".";
|
|
1982
|
+
const stem = path.basename(answer.file.name, path.extname(answer.file.name));
|
|
1983
|
+
const saved = [];
|
|
1984
|
+
for (const page of answer.pages ?? []) {
|
|
1985
|
+
const response = await fetch(page.imageUrl, {
|
|
1986
|
+
headers: { authorization: `Bearer ${api.token}` },
|
|
1987
|
+
}).catch((error) => {
|
|
1988
|
+
throw unreachable(config.server, error);
|
|
1989
|
+
});
|
|
1990
|
+
if (!response.ok) {
|
|
1991
|
+
throw new OrlanError(`The image of page ${page.index} from ${config.server} answered ${response.status}.`, undefined, "run the command again");
|
|
1992
|
+
}
|
|
1993
|
+
const type = response.headers.get("content-type")?.split(";")[0] ?? "";
|
|
1994
|
+
const image = path.join(folder, `${stem}-page-${page.index}.${PAGE_EXTENSIONS[type] ?? "png"}`);
|
|
1995
|
+
if (saved.length === 0)
|
|
1996
|
+
await mkdir(folder, { recursive: true });
|
|
1997
|
+
await writeFile(image, Buffer.from(await response.arrayBuffer()));
|
|
1998
|
+
saved.push({ index: page.index, image, text: page.text ?? "" });
|
|
1999
|
+
}
|
|
2000
|
+
const { number, renderStatus } = answer.version;
|
|
2001
|
+
const title = `Version ${number} of ${quote(answer.file.name)}`;
|
|
2002
|
+
const lines = [
|
|
2003
|
+
since !== undefined
|
|
2004
|
+
? `${title}: ${changedText(saved.map((page) => page.index))} since v${since}.`
|
|
2005
|
+
: `${title}: ${saved.length} page${saved.length === 1 ? "" : "s"}.`,
|
|
2006
|
+
...(renderStatus !== "done" && saved.length === 0
|
|
2007
|
+
? [`The pages of v${number} are not rendered yet (${renderStatus}).`]
|
|
2008
|
+
: []),
|
|
2009
|
+
...saved.map((page) => `Page ${page.index}: saved ${quote(page.image)}. Text: ${quote(page.text)}`),
|
|
2010
|
+
...claimLines(answer),
|
|
2011
|
+
];
|
|
2012
|
+
const first = saved[0];
|
|
2013
|
+
return finish(io, values, {
|
|
2014
|
+
file: { id: answer.file.id, name: answer.file.name },
|
|
2015
|
+
version: number,
|
|
2016
|
+
...(since !== undefined ? { changedSince: since } : {}),
|
|
2017
|
+
pages: saved,
|
|
2018
|
+
...(answer.claim !== undefined ? { claim: answer.claim } : {}),
|
|
2019
|
+
...(answer.claims ? { claims: answer.claims } : {}),
|
|
2020
|
+
}, lines, answer.nextStep ??
|
|
2021
|
+
(first
|
|
2022
|
+
? `look at the images, then orlan comment --file ${answer.file.id} --page ${first.index} "<text>" (a comment on a page)`
|
|
2023
|
+
: renderStatus !== "done"
|
|
2024
|
+
? "run the command again in a minute"
|
|
2025
|
+
: `orlan files pull ${answer.file.id} (the whole file)`));
|
|
2026
|
+
}
|
|
2027
|
+
/** The changed pages of a version, as words: "pages 2, 5 changed", "no page changed". */
|
|
2028
|
+
const changedText = (pages) => pages.length === 0 ? "no page changed" : `page${pages.length === 1 ? "" : "s"} ${pages.join(", ")} changed`;
|