@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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orlan",
|
|
3
|
+
"description": "The Orlan plugin for Claude Code.",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Orlan",
|
|
6
|
+
"url": "https://orlan.app"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "orlan",
|
|
11
|
+
"source": "./",
|
|
12
|
+
"description": "The Orlan review board in Claude Code: the orlan command, a monitor, hooks, skills and the MCP server."
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "orlan",
|
|
3
|
+
"displayName": "Orlan",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Work with the Orlan review board: the orlan command, a monitor that wakes the session when a person mentions the agent, hooks that show the session's state on the board, the skills, and the MCP server.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Orlan",
|
|
8
|
+
"url": "https://orlan.app"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://orlan.app",
|
|
11
|
+
"repository": "https://github.com/AliProgrammin/orlan-plugin",
|
|
12
|
+
"keywords": ["review", "board", "comments", "powerpoint", "pdf"],
|
|
13
|
+
"license": "MIT"
|
|
14
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Orlan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# @orlan-maker/cli
|
|
2
|
+
|
|
3
|
+
The `orlan` command connects an AI agent to [Orlan](https://orlan.app), the review board for decks,
|
|
4
|
+
PDFs, images and HTML. The agent then reads the comments on a file, posts a new version, replies and
|
|
5
|
+
resolves, from the terminal or over MCP.
|
|
6
|
+
|
|
7
|
+
Needs Node 22.18 or later.
|
|
8
|
+
|
|
9
|
+
## Set up
|
|
10
|
+
|
|
11
|
+
In Orlan, open the organisation menu, choose **Connect an agent**, and copy the prompt for your agent.
|
|
12
|
+
The prompt runs these four commands:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm i -g @orlan-maker/cli
|
|
16
|
+
orlan auth login
|
|
17
|
+
orlan skills add --agent claude-code
|
|
18
|
+
orlan mcp connect --agent claude-code
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- `orlan auth login` shows a code and opens the approval page in your browser. Sign in, check the
|
|
22
|
+
code, choose the organisation, and the topics and role your agents get. The command waits for the
|
|
23
|
+
approval (codes work for 10 minutes). The CLI token goes to the OS keychain (macOS keychain, or the
|
|
24
|
+
Secret Service on Linux), else to `credentials.json` in the config folder, readable by you only.
|
|
25
|
+
`--server <url>` signs in to another Orlan server; `--no-browser` only prints the address.
|
|
26
|
+
- `orlan skills add` writes the skills `orlan-review` and `orlan-versions` where the agent reads skills.
|
|
27
|
+
- `orlan mcp connect` makes an agent token and adds the Orlan MCP server to the agent. Only an admin of
|
|
28
|
+
the organisation connects agents. For Claude Code it also installs the Orlan plugin (below);
|
|
29
|
+
`--no-plugin` adds only the MCP server.
|
|
30
|
+
- `orlan status` shows who you are, the server, and each connected agent, and tests each agent's MCP
|
|
31
|
+
connection. It ends with exit code 1 when a check fails.
|
|
32
|
+
|
|
33
|
+
Agents: `claude-code`, `codex`, `cursor`, `opencode`, `other`.
|
|
34
|
+
|
|
35
|
+
| Agent | Skills | MCP entry |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| claude-code | `~/.claude/skills` | `claude mcp add --transport http --scope user orlan ...` |
|
|
38
|
+
| codex | `~/.agents/skills` | `[mcp_servers.orlan]` in `~/.codex/config.toml` |
|
|
39
|
+
| cursor | `~/.cursor/skills` | `mcpServers.orlan` in `~/.cursor/mcp.json` |
|
|
40
|
+
| opencode | `~/.config/opencode/skills` | `mcp.orlan` in `~/.config/opencode/opencode.json`, and the plugin in `~/.config/opencode/plugins/orlan.js` |
|
|
41
|
+
| other | `.agents/skills` in the folder you are in | `orlan mcp print` prints the address and the header |
|
|
42
|
+
|
|
43
|
+
Without `--agent`, the CLI finds the agent that runs it from its environment.
|
|
44
|
+
|
|
45
|
+
## The Claude Code plugin
|
|
46
|
+
|
|
47
|
+
This package is also the Claude Code plugin `orlan@orlan`. Its public marketplace is
|
|
48
|
+
https://github.com/AliProgrammin/orlan-plugin (MIT). Install it in two lines:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
claude plugin marketplace add AliProgrammin/orlan-plugin
|
|
52
|
+
claude plugin install orlan@orlan
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`orlan mcp connect --agent claude-code` installs it too; from a checkout,
|
|
56
|
+
`claude plugin marketplace add ./packages/cli` and `claude plugin install orlan@orlan` do the same. One install gives the session:
|
|
57
|
+
|
|
58
|
+
- `orlan` on the PATH of Claude Code's shell.
|
|
59
|
+
- A monitor that runs `orlan wait --follow`: a mention of the agent wakes the session, also when it is
|
|
60
|
+
idle. Monitors run only in an interactive session.
|
|
61
|
+
- Hooks that show the session on the board: working (session start, a prompt, each tool), waiting for
|
|
62
|
+
input (a permission prompt or a question in the terminal), idle (the turn ended), done (the session
|
|
63
|
+
ended). At the session start, `orlan brief` goes into the session's context.
|
|
64
|
+
- The skills `orlan-review` and `orlan-versions`.
|
|
65
|
+
- The Orlan MCP server. Its header comes from `orlan mcp headers`, with the token from the keychain. It
|
|
66
|
+
goes to `$ORLAN_SERVER`, else https://orlan.app: on another server, set `ORLAN_SERVER` where you start
|
|
67
|
+
Claude Code.
|
|
68
|
+
|
|
69
|
+
## The OpenCode plugin
|
|
70
|
+
|
|
71
|
+
`orlan mcp connect --agent opencode` also writes the Orlan plugin to `~/.config/opencode/plugins/orlan.js`.
|
|
72
|
+
When a session's turn ends, the plugin runs `orlan hook wait`. A mention of the agent on the board then goes
|
|
73
|
+
to the session as a new prompt, and a new turn starts. The plugin also shows the session on the board:
|
|
74
|
+
working, waiting for a permission prompt, idle, done. A request wakes a session only while OpenCode runs
|
|
75
|
+
(the TUI or `opencode serve`). Restart a running OpenCode to load the plugin. `orlan mcp disconnect
|
|
76
|
+
--agent opencode` removes it.
|
|
77
|
+
|
|
78
|
+
## Daily commands
|
|
79
|
+
|
|
80
|
+
Each works as the connected agent, with the agent's own token, and is in the topic's audit log. Each
|
|
81
|
+
is plain HTTP with the agent token, with no MCP session: `orlan files push` makes 2 requests (the file,
|
|
82
|
+
then the post), `orlan files pull` 2 (the file's answer, then the download), and most others 1.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
orlan brief [--topic <topic>]
|
|
86
|
+
orlan topics list
|
|
87
|
+
orlan files list --topic <topic>
|
|
88
|
+
orlan files add <path> [--topic <topic>]
|
|
89
|
+
orlan files pull <file> [--version <n>] [--topic <topic>] [--out <path>] [--edit [--shared] [--ttl <time>]]
|
|
90
|
+
orlan files push <file> <path> --changelog "<what changed>" [--base <n>] [--topic <topic>]
|
|
91
|
+
orlan comments list [--topic <topic>] [--status open|resolved|all] [--file <file>]
|
|
92
|
+
orlan comment <thread> "<text>" | --file <file> --page <n> [--region <x,y,w,h>] "<text>" | --at <x,y> "<text>" [--topic <topic>]
|
|
93
|
+
orlan comments resolve <thread> [--topic <topic>]
|
|
94
|
+
orlan open [<topic id>] [--file <file id>]
|
|
95
|
+
orlan wait [--follow]
|
|
96
|
+
orlan inbox [--new]
|
|
97
|
+
orlan inbox done <request id>
|
|
98
|
+
orlan respond <request id> --message "<text>" [--file <path>] [--to <file>] [--changelog "<text>"] [--base <n>] [--wait]
|
|
99
|
+
orlan hook <working|needs-input|idle|done>
|
|
100
|
+
orlan hook start|stop --agent codex|cursor
|
|
101
|
+
orlan mcp headers
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The ids people see work as input: a topic by its name, a file by its name, a thread as `#12`, and a
|
|
105
|
+
version as `v5` (or `5`). A name or a number is looked up on `--topic`, else on every topic of the
|
|
106
|
+
agent. When it matches more than one, the command stops with a plain error that lists each match with
|
|
107
|
+
its id and topic: give the id, or add `--topic`.
|
|
108
|
+
|
|
109
|
+
Every output ends with a `next_step` line, and every error says what to do next in a `next_step` line.
|
|
110
|
+
With `--json`, a command prints small JSON on one line with a `next_step` field; an error is JSON too:
|
|
111
|
+
`{"error": "<code>", "message": "...", "next_step": "..."}`. The hook commands and `orlan mcp headers`
|
|
112
|
+
print only what their harness reads, and `orlan inbox --new` prints nothing when there is no request.
|
|
113
|
+
|
|
114
|
+
`orlan brief` is for the start of a session. It prints the topic, the open requests, the files with
|
|
115
|
+
their current versions, and one `next_step` line, in about 500 tokens for 6 files and 3 requests. The
|
|
116
|
+
topic is the one the agent used last, else its only topic. `orlan files add` posts a new file to the
|
|
117
|
+
topic and puts it on the board, in one request. Both are plain HTTP calls with the agent token, with
|
|
118
|
+
no MCP session.
|
|
119
|
+
|
|
120
|
+
`orlan comment` replies in a thread, or opens a new thread: with `--file` and `--page` on a page (the
|
|
121
|
+
whole page, or `--region` as fractions of the page), with `--at` on a point of the board. People see it
|
|
122
|
+
on the board at once. "@" and a name mentions a person or an agent of the topic. A mention of another
|
|
123
|
+
agent makes no request when that agent still has an open request from you in the thread, or after 5 asks
|
|
124
|
+
this hour: the command prints "Not asked: <name>".
|
|
125
|
+
|
|
126
|
+
`orlan wait` waits for the next request to the agent: a person, a guest or an agent mentions it in a
|
|
127
|
+
comment. It prints the request with each comment, its file, version, page, region, the text under the
|
|
128
|
+
region and a crop link, and exits. Running it again after a lost connection is safe: the request comes
|
|
129
|
+
once. The wait confirms each request after it printed it; when the wait stops before that, the request
|
|
130
|
+
goes back to the inbox within 20 seconds. `--follow` stays open and prints one line per request. `orlan inbox` prints the open requests;
|
|
131
|
+
`--new` takes the new ones and prints nothing when there are none, for a stop hook. `orlan inbox done`
|
|
132
|
+
closes a request when its work is finished.
|
|
133
|
+
|
|
134
|
+
`orlan files pull --edit` puts the agent's claim on the file: other agents get it in their answer,
|
|
135
|
+
and the board shows "Claude (for Sara) is editing from v4 - 3 min" on the file. The claim warns and
|
|
136
|
+
blocks nothing. It lasts 15 minutes (`--ttl 90s`, `--ttl 1h`), and a push or respond of the agent ends
|
|
137
|
+
it. When another agent has a claim, the pull gives no claim and a `next_step`: wait for its version,
|
|
138
|
+
ask it with `orlan comment`, or take a shared claim with `--edit --shared`. A push or respond names its
|
|
139
|
+
base version: `--base`, else the version the pull got, else (respond) the version the comments were on.
|
|
140
|
+
When a newer version landed first, Orlan refuses it with `stale_base`, the newer changelog and a
|
|
141
|
+
`next_step`, and nothing changes.
|
|
142
|
+
|
|
143
|
+
`orlan respond` closes a review round in one command. With `--file`, it posts the file as a new version
|
|
144
|
+
of the file the request is about, with the version the comments were on as its base (`--to <file id>`
|
|
145
|
+
when the request names no file or several). Then it replies with the message in each thread of the
|
|
146
|
+
request, resolves the threads, and marks the request done. `--wait` then waits for the next request, as
|
|
147
|
+
`orlan wait` does. A request that is done already gives a refusal and a `next_step` line. To ask a
|
|
148
|
+
person a question, reply with `orlan comment` and leave the thread open: the answer comes to the
|
|
149
|
+
inbox as a request. A request that stays taken for 10 minutes with no done goes back to the inbox.
|
|
150
|
+
|
|
151
|
+
`orlan hook` is for harness hooks, not for the model: it tells the board that the agent works, waits
|
|
152
|
+
in the terminal (for example on a permission prompt), is idle or is done. It reads the hook's JSON on
|
|
153
|
+
standard input, makes one request, prints nothing and makes no model call. Orlan never answers the
|
|
154
|
+
terminal prompt; the board tells people to go to the terminal. With no agent connected, it does nothing.
|
|
155
|
+
|
|
156
|
+
`orlan hook start` and `orlan hook stop` are the session start and stop hooks of Codex and Cursor, and
|
|
157
|
+
`orlan mcp connect --agent codex|cursor` installs them. Codex and Cursor sessions do not wake when idle:
|
|
158
|
+
the start hook puts the brief in the session, and when a turn ends the stop hook gives the session the
|
|
159
|
+
new requests, so it works on at once. After 5 requests in a row it lets the session stop. Codex runs a
|
|
160
|
+
hook only after you trust it once with `/hooks`.
|
|
161
|
+
|
|
162
|
+
`orlan mcp headers` prints the MCP authorization header as JSON, for a `headersHelper`. The plugin's MCP
|
|
163
|
+
server runs it.
|
|
164
|
+
|
|
165
|
+
While a wait of the agent is open (the plugin's monitor), the `next_step` lines do not say `--wait`:
|
|
166
|
+
the open wait gets the next request.
|
|
167
|
+
|
|
168
|
+
A version the agent posts waits for a person to make it current. When several agents are connected,
|
|
169
|
+
add `--agent <id>`.
|
|
170
|
+
|
|
171
|
+
## Stop
|
|
172
|
+
|
|
173
|
+
- `orlan mcp disconnect --agent <id>` revokes the agent's token and removes its MCP entry.
|
|
174
|
+
- `orlan auth logout` revokes the CLI token. You can also revoke it in Orlan: Profile, Orlan CLI sign-ins.
|
|
175
|
+
|
|
176
|
+
Run `orlan help` for every command, and `orlan <command> --help` for its options.
|
|
177
|
+
|
|
178
|
+
Settings: `ORLAN_SERVER` names the server, `ORLAN_CONFIG_DIR` the config folder, `ORLAN_KEYCHAIN=off`
|
|
179
|
+
keeps secrets in the file, and `BROWSER` names the program that opens the approval page.
|
package/bin/orlan
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# The orlan command of the Claude Code plugin (F044). Claude Code puts this folder on the PATH of its
|
|
3
|
+
# shell. It runs the CLI of this package: the sources in a checkout, else the build the npm package has.
|
|
4
|
+
root=$(cd "$(dirname "$0")/.." && pwd)
|
|
5
|
+
if [ -f "$root/src/main.ts" ]; then
|
|
6
|
+
exec node "$root/src/main.ts" "$@"
|
|
7
|
+
fi
|
|
8
|
+
exec node "$root/dist/main.js" "$@"
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agents the CLI connects: where each one reads skills, and how its MCP settings get the Orlan
|
|
3
|
+
* server. The ids match the web app's connect dialog (agentClients in @orlan/shared).
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { OrlanError } from "./http.js";
|
|
11
|
+
export const agentIds = ["claude-code", "codex", "cursor", "opencode", "other"];
|
|
12
|
+
export const agentLabels = {
|
|
13
|
+
"claude-code": "Claude Code",
|
|
14
|
+
codex: "Codex",
|
|
15
|
+
cursor: "Cursor",
|
|
16
|
+
opencode: "OpenCode",
|
|
17
|
+
other: "your agent",
|
|
18
|
+
};
|
|
19
|
+
/** The MCP server name in each agent's settings. */
|
|
20
|
+
export const MCP_NAME = "orlan";
|
|
21
|
+
export function parseAgent(value) {
|
|
22
|
+
if (agentIds.includes(value))
|
|
23
|
+
return value;
|
|
24
|
+
throw new OrlanError(`"${value}" is not an agent Orlan knows. Use one of: ${agentIds.join(", ")}.`);
|
|
25
|
+
}
|
|
26
|
+
/** Environment variables each agent sets for the commands it runs. */
|
|
27
|
+
const markers = [
|
|
28
|
+
["claude-code", ["CLAUDECODE"]],
|
|
29
|
+
["codex", ["CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID"]],
|
|
30
|
+
["opencode", ["OPENCODE"]],
|
|
31
|
+
["cursor", ["CURSOR_AGENT", "CURSOR_TRACE_ID"]],
|
|
32
|
+
];
|
|
33
|
+
/** The agent that runs this command, from its environment, or undefined. */
|
|
34
|
+
export function detectAgent(env = process.env) {
|
|
35
|
+
return markers.find(([, names]) => names.some((name) => env[name]))?.[0];
|
|
36
|
+
}
|
|
37
|
+
const home = () => homedir();
|
|
38
|
+
const xdgConfig = () => process.env.XDG_CONFIG_HOME || path.join(home(), ".config");
|
|
39
|
+
/**
|
|
40
|
+
* The folder each agent reads skills from. "other" gets them in the project folder. Checked against
|
|
41
|
+
* each tool's documentation on 2026-09-24 (B018):
|
|
42
|
+
* - Codex: user skills in $HOME/.agents/skills - https://learn.chatgpt.com/docs/build-skills
|
|
43
|
+
* (was https://developers.openai.com/codex/skills)
|
|
44
|
+
* - Cursor: user skills in ~/.cursor/skills (it reads ~/.agents/skills too) -
|
|
45
|
+
* https://cursor.com/docs/context/skills
|
|
46
|
+
* - OpenCode: global skills in ~/.config/opencode/skills/<name>/SKILL.md -
|
|
47
|
+
* https://opencode.ai/docs/skills/
|
|
48
|
+
*/
|
|
49
|
+
export function skillsDir(agent, cwd = process.cwd()) {
|
|
50
|
+
switch (agent) {
|
|
51
|
+
case "claude-code":
|
|
52
|
+
return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(home(), ".claude"), "skills");
|
|
53
|
+
case "codex":
|
|
54
|
+
return path.join(home(), ".agents", "skills");
|
|
55
|
+
case "cursor":
|
|
56
|
+
return path.join(home(), ".cursor", "skills");
|
|
57
|
+
case "opencode":
|
|
58
|
+
return path.join(xdgConfig(), "opencode", "skills");
|
|
59
|
+
case "other":
|
|
60
|
+
return path.join(cwd, ".agents", "skills");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The MCP settings file of an agent that has one the CLI edits. Checked against each tool's
|
|
65
|
+
* documentation on 2026-09-24 (B018):
|
|
66
|
+
* - Codex: `[mcp_servers.<name>]` with `url` and `http_headers` in ~/.codex/config.toml -
|
|
67
|
+
* https://learn.chatgpt.com/docs/extend/mcp?surface=cli (was https://developers.openai.com/codex/mcp)
|
|
68
|
+
* - Cursor: `mcpServers.<name>` with `url` and `headers` in ~/.cursor/mcp.json (the editor and the
|
|
69
|
+
* cursor-agent CLI read it) - https://cursor.com/docs/context/mcp and https://cursor.com/docs/cli/mcp
|
|
70
|
+
* - OpenCode: `mcp.<name>` with `type: "remote"`, `url`, `headers` and `enabled` in
|
|
71
|
+
* ~/.config/opencode/opencode.json - https://opencode.ai/docs/mcp-servers/ and
|
|
72
|
+
* https://opencode.ai/docs/config/
|
|
73
|
+
*/
|
|
74
|
+
export function mcpFile(agent) {
|
|
75
|
+
switch (agent) {
|
|
76
|
+
case "codex":
|
|
77
|
+
return path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "config.toml");
|
|
78
|
+
case "cursor":
|
|
79
|
+
return path.join(home(), ".cursor", "mcp.json");
|
|
80
|
+
case "opencode":
|
|
81
|
+
return path.join(xdgConfig(), "opencode", "opencode.json");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function readText(file) {
|
|
85
|
+
try {
|
|
86
|
+
return await readFile(file, "utf8");
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === "ENOENT")
|
|
90
|
+
return "";
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Writes the file at once. The file holds a secret, so only this user reads it. */
|
|
95
|
+
async function writeText(file, text) {
|
|
96
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
97
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
98
|
+
await writeFile(temporary, text, { mode: 0o600 });
|
|
99
|
+
await rename(temporary, file);
|
|
100
|
+
}
|
|
101
|
+
/** A TOML table header line such as `[mcp_servers.orlan]` or `[[x]]`, with its dotted name. */
|
|
102
|
+
const tableHeader = (line) => /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/.exec(line)?.[1];
|
|
103
|
+
/**
|
|
104
|
+
* The Codex config.toml without the `[mcp_servers.orlan]` table and its sub-tables, and with the new
|
|
105
|
+
* one when `entry` is given. Every other line stays as it was, comments too.
|
|
106
|
+
*/
|
|
107
|
+
export function editCodexToml(text, entry) {
|
|
108
|
+
const lines = text.split("\n");
|
|
109
|
+
const kept = [];
|
|
110
|
+
let inOrlan = false;
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const header = tableHeader(line);
|
|
113
|
+
if (header !== undefined) {
|
|
114
|
+
const name = header.replace(/\s/g, "").replace(/"/g, "");
|
|
115
|
+
inOrlan = name === `mcp_servers.${MCP_NAME}` || name.startsWith(`mcp_servers.${MCP_NAME}.`);
|
|
116
|
+
}
|
|
117
|
+
if (!inOrlan)
|
|
118
|
+
kept.push(line);
|
|
119
|
+
}
|
|
120
|
+
let result = kept.join("\n").replace(/\n+$/, "");
|
|
121
|
+
if (entry) {
|
|
122
|
+
const table = [
|
|
123
|
+
`[mcp_servers.${MCP_NAME}]`,
|
|
124
|
+
`url = ${JSON.stringify(entry.url)}`,
|
|
125
|
+
`http_headers = { "Authorization" = ${JSON.stringify(entry.authorization)} }`,
|
|
126
|
+
].join("\n");
|
|
127
|
+
result = result ? `${result}\n\n${table}` : table;
|
|
128
|
+
}
|
|
129
|
+
return result ? `${result}\n` : "";
|
|
130
|
+
}
|
|
131
|
+
/** Sets or removes one key in a JSON settings file, under `section`. */
|
|
132
|
+
async function editJson(file, section, value) {
|
|
133
|
+
const text = await readText(file);
|
|
134
|
+
let settings;
|
|
135
|
+
try {
|
|
136
|
+
settings = text.trim() ? JSON.parse(text) : {};
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new OrlanError(`${file} is not plain JSON (it has comments or an error), so Orlan does not change it. ` +
|
|
140
|
+
"Run `orlan mcp print` and add the entry by hand.");
|
|
141
|
+
}
|
|
142
|
+
const servers = { ...(settings[section] ?? {}) };
|
|
143
|
+
if (value === undefined) {
|
|
144
|
+
if (!(MCP_NAME in servers))
|
|
145
|
+
return;
|
|
146
|
+
delete servers[MCP_NAME];
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
servers[MCP_NAME] = value;
|
|
150
|
+
}
|
|
151
|
+
settings[section] = servers;
|
|
152
|
+
await writeText(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
153
|
+
}
|
|
154
|
+
function runClaude(args) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
execFile("claude", args, { timeout: 60_000 }, (error, stdout, stderr) => {
|
|
157
|
+
if (error && error.code === "ENOENT") {
|
|
158
|
+
reject(new OrlanError("Orlan cannot find the `claude` command. Install Claude Code first."));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
resolve({ ok: !error, output: `${stdout}${stderr}`.trim() });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The Claude Code plugin (F044). This package is the plugin and its own marketplace: the folder with
|
|
167
|
+
* package.json has .claude-plugin/plugin.json and .claude-plugin/marketplace.json. A local marketplace
|
|
168
|
+
* loads the plugin in place, so an update of the package updates the plugin.
|
|
169
|
+
*/
|
|
170
|
+
export const CLAUDE_PLUGIN = "orlan@orlan";
|
|
171
|
+
const packageRoot = () => fileURLToPath(new URL("..", import.meta.url));
|
|
172
|
+
/** True when the Orlan plugin is installed and enabled in Claude Code. */
|
|
173
|
+
async function claudePluginEnabled() {
|
|
174
|
+
const listed = await runClaude(["plugin", "list", "--json"]);
|
|
175
|
+
if (!listed.ok)
|
|
176
|
+
return false;
|
|
177
|
+
try {
|
|
178
|
+
const plugins = JSON.parse(listed.output);
|
|
179
|
+
return Array.isArray(plugins) && plugins.some((plugin) => plugin.id === CLAUDE_PLUGIN && plugin.enabled);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Installs the Orlan plugin in Claude Code for this user, from this package, when it is not enabled
|
|
187
|
+
* yet. Returns false when Claude Code refused the install.
|
|
188
|
+
*/
|
|
189
|
+
export async function installClaudePlugin() {
|
|
190
|
+
if (await claudePluginEnabled())
|
|
191
|
+
return true;
|
|
192
|
+
await runClaude(["plugin", "marketplace", "add", packageRoot(), "--scope", "user"]);
|
|
193
|
+
const installed = await runClaude(["plugin", "install", CLAUDE_PLUGIN, "--scope", "user"]);
|
|
194
|
+
return installed.ok && (await claudePluginEnabled());
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The hooks file of Codex and Cursor (F046). Checked against each tool's documentation on 2026-09-26:
|
|
198
|
+
* - Codex: $CODEX_HOME/hooks.json (default ~/.codex), events SessionStart, UserPromptSubmit,
|
|
199
|
+
* PermissionRequest, PostToolUse and Stop. A person trusts each hook once with /hooks, and Codex keeps
|
|
200
|
+
* the trust by file, event and position - https://learn.chatgpt.com/docs/hooks
|
|
201
|
+
* - Cursor: ~/.cursor/hooks.json with `version: 1`, events sessionStart, beforeSubmitPrompt, stop and
|
|
202
|
+
* sessionEnd. The editor and the cursor-agent CLI read it - https://cursor.com/docs/hooks
|
|
203
|
+
*/
|
|
204
|
+
export function hooksFile(agent) {
|
|
205
|
+
return agent === "codex"
|
|
206
|
+
? path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "hooks.json")
|
|
207
|
+
: path.join(home(), ".cursor", "hooks.json");
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* How many requests in a row a stop hook gives one session before it lets the session stop. Codex has
|
|
211
|
+
* no limit of its own. Cursor drops a hook's follow-up at its loop_limit, 5 by default, which Orlan
|
|
212
|
+
* writes too. Orlan takes no request past this limit, so it never takes a request the harness drops.
|
|
213
|
+
*/
|
|
214
|
+
export const STOP_LOOP_LIMIT = 5;
|
|
215
|
+
/** A hook command Orlan wrote ends with `hook <state> --agent codex` or `--agent cursor`. */
|
|
216
|
+
const orlanHookCommand = /\shook (start|stop|working|needs-input|idle|done) --agent (codex|cursor)$/;
|
|
217
|
+
/**
|
|
218
|
+
* The command a hook runs: this Node and this CLI by full path, because a harness started from the
|
|
219
|
+
* desktop (Cursor, the Codex app) often has no `orlan` or `node` on its PATH.
|
|
220
|
+
*/
|
|
221
|
+
function hookCommand(args) {
|
|
222
|
+
return `${JSON.stringify(process.execPath)} ${JSON.stringify(cliMain())} hook ${args}`;
|
|
223
|
+
}
|
|
224
|
+
/** The main file of this CLI: src/main.ts in a checkout, dist/main.js in the npm package. */
|
|
225
|
+
function cliMain() {
|
|
226
|
+
const source = fileURLToPath(import.meta.url);
|
|
227
|
+
return path.join(path.dirname(source), `main${path.extname(source)}`);
|
|
228
|
+
}
|
|
229
|
+
/** The Orlan hooks of each harness, by event, in the shape of its hooks.json. */
|
|
230
|
+
function orlanHooks(agent) {
|
|
231
|
+
if (agent === "codex") {
|
|
232
|
+
const entry = (args, timeout, statusMessage) => ({
|
|
233
|
+
hooks: [
|
|
234
|
+
{
|
|
235
|
+
type: "command",
|
|
236
|
+
command: hookCommand(`${args} --agent codex`),
|
|
237
|
+
timeout,
|
|
238
|
+
...(statusMessage ? { statusMessage } : {}),
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
SessionStart: entry("start", 10, "Reading the Orlan brief"),
|
|
244
|
+
UserPromptSubmit: entry("working", 5),
|
|
245
|
+
PermissionRequest: entry("needs-input", 5),
|
|
246
|
+
PostToolUse: entry("working", 5),
|
|
247
|
+
Stop: entry("stop", 10, "Checking Orlan for requests"),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const entry = (args, timeout, more = {}) => ({
|
|
251
|
+
command: hookCommand(`${args} --agent cursor`),
|
|
252
|
+
timeout,
|
|
253
|
+
...more,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
sessionStart: entry("start", 10),
|
|
257
|
+
beforeSubmitPrompt: entry("working", 5),
|
|
258
|
+
stop: entry("stop", 10, { loop_limit: STOP_LOOP_LIMIT }),
|
|
259
|
+
sessionEnd: entry("done", 5),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** True for a hook entry Orlan wrote: a Cursor entry with an Orlan command, or a Codex group with one. */
|
|
263
|
+
function isOrlanHook(entry) {
|
|
264
|
+
if (!entry || typeof entry !== "object")
|
|
265
|
+
return false;
|
|
266
|
+
const { command, hooks } = entry;
|
|
267
|
+
if (typeof command === "string")
|
|
268
|
+
return orlanHookCommand.test(command);
|
|
269
|
+
return Array.isArray(hooks) && hooks.some(isOrlanHook);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The hooks.json settings with the Orlan hooks of `add` in place of the old ones, or with no Orlan
|
|
273
|
+
* hooks when `add` is undefined. Every other hook stays at its place, and an Orlan hook goes back to
|
|
274
|
+
* its place: Codex keeps a person's trust by the position of the hook.
|
|
275
|
+
*/
|
|
276
|
+
export function editHooks(settings, add) {
|
|
277
|
+
const events = { ...(settings.hooks ?? {}) };
|
|
278
|
+
for (const event of new Set([...Object.keys(events), ...Object.keys(add ?? {})])) {
|
|
279
|
+
const current = events[event] ?? [];
|
|
280
|
+
// Not a list of hooks: not a shape Orlan writes, so it stays as it is.
|
|
281
|
+
if (!Array.isArray(current))
|
|
282
|
+
continue;
|
|
283
|
+
const at = current.findIndex(isOrlanHook);
|
|
284
|
+
const kept = current.filter((entry) => !isOrlanHook(entry));
|
|
285
|
+
const mine = add?.[event];
|
|
286
|
+
if (mine)
|
|
287
|
+
kept.splice(at === -1 ? kept.length : at, 0, mine);
|
|
288
|
+
if (kept.length > 0)
|
|
289
|
+
events[event] = kept;
|
|
290
|
+
else
|
|
291
|
+
delete events[event];
|
|
292
|
+
}
|
|
293
|
+
return { ...settings, hooks: events };
|
|
294
|
+
}
|
|
295
|
+
/** Writes the Orlan hooks into the harness's hooks.json, or takes them out. Returns the file. */
|
|
296
|
+
export async function setHooks(agent, install) {
|
|
297
|
+
const file = hooksFile(agent);
|
|
298
|
+
const text = await readText(file);
|
|
299
|
+
if (!text.trim() && !install)
|
|
300
|
+
return file;
|
|
301
|
+
let settings;
|
|
302
|
+
try {
|
|
303
|
+
settings = text.trim() ? JSON.parse(text) : {};
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
throw new OrlanError(`${file} is not plain JSON, so Orlan does not change it. Correct the file, then run this again.`);
|
|
307
|
+
}
|
|
308
|
+
const edited = editHooks(settings, install ? orlanHooks(agent) : undefined);
|
|
309
|
+
await writeText(file, `${JSON.stringify(agent === "cursor" ? { version: 1, ...edited } : edited, null, 2)}\n`);
|
|
310
|
+
return file;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The Orlan plugin of OpenCode (F045), in its global plugins folder: ~/.config/opencode/plugins/ -
|
|
314
|
+
* https://opencode.ai/docs/plugins/ (checked 2026-09-27 against OpenCode 1.18.30).
|
|
315
|
+
*/
|
|
316
|
+
export function opencodePluginFile() {
|
|
317
|
+
return path.join(xdgConfig(), "opencode", "plugins", "orlan.js");
|
|
318
|
+
}
|
|
319
|
+
/** The first line of the plugin, so a disconnect removes only a file Orlan wrote. */
|
|
320
|
+
const OPENCODE_PLUGIN_MARK = "// The Orlan plugin for OpenCode (F045).";
|
|
321
|
+
/**
|
|
322
|
+
* The plugin as `orlan mcp connect` writes it: opencode/orlan.js of this package, with this Node and
|
|
323
|
+
* this CLI by full path in place of `orlan` on the PATH.
|
|
324
|
+
*/
|
|
325
|
+
export async function opencodePlugin() {
|
|
326
|
+
const source = await readFile(path.join(packageRoot(), "opencode", "orlan.js"), "utf8");
|
|
327
|
+
const line = 'const ORLAN = ["orlan"];';
|
|
328
|
+
if (!source.startsWith(OPENCODE_PLUGIN_MARK) || !source.includes(line)) {
|
|
329
|
+
throw new OrlanError("The OpenCode plugin of this orlan package is damaged. Install @orlan-maker/cli again.");
|
|
330
|
+
}
|
|
331
|
+
return source.replace(line, `const ORLAN = ${JSON.stringify([process.execPath, cliMain()])};`);
|
|
332
|
+
}
|
|
333
|
+
/** Writes the Orlan plugin into OpenCode's plugins folder, or takes it out. Returns the file. */
|
|
334
|
+
export async function setOpencodePlugin(install) {
|
|
335
|
+
const file = opencodePluginFile();
|
|
336
|
+
if (install) {
|
|
337
|
+
await writeText(file, await opencodePlugin());
|
|
338
|
+
}
|
|
339
|
+
else if ((await readText(file)).startsWith(OPENCODE_PLUGIN_MARK)) {
|
|
340
|
+
await rm(file, { force: true });
|
|
341
|
+
}
|
|
342
|
+
return file;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Writes the Orlan MCP server into the agent's settings, in place of an older entry. Returns what it
|
|
346
|
+
* changed, for the person.
|
|
347
|
+
*/
|
|
348
|
+
export async function addMcp(agent, url, secret) {
|
|
349
|
+
const authorization = `Bearer ${secret}`;
|
|
350
|
+
switch (agent) {
|
|
351
|
+
case "claude-code": {
|
|
352
|
+
await runClaude(["mcp", "remove", MCP_NAME, "--scope", "user"]);
|
|
353
|
+
const added = await runClaude([
|
|
354
|
+
"mcp",
|
|
355
|
+
"add",
|
|
356
|
+
"--transport",
|
|
357
|
+
"http",
|
|
358
|
+
"--scope",
|
|
359
|
+
"user",
|
|
360
|
+
MCP_NAME,
|
|
361
|
+
url,
|
|
362
|
+
"--header",
|
|
363
|
+
`Authorization: ${authorization}`,
|
|
364
|
+
]);
|
|
365
|
+
if (!added.ok)
|
|
366
|
+
throw new OrlanError(`claude mcp add failed: ${added.output.replaceAll(secret, "orl_...")}`);
|
|
367
|
+
return "Claude Code (claude mcp add --scope user)";
|
|
368
|
+
}
|
|
369
|
+
case "codex": {
|
|
370
|
+
const file = mcpFile("codex");
|
|
371
|
+
await writeText(file, editCodexToml(await readText(file), { url, authorization }));
|
|
372
|
+
return file;
|
|
373
|
+
}
|
|
374
|
+
case "cursor": {
|
|
375
|
+
const file = mcpFile("cursor");
|
|
376
|
+
await editJson(file, "mcpServers", { url, headers: { Authorization: authorization } });
|
|
377
|
+
return file;
|
|
378
|
+
}
|
|
379
|
+
case "opencode": {
|
|
380
|
+
const file = mcpFile("opencode");
|
|
381
|
+
await editJson(file, "mcp", { type: "remote", url, headers: { Authorization: authorization }, enabled: true });
|
|
382
|
+
return file;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** Takes the Orlan MCP server out of the agent's settings. */
|
|
387
|
+
export async function removeMcp(agent) {
|
|
388
|
+
switch (agent) {
|
|
389
|
+
case "claude-code":
|
|
390
|
+
await runClaude(["mcp", "remove", MCP_NAME, "--scope", "user"]);
|
|
391
|
+
return;
|
|
392
|
+
case "codex": {
|
|
393
|
+
const file = mcpFile("codex");
|
|
394
|
+
const text = await readText(file);
|
|
395
|
+
if (text)
|
|
396
|
+
await writeText(file, editCodexToml(text));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
case "cursor":
|
|
400
|
+
await editJson(mcpFile("cursor"), "mcpServers", undefined);
|
|
401
|
+
return;
|
|
402
|
+
case "opencode":
|
|
403
|
+
await editJson(mcpFile("opencode"), "mcp", undefined);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
}
|