@orlan-maker/cli 0.1.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 +15 -0
- package/.claude-plugin/plugin.json +14 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/orlan +8 -0
- package/dist/agents.js +406 -0
- package/dist/browser.js +27 -0
- package/dist/commands.js +1465 -0
- package/dist/config.js +55 -0
- package/dist/http.js +156 -0
- package/dist/main.js +114 -0
- package/dist/mcp.js +113 -0
- package/dist/secrets.js +120 -0
- package/dist/skills.js +20 -0
- package/hooks/hooks.json +68 -0
- package/hooks/session-start +11 -0
- package/monitors/monitors.json +7 -0
- package/opencode/orlan.js +109 -0
- package/package.json +46 -0
- package/skills/orlan-review/SKILL.md +78 -0
- package/skills/orlan-versions/SKILL.md +29 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the CLI keeps between commands, in the user config folder: the server, who signed in, and the
|
|
3
|
+
* agents it connected. The secrets are not in this file: they go to the OS keychain (secrets.ts).
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
/** The server when neither --server nor ORLAN_SERVER names one. */
|
|
9
|
+
export const DEFAULT_SERVER = "https://orlan.app";
|
|
10
|
+
/** $ORLAN_CONFIG_DIR, else the platform's user config folder. */
|
|
11
|
+
export function configDir(env = process.env) {
|
|
12
|
+
if (env.ORLAN_CONFIG_DIR)
|
|
13
|
+
return env.ORLAN_CONFIG_DIR;
|
|
14
|
+
if (process.platform === "win32" && env.APPDATA)
|
|
15
|
+
return path.join(env.APPDATA, "orlan");
|
|
16
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "orlan");
|
|
17
|
+
}
|
|
18
|
+
const configFile = () => path.join(configDir(), "config.json");
|
|
19
|
+
/** The origin of a server address: https://orlan.app/ and https://orlan.app/t/1 are the same server. */
|
|
20
|
+
export function serverOrigin(server) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(server);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(`"${server}" is not a server address. Use a full address, for example https://orlan.app.`);
|
|
27
|
+
}
|
|
28
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
29
|
+
throw new Error(`"${server}" is not an http or https address.`);
|
|
30
|
+
}
|
|
31
|
+
return url.origin;
|
|
32
|
+
}
|
|
33
|
+
export async function readConfig() {
|
|
34
|
+
let saved = {};
|
|
35
|
+
try {
|
|
36
|
+
saved = JSON.parse(await readFile(configFile(), "utf8"));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code !== "ENOENT")
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
...saved,
|
|
44
|
+
server: serverOrigin(process.env.ORLAN_SERVER || saved.server || DEFAULT_SERVER),
|
|
45
|
+
agents: saved.agents ?? {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Writes the whole file at once, so a stopped command never leaves half a file. */
|
|
49
|
+
export async function writeConfig(config) {
|
|
50
|
+
const file = configFile();
|
|
51
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
53
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
54
|
+
await rename(temporary, file);
|
|
55
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Calls to the Orlan API, and the one error type every command prints. */
|
|
2
|
+
/**
|
|
3
|
+
* A failure the CLI explains to the person. `code` is the API's named error, when there is one.
|
|
4
|
+
* `next` is what to do next: main.ts prints it as the next_step line.
|
|
5
|
+
*/
|
|
6
|
+
export class OrlanError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
next;
|
|
9
|
+
constructor(message, code, next) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.next = next ?? (code ? nextSteps[code] : undefined);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** What a named API error means for a person at the terminal. */
|
|
16
|
+
const explained = {
|
|
17
|
+
not_signed_in: "Orlan did not accept the token. Run `orlan auth login` again.",
|
|
18
|
+
forbidden: "Your role does not allow this. An admin of the organisation connects agents.",
|
|
19
|
+
not_found: "Orlan found no such item, or your token cannot reach it.",
|
|
20
|
+
token_invalid: "Orlan needs at least one topic for the agent. Add --topic <topic id>, or sign in again and pick topics.",
|
|
21
|
+
login_denied: "The sign-in was denied in the browser.",
|
|
22
|
+
login_expired: "The sign-in code expired. Run `orlan auth login` again.",
|
|
23
|
+
login_used: "This sign-in code was already used. Run `orlan auth login` again.",
|
|
24
|
+
login_rate_limited: "Too many sign-ins from this computer in a short time. Wait 10 minutes, then try again.",
|
|
25
|
+
org_suspended: "This organisation is suspended.",
|
|
26
|
+
topic_missing: "The agent has several topics and used none yet. Add --topic <topic id>: `orlan topics list` shows them.",
|
|
27
|
+
file_too_large: "The file is larger than Orlan takes.",
|
|
28
|
+
plan_limit_agents: "The organisation uses all the agents of the Free plan. Disconnect an agent with `orlan mcp disconnect`, or ask an admin to move the organisation to Team (organisation menu, Billing).",
|
|
29
|
+
guest_account_agent_limit: "A guest account connects one agent. Disconnect it with `orlan mcp disconnect`, or add your email in Orlan (the bar at the top) to connect more.",
|
|
30
|
+
guest_account_file_limit: "A guest account holds 3 files, 25 MB in total. Add your email in Orlan (the bar at the top) to upload more.",
|
|
31
|
+
};
|
|
32
|
+
/** What to do after a named API error (F038): every error ends with a next_step line. */
|
|
33
|
+
const nextSteps = {
|
|
34
|
+
not_signed_in: "orlan auth login, then orlan mcp connect --agent <id>",
|
|
35
|
+
forbidden: "ask an admin of the organisation, or tell your person what you wanted to do",
|
|
36
|
+
not_found: "orlan brief (the topics, files and requests your token can reach)",
|
|
37
|
+
ref_ambiguous: "give the id from the list above, or add --topic <topic>",
|
|
38
|
+
input_invalid: "fix the input above; `orlan <command> --help` shows the options",
|
|
39
|
+
token_invalid: "orlan auth login, and pick topics for the agent",
|
|
40
|
+
login_denied: "orlan auth login",
|
|
41
|
+
login_expired: "orlan auth login",
|
|
42
|
+
login_used: "orlan auth login",
|
|
43
|
+
login_rate_limited: "wait 10 minutes, then orlan auth login",
|
|
44
|
+
org_suspended: "ask an admin of the organisation to contact Orlan",
|
|
45
|
+
topic_missing: "add --topic <topic>: orlan topics list shows them",
|
|
46
|
+
file_too_large: "make the file smaller, then run the command again",
|
|
47
|
+
file_missing: "give a file that is not empty",
|
|
48
|
+
name_invalid: "give the file a name with its extension, for example deck.pptx",
|
|
49
|
+
board_full: "ask a person to make room on the board",
|
|
50
|
+
stale_base: "orlan files pull <file> --edit, make your change on the newer version, then post it again",
|
|
51
|
+
base_invalid: "give --base a version of the file: orlan files list shows them",
|
|
52
|
+
request_done: "orlan wait (the next request)",
|
|
53
|
+
request_stopped: "stop the work on this request, and tell your person that a person stopped it on Orlan",
|
|
54
|
+
file_target_missing: "add --to <file>",
|
|
55
|
+
file_target_ambiguous: "add --to <file>",
|
|
56
|
+
upload_invalid: "run the command again: it sends the file again",
|
|
57
|
+
upload_expired: "run the command again: it sends the file again",
|
|
58
|
+
mention_invalid: "write the comment without that mention",
|
|
59
|
+
plan_limit_agents: "orlan mcp disconnect --agent <id>, or ask an admin to move the organisation to Team",
|
|
60
|
+
guest_account_agent_limit: "orlan mcp disconnect --agent <id>, or add your email in Orlan",
|
|
61
|
+
guest_account_file_limit: "add your email in Orlan (the bar at the top)",
|
|
62
|
+
internal_error: "run the command again; when it fails again, tell your person",
|
|
63
|
+
};
|
|
64
|
+
export async function apiCall(server, pathname, init = {}) {
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetch(new URL(pathname, server), {
|
|
68
|
+
method: init.method ?? (init.body === undefined ? "GET" : "POST"),
|
|
69
|
+
headers: {
|
|
70
|
+
...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
|
|
71
|
+
...(init.body === undefined ? {} : { "content-type": "application/json" }),
|
|
72
|
+
},
|
|
73
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw unreachable(server, error);
|
|
78
|
+
}
|
|
79
|
+
const json = (await response.json().catch(() => undefined));
|
|
80
|
+
if (!response.ok)
|
|
81
|
+
throw apiError(response.status, json?.error);
|
|
82
|
+
return json;
|
|
83
|
+
}
|
|
84
|
+
/** The error of a server that did not answer. */
|
|
85
|
+
export function unreachable(server, error) {
|
|
86
|
+
return new OrlanError(`Orlan at ${server} did not answer: ${error.message}.`, undefined, "check the network and the server (orlan status), then run the command again");
|
|
87
|
+
}
|
|
88
|
+
/** The error of a refused API call, explained when the code has an explanation. */
|
|
89
|
+
export function apiError(status, code) {
|
|
90
|
+
return new OrlanError((code && explained[code]) ?? `Orlan answered ${status}${code ? ` (${code})` : ""}.`, code);
|
|
91
|
+
}
|
|
92
|
+
/** The error of an agent token that Orlan did not accept: revoked or replaced. */
|
|
93
|
+
export const agentTokenRefused = () => new OrlanError("Orlan did not accept the agent token. It was revoked or replaced.", "not_signed_in", "orlan mcp connect --agent <id>");
|
|
94
|
+
/**
|
|
95
|
+
* The stateless calls of the daily commands (F038): each is one plain HTTP request with the agent
|
|
96
|
+
* token. No MCP session: no initialize, no DELETE, no session row. Orlan writes each call in the
|
|
97
|
+
* audit log like any agent call. Human ids (#12, v5, a file or topic name) work as input.
|
|
98
|
+
*/
|
|
99
|
+
export class AgentApi {
|
|
100
|
+
server;
|
|
101
|
+
/** The agent token: the file downloads send it as their Bearer header too. */
|
|
102
|
+
token;
|
|
103
|
+
constructor(server, token) {
|
|
104
|
+
this.server = server;
|
|
105
|
+
this.token = token;
|
|
106
|
+
}
|
|
107
|
+
async send(url, init, what) {
|
|
108
|
+
let response;
|
|
109
|
+
try {
|
|
110
|
+
response = await fetch(url, { ...init, headers: { authorization: `Bearer ${this.token}`, ...init.headers } });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
throw unreachable(this.server, error);
|
|
114
|
+
}
|
|
115
|
+
if (response.status === 401)
|
|
116
|
+
throw agentTokenRefused();
|
|
117
|
+
const json = (await response.json().catch(() => undefined));
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
const code = json?.error;
|
|
120
|
+
// A detail can end with Orlan's own next_step line: that is the next step.
|
|
121
|
+
const [detail, next] = (json?.detail ?? "").split(/\n?next_step: /);
|
|
122
|
+
// With no detail, a known code has its explanation.
|
|
123
|
+
const message = !detail && code && explained[code]
|
|
124
|
+
? explained[code]
|
|
125
|
+
: `Orlan refused ${what}: ${code ? `${code}${detail ? `. ${detail}` : ""}` : `answered ${response.status}`}`;
|
|
126
|
+
throw new OrlanError(message, code, next || undefined);
|
|
127
|
+
}
|
|
128
|
+
return json;
|
|
129
|
+
}
|
|
130
|
+
/** Calls an agent tool with its input and returns its JSON answer. A refusal throws with its code. */
|
|
131
|
+
call(tool, input = {}) {
|
|
132
|
+
return this.send(new URL(`/api/agent/tools/${encodeURIComponent(tool)}`, this.server), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }, tool);
|
|
133
|
+
}
|
|
134
|
+
/** GET an agent route, with its query. */
|
|
135
|
+
get(pathname, query, what) {
|
|
136
|
+
return this.send(this.url(pathname, query), {}, what);
|
|
137
|
+
}
|
|
138
|
+
/** POST a local file as the raw body of an agent route, with its query. */
|
|
139
|
+
postFile(pathname, query, body, what) {
|
|
140
|
+
return this.send(this.url(pathname, query), { method: "POST", headers: { "content-type": "application/octet-stream" }, body: new Uint8Array(body) }, what);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Sends a local file in one request and returns its upload id, for post or respond. The
|
|
144
|
+
* file goes to the topic of the target: a file (a version of it), a request (its answer), or a topic.
|
|
145
|
+
*/
|
|
146
|
+
async upload(body, target, what = "the upload") {
|
|
147
|
+
return (await this.postFile("/api/agent/uploads", target, body, what)).uploadId;
|
|
148
|
+
}
|
|
149
|
+
url(pathname, query) {
|
|
150
|
+
const url = new URL(pathname, this.server);
|
|
151
|
+
for (const [key, value] of Object.entries(query))
|
|
152
|
+
if (value)
|
|
153
|
+
url.searchParams.set(key, value);
|
|
154
|
+
return url;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The orlan command (F026, decision D023). `orlan help` lists the commands; `orlan <command> --help`
|
|
4
|
+
* shows one.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { commands } from "./commands.js";
|
|
10
|
+
import { OrlanError } from "./http.js";
|
|
11
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
12
|
+
function overview() {
|
|
13
|
+
const width = Math.max(...Object.keys(commands).map((name) => name.length));
|
|
14
|
+
return [
|
|
15
|
+
"orlan - connect an agent to Orlan, and work with its topics, files and comments.",
|
|
16
|
+
"",
|
|
17
|
+
"Set up:",
|
|
18
|
+
" npm i -g @orlan-maker/cli",
|
|
19
|
+
" orlan auth login",
|
|
20
|
+
" orlan skills add --agent <id>",
|
|
21
|
+
" orlan mcp connect --agent <id>",
|
|
22
|
+
"",
|
|
23
|
+
"Commands:",
|
|
24
|
+
...Object.entries(commands).map(([name, command]) => ` ${name.padEnd(width)} ${command.summary}`),
|
|
25
|
+
"",
|
|
26
|
+
"Agents: claude-code, codex, cursor, opencode, other.",
|
|
27
|
+
"Run `orlan <command> --help` for the options of a command. `orlan --version` prints the version.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
function commandHelp(command) {
|
|
31
|
+
return [
|
|
32
|
+
`Usage: ${command.usage}`,
|
|
33
|
+
"",
|
|
34
|
+
command.summary,
|
|
35
|
+
...(command.details.length ? ["", ...command.details] : []),
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
/** Finds the command the arguments name: one word ("status") or two ("auth login"). */
|
|
39
|
+
function findCommand(args) {
|
|
40
|
+
const two = args.slice(0, 2).join(" ");
|
|
41
|
+
if (commands[two])
|
|
42
|
+
return { name: two, rest: args.slice(2) };
|
|
43
|
+
const one = args[0] ?? "";
|
|
44
|
+
if (commands[one])
|
|
45
|
+
return { name: one, rest: args.slice(1) };
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/** Runs the command line and returns the exit code. */
|
|
49
|
+
export async function main(args, io) {
|
|
50
|
+
if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] === "-h") {
|
|
51
|
+
const asked = args[0] === "help" ? findCommand(args.slice(1)) : undefined;
|
|
52
|
+
io.out(asked ? commandHelp(commands[asked.name]) : overview());
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
56
|
+
io.out(io.version);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
const found = findCommand(args);
|
|
60
|
+
if (!found) {
|
|
61
|
+
const group = Object.keys(commands).filter((name) => name.startsWith(`${args[0]} `));
|
|
62
|
+
process.stderr.write(group.length
|
|
63
|
+
? `orlan: "${args.join(" ")}" needs one of: ${group.join(", ")}.\nnext_step: orlan help\n`
|
|
64
|
+
: `orlan: there is no command "${args[0]}".\nnext_step: orlan help\n`);
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
const command = commands[found.name];
|
|
68
|
+
if (found.rest.includes("--help") || found.rest.includes("-h")) {
|
|
69
|
+
io.out(commandHelp(command));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
const help = `orlan ${found.name} --help`;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = parseArgs({ args: found.rest, options: command.options, allowPositionals: true, strict: true });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
process.stderr.write(`orlan: ${error.message}\nUsage: ${command.usage}\nnext_step: ${help}\n`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const [least, most] = command.positionals;
|
|
82
|
+
if (parsed.positionals.length < least || parsed.positionals.length > most) {
|
|
83
|
+
process.stderr.write(`orlan: wrong arguments.\nUsage: ${command.usage}\nnext_step: ${help}\n`);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return (await command.run(parsed.values, parsed.positionals, io)) ?? 0;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const missing = error.code === "ENOENT";
|
|
91
|
+
if (!(error instanceof OrlanError) && !missing)
|
|
92
|
+
throw error;
|
|
93
|
+
// Every error says what to do next (F038). With --json it is JSON, like the answer.
|
|
94
|
+
const next = (error instanceof OrlanError ? error.next : undefined) ??
|
|
95
|
+
(missing ? "check the path, then run the command again" : help);
|
|
96
|
+
const code = error instanceof OrlanError ? error.code : "file_missing";
|
|
97
|
+
if (parsed.values.json) {
|
|
98
|
+
io.out(JSON.stringify({ error: code ?? "failed", message: error.message, next_step: next }));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
process.stderr.write(`orlan: ${error.message}\nnext_step: ${next}\n`);
|
|
102
|
+
}
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Runs only as the program, not when a test imports main(). npm starts it through a link, so compare real paths.
|
|
107
|
+
const entry = process.argv[1];
|
|
108
|
+
if (entry && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
109
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
110
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
111
|
+
flushed: () => new Promise((resolve) => process.stdout.write("", () => resolve())),
|
|
112
|
+
version,
|
|
113
|
+
});
|
|
114
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small MCP client over Streamable HTTP, for the MCP test of `orlan status` and `orlan mcp connect`:
|
|
3
|
+
* it proves that the agent's MCP server works. The daily commands use plain HTTP (AgentApi, F038). A
|
|
4
|
+
* test is one MCP session of the agent token: it starts the session, calls its tool, and closes it. The
|
|
5
|
+
* client names itself "orlan-cli", so Orlan keeps these sessions off the Agents tab and sends no
|
|
6
|
+
* "session ended" notification for them. Every call is in the audit log like any agent call.
|
|
7
|
+
*/
|
|
8
|
+
import { OrlanError } from "./http.js";
|
|
9
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
10
|
+
/** The JSON-RPC answer with this id, from a JSON body or from a server-sent event stream. */
|
|
11
|
+
async function answerOf(response, id) {
|
|
12
|
+
const text = await response.text();
|
|
13
|
+
const type = response.headers.get("content-type") ?? "";
|
|
14
|
+
const messages = type.includes("text/event-stream")
|
|
15
|
+
? text
|
|
16
|
+
.split(/\r?\n\r?\n/)
|
|
17
|
+
.map((event) => event
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.filter((line) => line.startsWith("data:"))
|
|
20
|
+
.map((line) => line.slice(5).trimStart())
|
|
21
|
+
.join("\n"))
|
|
22
|
+
.filter((data) => data.length > 0)
|
|
23
|
+
.map((data) => JSON.parse(data))
|
|
24
|
+
: [JSON.parse(text)];
|
|
25
|
+
const answer = messages.find((message) => message.id === id);
|
|
26
|
+
if (!answer)
|
|
27
|
+
throw new OrlanError("Orlan's MCP server gave no answer.");
|
|
28
|
+
if (answer.error)
|
|
29
|
+
throw new OrlanError(`Orlan's MCP server refused the call: ${answer.error.message}`);
|
|
30
|
+
return answer;
|
|
31
|
+
}
|
|
32
|
+
export class McpClient {
|
|
33
|
+
sessionId;
|
|
34
|
+
nextId = 1;
|
|
35
|
+
url;
|
|
36
|
+
/** The agent token: the file downloads send it as their Bearer header too. */
|
|
37
|
+
token;
|
|
38
|
+
version;
|
|
39
|
+
constructor(server, token, version) {
|
|
40
|
+
this.url = new URL("/mcp", server);
|
|
41
|
+
this.token = token;
|
|
42
|
+
this.version = version;
|
|
43
|
+
}
|
|
44
|
+
async post(body) {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(this.url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
authorization: `Bearer ${this.token}`,
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
accept: "application/json, text/event-stream",
|
|
53
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId, "mcp-protocol-version": PROTOCOL_VERSION } : {}),
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new OrlanError(`Orlan's MCP server at ${this.url} did not answer: ${error.message}.`);
|
|
60
|
+
}
|
|
61
|
+
if (response.status === 401) {
|
|
62
|
+
throw new OrlanError("Orlan did not accept the agent token. It was revoked or replaced: run `orlan mcp connect` again.", "not_signed_in");
|
|
63
|
+
}
|
|
64
|
+
if (!response.ok && response.status !== 202) {
|
|
65
|
+
throw new OrlanError(`Orlan's MCP server answered ${response.status}.`);
|
|
66
|
+
}
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
async request(method, params) {
|
|
70
|
+
const id = this.nextId++;
|
|
71
|
+
const response = await this.post({ jsonrpc: "2.0", id, method, params });
|
|
72
|
+
if (method === "initialize")
|
|
73
|
+
this.sessionId = response.headers.get("mcp-session-id") ?? undefined;
|
|
74
|
+
return (await answerOf(response, id)).result;
|
|
75
|
+
}
|
|
76
|
+
async open() {
|
|
77
|
+
await this.request("initialize", {
|
|
78
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
79
|
+
capabilities: {},
|
|
80
|
+
clientInfo: { name: "orlan-cli", version: this.version },
|
|
81
|
+
});
|
|
82
|
+
if (!this.sessionId)
|
|
83
|
+
throw new OrlanError("Orlan's MCP server started no session.");
|
|
84
|
+
await (await this.post({ jsonrpc: "2.0", method: "notifications/initialized" })).text();
|
|
85
|
+
}
|
|
86
|
+
/** Calls a tool and returns its JSON answer. A refusal ("Refused: <code>") throws with that code. */
|
|
87
|
+
async call(tool, args = {}) {
|
|
88
|
+
if (!this.sessionId)
|
|
89
|
+
await this.open();
|
|
90
|
+
const result = (await this.request("tools/call", { name: tool, arguments: args }));
|
|
91
|
+
const text = result.content?.find((part) => part.type === "text")?.text ?? "";
|
|
92
|
+
if (result.isError) {
|
|
93
|
+
const code = /^Refused: ([a-z_]+)/.exec(text)?.[1];
|
|
94
|
+
throw new OrlanError(`Orlan refused ${tool}: ${text.replace(/^Refused: /, "")}`, code);
|
|
95
|
+
}
|
|
96
|
+
return JSON.parse(text);
|
|
97
|
+
}
|
|
98
|
+
/** Ends the session (HTTP DELETE). */
|
|
99
|
+
async close() {
|
|
100
|
+
if (!this.sessionId)
|
|
101
|
+
return;
|
|
102
|
+
const sessionId = this.sessionId;
|
|
103
|
+
this.sessionId = undefined;
|
|
104
|
+
await fetch(this.url, {
|
|
105
|
+
method: "DELETE",
|
|
106
|
+
headers: {
|
|
107
|
+
authorization: `Bearer ${this.token}`,
|
|
108
|
+
"mcp-session-id": sessionId,
|
|
109
|
+
"mcp-protocol-version": PROTOCOL_VERSION,
|
|
110
|
+
},
|
|
111
|
+
}).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI token and the agent tokens. They go to the OS keychain when there is one: the macOS
|
|
3
|
+
* keychain (`security`), or the Secret Service on Linux (`secret-tool`). Else, or with
|
|
4
|
+
* ORLAN_KEYCHAIN=off, to credentials.json in the config folder, readable by this user only (0600).
|
|
5
|
+
* A secret never goes on a command line: both keychain tools read it from standard input.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { configDir } from "./config.js";
|
|
11
|
+
const SERVICE = "orlan";
|
|
12
|
+
/** The account name of a secret: the server and what it is for, for example "https://orlan.app#cli". */
|
|
13
|
+
export const account = (server, name) => `${server}#${name}`;
|
|
14
|
+
function run(command, args, input) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
|
|
17
|
+
let stdout = "";
|
|
18
|
+
child.stdout.on("data", (chunk) => {
|
|
19
|
+
stdout += chunk.toString("utf8");
|
|
20
|
+
});
|
|
21
|
+
child.on("error", reject);
|
|
22
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout }));
|
|
23
|
+
child.stdin.end(input ?? "");
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Only these characters reach a keychain command, so no quoting can break. */
|
|
27
|
+
const safe = (value) => {
|
|
28
|
+
if (!/^[A-Za-z0-9_\-.:/#]+$/.test(value))
|
|
29
|
+
throw new Error(`Orlan cannot store "${value}" in the keychain.`);
|
|
30
|
+
return value;
|
|
31
|
+
};
|
|
32
|
+
const macKeychain = {
|
|
33
|
+
// `security -i` reads its commands from standard input, so the secret is not in the process list.
|
|
34
|
+
set: async (name, secret) => (await run("/usr/bin/security", ["-i"], `add-generic-password -U -s ${SERVICE} -a "${safe(name)}" -l "Orlan CLI" -w "${safe(secret)}"\n`)).code === 0,
|
|
35
|
+
get: async (name) => {
|
|
36
|
+
const found = await run("/usr/bin/security", ["find-generic-password", "-s", SERVICE, "-a", safe(name), "-w"]);
|
|
37
|
+
return found.code === 0 ? found.stdout.trim() || undefined : undefined;
|
|
38
|
+
},
|
|
39
|
+
delete: async (name) => {
|
|
40
|
+
await run("/usr/bin/security", ["delete-generic-password", "-s", SERVICE, "-a", safe(name)]);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const secretService = {
|
|
44
|
+
set: async (name, secret) => (await run("secret-tool", ["store", "--label=Orlan CLI", "service", SERVICE, "account", name], secret)).code === 0,
|
|
45
|
+
get: async (name) => {
|
|
46
|
+
const found = await run("secret-tool", ["lookup", "service", SERVICE, "account", name]);
|
|
47
|
+
return found.code === 0 ? found.stdout.trim() || undefined : undefined;
|
|
48
|
+
},
|
|
49
|
+
delete: async (name) => {
|
|
50
|
+
await run("secret-tool", ["clear", "service", SERVICE, "account", name]);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
function keychain() {
|
|
54
|
+
if (process.env.ORLAN_KEYCHAIN === "off")
|
|
55
|
+
return undefined;
|
|
56
|
+
if (process.platform === "darwin")
|
|
57
|
+
return macKeychain;
|
|
58
|
+
if (process.platform === "linux")
|
|
59
|
+
return secretService;
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
/** A keychain call that fails to start (no secret-tool, no D-Bus session) counts as no keychain. */
|
|
63
|
+
async function tryKeychain(work) {
|
|
64
|
+
const chain = keychain();
|
|
65
|
+
if (!chain)
|
|
66
|
+
return undefined;
|
|
67
|
+
try {
|
|
68
|
+
return await work(chain);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const credentialsFile = () => path.join(configDir(), "credentials.json");
|
|
75
|
+
async function readFileStore() {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(await readFile(credentialsFile(), "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code === "ENOENT")
|
|
81
|
+
return {};
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function writeFileStore(store) {
|
|
86
|
+
const file = credentialsFile();
|
|
87
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
88
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
89
|
+
await writeFile(temporary, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
await chmod(temporary, 0o600);
|
|
91
|
+
await rename(temporary, file);
|
|
92
|
+
}
|
|
93
|
+
/** Stores a secret. Returns where it went. */
|
|
94
|
+
export async function setSecret(name, secret) {
|
|
95
|
+
// Read back: `security -i` can end with 0 when its command failed.
|
|
96
|
+
const stored = await tryKeychain(async (chain) => (await chain.set(name, secret)) && (await chain.get(name)) === secret);
|
|
97
|
+
const store = await readFileStore();
|
|
98
|
+
if (stored) {
|
|
99
|
+
// An older copy in the file would outlive the keychain entry.
|
|
100
|
+
if (name in store) {
|
|
101
|
+
delete store[name];
|
|
102
|
+
await writeFileStore(store);
|
|
103
|
+
}
|
|
104
|
+
return "keychain";
|
|
105
|
+
}
|
|
106
|
+
store[name] = secret;
|
|
107
|
+
await writeFileStore(store);
|
|
108
|
+
return "file";
|
|
109
|
+
}
|
|
110
|
+
export async function getSecret(name) {
|
|
111
|
+
return (await tryKeychain((chain) => chain.get(name))) ?? (await readFileStore())[name];
|
|
112
|
+
}
|
|
113
|
+
export async function deleteSecret(name) {
|
|
114
|
+
await tryKeychain((chain) => chain.delete(name));
|
|
115
|
+
const store = await readFileStore();
|
|
116
|
+
if (name in store) {
|
|
117
|
+
delete store[name];
|
|
118
|
+
await writeFileStore(store);
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Orlan skills `orlan skills add` writes where an agent reads skills. Each is a SKILL.md in the
|
|
3
|
+
* package's skills/ folder, the same files the Claude Code plugin loads (F044).
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { skillsDir } from "./agents.js";
|
|
8
|
+
export const skillNames = ["orlan-review", "orlan-versions"];
|
|
9
|
+
/** Writes every Orlan skill for the agent. Returns the folders it wrote. */
|
|
10
|
+
export async function writeSkills(agent, cwd = process.cwd()) {
|
|
11
|
+
const root = skillsDir(agent, cwd);
|
|
12
|
+
const written = [];
|
|
13
|
+
for (const name of skillNames) {
|
|
14
|
+
const folder = path.join(root, name);
|
|
15
|
+
await mkdir(folder, { recursive: true });
|
|
16
|
+
await writeFile(path.join(folder, "SKILL.md"), await readFile(new URL(`../skills/${name}/SKILL.md`, import.meta.url), "utf8"));
|
|
17
|
+
written.push(folder);
|
|
18
|
+
}
|
|
19
|
+
return written;
|
|
20
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [{ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start", "args": [] }]
|
|
6
|
+
}
|
|
7
|
+
],
|
|
8
|
+
"UserPromptSubmit": [
|
|
9
|
+
{
|
|
10
|
+
"hooks": [
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/bin/orlan",
|
|
14
|
+
"args": ["hook", "working", "--agent", "claude-code"],
|
|
15
|
+
"async": true
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"PostToolUse": [
|
|
21
|
+
{
|
|
22
|
+
"hooks": [
|
|
23
|
+
{
|
|
24
|
+
"type": "command",
|
|
25
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/bin/orlan",
|
|
26
|
+
"args": ["hook", "working", "--agent", "claude-code"],
|
|
27
|
+
"async": true
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"Notification": [
|
|
33
|
+
{
|
|
34
|
+
"matcher": "permission_prompt|agent_needs_input",
|
|
35
|
+
"hooks": [
|
|
36
|
+
{
|
|
37
|
+
"type": "command",
|
|
38
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/bin/orlan",
|
|
39
|
+
"args": ["hook", "needs-input", "--agent", "claude-code"]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"Stop": [
|
|
45
|
+
{
|
|
46
|
+
"hooks": [
|
|
47
|
+
{
|
|
48
|
+
"type": "command",
|
|
49
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/bin/orlan",
|
|
50
|
+
"args": ["hook", "idle", "--agent", "claude-code"]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"SessionEnd": [
|
|
56
|
+
{
|
|
57
|
+
"hooks": [
|
|
58
|
+
{
|
|
59
|
+
"type": "command",
|
|
60
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/bin/orlan",
|
|
61
|
+
"args": ["hook", "done", "--agent", "claude-code"],
|
|
62
|
+
"timeout": 5
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# SessionStart hook of the Orlan plugin (F044). The board shows the session as working, and the brief
|
|
3
|
+
# goes into the session's context (the output of a SessionStart hook does). An error of the brief goes
|
|
4
|
+
# there too, for example "No agent is connected", so the agent can tell the person what to do.
|
|
5
|
+
orlan="$CLAUDE_PLUGIN_ROOT/bin/orlan"
|
|
6
|
+
"$orlan" hook working --agent claude-code
|
|
7
|
+
if "$orlan" brief --agent claude-code 2>&1; then
|
|
8
|
+
echo "The Orlan plugin's monitor runs orlan wait --follow for this session: each request comes as a notification. Do not run orlan wait yourself."
|
|
9
|
+
echo "Answer a request with the orlan command in few steps: orlan files pull <file id> --out <path> && cat <path>; edit the file; orlan respond <request id> --file <path> [--to <file id>] --message \"<what changed>\"."
|
|
10
|
+
fi
|
|
11
|
+
exit 0
|