@orlan-maker/cli 0.2.2 → 0.3.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/README.md +111 -95
- package/dist/agents.js +229 -214
- package/dist/commands.js +468 -669
- package/dist/http.js +93 -16
- package/dist/main.js +17 -3
- package/dist/skills.js +20 -4
- package/package.json +5 -6
- package/skills/orlan/SKILL.md +77 -12
- package/skills/orlan-review/SKILL.md +14 -28
- package/.claude-plugin/marketplace.json +0 -15
- package/.claude-plugin/plugin.json +0 -14
- package/bin/orlan +0 -8
- package/hooks/hooks.json +0 -68
- package/hooks/orlan-intro.txt +0 -1
- package/hooks/session-start +0 -14
- package/opencode/orlan.js +0 -121
package/dist/agents.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agents the CLI connects: where each one reads skills,
|
|
3
|
-
*
|
|
2
|
+
* The agents the CLI connects: where each one reads skills, and what an older CLI installed that a
|
|
3
|
+
* connect removes: the plugin, the hooks and the Orlan MCP entry (D088, D102). The ids match the web app's connect dialog (agentClients in
|
|
4
4
|
* @orlan/shared).
|
|
5
5
|
*/
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
10
|
-
import {
|
|
11
|
+
import { createScanner, findNodeAtLocation, parse as parseJson, parseTree, printParseErrorCode, } from "jsonc-parser";
|
|
11
12
|
import { OrlanError } from "./http.js";
|
|
12
13
|
export const agentIds = ["claude-code", "codex", "cursor", "opencode", "other"];
|
|
13
14
|
export const agentLabels = {
|
|
@@ -121,163 +122,182 @@ export function editCodexToml(text) {
|
|
|
121
122
|
return result ? `${result}\n` : "";
|
|
122
123
|
}
|
|
123
124
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
125
|
+
* The JSON settings files are JSON with comments (JSONC): OpenCode reads opencode.json so, and a person
|
|
126
|
+
* can write comments in hooks.json and mcp.json. jsonc-parser reads them. The edit takes out the text of
|
|
127
|
+
* one entry and its comma, and nothing else: every comment, the order and the format of the rest stay
|
|
128
|
+
* (B047). jsonc-parser's own modify() also changes the text around the entry.
|
|
126
129
|
*/
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
const PARSE_OPTIONS = { allowTrailingComma: true };
|
|
131
|
+
/** SyntaxKind.CommaToken of jsonc-parser: a const enum, which this build cannot import. */
|
|
132
|
+
const COMMA_TOKEN = 5;
|
|
133
|
+
/** The offset of the comma just after the node, comments and white space between them allowed, or -1. */
|
|
134
|
+
function commaAfter(text, node) {
|
|
135
|
+
const scanner = createScanner(text, true);
|
|
136
|
+
scanner.setPosition(node.offset + node.length);
|
|
137
|
+
return scanner.scan() === COMMA_TOKEN ? scanner.getTokenOffset() : -1;
|
|
138
|
+
}
|
|
139
|
+
/** The text without the entry at `where`: a key with its value, or an element of a list. */
|
|
140
|
+
function without(text, where) {
|
|
141
|
+
const root = parseTree(text, [], PARSE_OPTIONS);
|
|
142
|
+
const found = root && findNodeAtLocation(root, where);
|
|
143
|
+
const entry = found?.parent?.type === "property" ? found.parent : found;
|
|
144
|
+
const siblings = entry?.parent?.children;
|
|
145
|
+
if (!entry || !siblings)
|
|
146
|
+
return text;
|
|
147
|
+
let start = entry.offset;
|
|
148
|
+
let end = entry.offset + entry.length;
|
|
149
|
+
const cuts = [];
|
|
150
|
+
const comma = commaAfter(text, entry);
|
|
151
|
+
const index = siblings.indexOf(entry);
|
|
152
|
+
const previous = index > 0 ? siblings[index - 1] : undefined;
|
|
153
|
+
if (comma >= 0)
|
|
154
|
+
end = comma + 1;
|
|
155
|
+
else if (previous && commaAfter(text, previous) >= 0) {
|
|
156
|
+
// The last entry: the comma before it goes.
|
|
157
|
+
const before = commaAfter(text, previous);
|
|
158
|
+
cuts.push([before, before + 1]);
|
|
134
159
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
// An entry on lines of its own takes those lines with it.
|
|
161
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
|
162
|
+
const lineEnd = text.indexOf("\n", end);
|
|
163
|
+
const blank = (part) => part.trim() === "";
|
|
164
|
+
if (blank(text.slice(lineStart, start)) && blank(text.slice(end, lineEnd === -1 ? text.length : lineEnd))) {
|
|
165
|
+
start = lineStart;
|
|
166
|
+
end = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
138
167
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return false;
|
|
142
|
-
delete servers[MCP_NAME];
|
|
143
|
-
await writeText(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
144
|
-
return true;
|
|
168
|
+
cuts.push([start, end]);
|
|
169
|
+
return cuts.reduceRight((edited, [from, to]) => edited.slice(0, from) + edited.slice(to), text);
|
|
145
170
|
}
|
|
171
|
+
/** The value of a JSON settings file that can have comments, or {} when it is not an object. */
|
|
172
|
+
const parseJsonc = (text) => {
|
|
173
|
+
const value = parseJson(text, [], PARSE_OPTIONS);
|
|
174
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
175
|
+
};
|
|
176
|
+
/** The object at `key` of a value, or {} when it is not an object. */
|
|
177
|
+
const field = (value, key) => {
|
|
178
|
+
const found = value && typeof value === "object" ? value[key] : undefined;
|
|
179
|
+
return found && typeof found === "object" && !Array.isArray(found) ? found : {};
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Checks that a JSON settings file (comments are allowed) is an object Orlan can edit. Throws before
|
|
183
|
+
* anything changed, and says what to do by hand.
|
|
184
|
+
*/
|
|
185
|
+
function checkJsonc(file, text, byHand) {
|
|
186
|
+
const errors = [];
|
|
187
|
+
const value = parseJson(text, errors, PARSE_OPTIONS);
|
|
188
|
+
const [error] = errors;
|
|
189
|
+
if (!error && value && typeof value === "object" && !Array.isArray(value))
|
|
190
|
+
return;
|
|
191
|
+
const where = error
|
|
192
|
+
? ` (${printParseErrorCode(error.error)} on line ${text.slice(0, error.offset).split("\n").length})`
|
|
193
|
+
: "";
|
|
194
|
+
throw new OrlanError(`${file} is not a JSON object${where}, so Orlan changes no file. ${byHand}, then run this again.`);
|
|
195
|
+
}
|
|
196
|
+
/** The change that removes the Orlan key under `section` of a JSON settings file. Every other key stays. */
|
|
197
|
+
async function jsonKeyChange(file, section) {
|
|
198
|
+
const text = await readText(file);
|
|
199
|
+
if (!text.includes(`"${MCP_NAME}"`))
|
|
200
|
+
return undefined;
|
|
201
|
+
checkJsonc(file, text, `Remove the "${MCP_NAME}" entry under "${section}" by hand`);
|
|
202
|
+
if (!(MCP_NAME in field(parseJsonc(text), section)))
|
|
203
|
+
return undefined;
|
|
204
|
+
return { file, text: without(text, [section, MCP_NAME]), what: "the Orlan MCP entry" };
|
|
205
|
+
}
|
|
206
|
+
/** Runs the claude command. */
|
|
146
207
|
function runClaude(args) {
|
|
147
|
-
return new Promise((resolve
|
|
148
|
-
execFile("claude", args, { timeout: 60_000 }, (error, stdout, stderr) => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
resolve({ ok: !error, output: `${stdout}${stderr}`.trim() });
|
|
154
|
-
});
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
execFile("claude", args, { timeout: 60_000 }, (error, stdout, stderr) => resolve({
|
|
210
|
+
ok: !error,
|
|
211
|
+
missing: error?.code === "ENOENT",
|
|
212
|
+
output: `${stdout}${stderr}`.trim(),
|
|
213
|
+
}));
|
|
155
214
|
});
|
|
156
215
|
}
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
* package.json has .claude-plugin/plugin.json and .claude-plugin/marketplace.json. A local marketplace
|
|
160
|
-
* loads the plugin in place, so an update of the package updates the plugin.
|
|
161
|
-
*/
|
|
162
|
-
export const CLAUDE_PLUGIN = "orlan@orlan";
|
|
163
|
-
const packageRoot = () => fileURLToPath(new URL("..", import.meta.url));
|
|
164
|
-
/** True when the Orlan plugin is installed and enabled in Claude Code. */
|
|
165
|
-
async function claudePluginEnabled() {
|
|
166
|
-
const listed = await runClaude(["plugin", "list", "--json"]);
|
|
216
|
+
/** The JSON list a `claude ... --json` command printed, or [] when it failed. */
|
|
217
|
+
function claudeList(listed) {
|
|
167
218
|
if (!listed.ok)
|
|
168
|
-
return
|
|
219
|
+
return [];
|
|
169
220
|
try {
|
|
170
|
-
const
|
|
171
|
-
return Array.isArray(
|
|
221
|
+
const list = JSON.parse(listed.output);
|
|
222
|
+
return Array.isArray(list) ? list : [];
|
|
172
223
|
}
|
|
173
224
|
catch {
|
|
174
|
-
return
|
|
225
|
+
return [];
|
|
175
226
|
}
|
|
176
227
|
}
|
|
177
228
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
229
|
+
* The Claude Code plugin of CLI 0.1 and 0.2 (F044), and the local marketplace that `orlan connect` added
|
|
230
|
+
* for it: a marketplace "orlan" from a folder, the orlan package. A marketplace "orlan" from another
|
|
231
|
+
* source is not one Orlan added, so it stays.
|
|
232
|
+
*/
|
|
233
|
+
export const CLAUDE_PLUGIN = "orlan@orlan";
|
|
234
|
+
/** A marketplace entry of Claude Code that comes from a local folder. */
|
|
235
|
+
const fromFolder = (entry) => field(entry, "source").source === "directory";
|
|
236
|
+
/**
|
|
237
|
+
* With no claude command, the CLI cannot change Claude Code: the commands that remove what an older
|
|
238
|
+
* orlan installed, read from Claude Code's own files (B047). Empty when there is nothing.
|
|
180
239
|
*/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
240
|
+
async function claudeLeftovers() {
|
|
241
|
+
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(home(), ".claude");
|
|
242
|
+
const read = async (file) => parseJsonc(await readText(file));
|
|
243
|
+
const [settings, installed, known, user] = await Promise.all([
|
|
244
|
+
read(path.join(dir, "settings.json")),
|
|
245
|
+
read(path.join(dir, "plugins", "installed_plugins.json")),
|
|
246
|
+
read(path.join(dir, "plugins", "known_marketplaces.json")),
|
|
247
|
+
read(process.env.CLAUDE_CONFIG_DIR ? path.join(dir, ".claude.json") : path.join(home(), ".claude.json")),
|
|
248
|
+
]);
|
|
249
|
+
const commands = [];
|
|
250
|
+
const installs = field(installed, "plugins")[CLAUDE_PLUGIN];
|
|
251
|
+
if (CLAUDE_PLUGIN in field(settings, "enabledPlugins") || installs !== undefined) {
|
|
252
|
+
const scopes = new Set((Array.isArray(installs) ? installs : [{}]).map((each) => each?.scope ?? "user"));
|
|
253
|
+
for (const scope of scopes) {
|
|
254
|
+
commands.push(`claude plugin uninstall ${CLAUDE_PLUGIN}${scope === "user" ? "" : ` --scope ${scope}`}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (fromFolder(field(known, "orlan")) || fromFolder(field(field(settings, "extraKnownMarketplaces"), "orlan"))) {
|
|
258
|
+
commands.push("claude plugin marketplace remove orlan");
|
|
259
|
+
}
|
|
260
|
+
if (MCP_NAME in field(user, "mcpServers"))
|
|
261
|
+
commands.push(`claude mcp remove ${MCP_NAME} --scope user`);
|
|
262
|
+
return commands;
|
|
187
263
|
}
|
|
188
264
|
/**
|
|
189
|
-
* Takes the Orlan plugin
|
|
190
|
-
*
|
|
265
|
+
* Takes the old Orlan plugin, its local marketplace and the Orlan MCP entry out of Claude Code, with the
|
|
266
|
+
* claude command. With no claude command, it gives the commands to run by hand instead.
|
|
191
267
|
*/
|
|
192
|
-
|
|
193
|
-
await runClaude(["plugin", "
|
|
268
|
+
async function removeFromClaude() {
|
|
269
|
+
const listed = await runClaude(["plugin", "list", "--json"]);
|
|
270
|
+
if (listed.missing)
|
|
271
|
+
return { removed: [], byHand: await claudeLeftovers() };
|
|
272
|
+
const plugins = claudeList(listed);
|
|
273
|
+
const marketplaces = claudeList(await runClaude(["plugin", "marketplace", "list", "--json"]));
|
|
274
|
+
const removed = [];
|
|
275
|
+
for (const plugin of plugins.filter((each) => each.id === CLAUDE_PLUGIN)) {
|
|
276
|
+
const scope = plugin.scope ?? "user";
|
|
277
|
+
if ((await runClaude(["plugin", "uninstall", CLAUDE_PLUGIN, "--scope", scope])).ok) {
|
|
278
|
+
removed.push(`the Claude Code plugin ${CLAUDE_PLUGIN}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (marketplaces.some((each) => each.name === "orlan" && each.source === "directory")) {
|
|
282
|
+
if ((await runClaude(["plugin", "marketplace", "remove", "orlan"])).ok) {
|
|
283
|
+
removed.push("the local Claude Code marketplace orlan");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if ((await runClaude(["mcp", "remove", MCP_NAME, "--scope", "user"])).ok)
|
|
287
|
+
removed.push("the Orlan MCP entry");
|
|
288
|
+
return { removed: [...new Set(removed)], byHand: [] };
|
|
194
289
|
}
|
|
195
290
|
/**
|
|
196
|
-
* The hooks file of Codex and Cursor (F046)
|
|
197
|
-
*
|
|
198
|
-
* PermissionRequest, PostToolUse and Stop. A person trusts each hook once with /hooks, and Codex keeps
|
|
199
|
-
* the trust by file, event and position - https://learn.chatgpt.com/docs/hooks
|
|
200
|
-
* - Cursor: ~/.cursor/hooks.json with `version: 1`, events sessionStart, beforeSubmitPrompt, stop and
|
|
201
|
-
* sessionEnd. The editor and the cursor-agent CLI read it - https://cursor.com/docs/hooks
|
|
291
|
+
* The hooks file where CLI 0.2 wrote the Orlan hooks of Codex and Cursor (F046):
|
|
292
|
+
* $CODEX_HOME/hooks.json (default ~/.codex) and ~/.cursor/hooks.json.
|
|
202
293
|
*/
|
|
203
294
|
export function hooksFile(agent) {
|
|
204
295
|
return agent === "codex"
|
|
205
296
|
? path.join(process.env.CODEX_HOME || path.join(home(), ".codex"), "hooks.json")
|
|
206
297
|
: path.join(home(), ".cursor", "hooks.json");
|
|
207
298
|
}
|
|
208
|
-
/**
|
|
209
|
-
* How many requests in a row a stop hook gives one session before it lets the session stop. Codex has
|
|
210
|
-
* no limit of its own. Cursor drops a hook's follow-up at its loop_limit, 5 by default, which Orlan
|
|
211
|
-
* writes too. Orlan takes no request past this limit, so it never takes a request the harness drops.
|
|
212
|
-
*/
|
|
213
|
-
export const STOP_LOOP_LIMIT = 5;
|
|
214
299
|
/** A hook command Orlan wrote ends with `hook <state> --agent codex` or `--agent cursor`. */
|
|
215
300
|
const orlanHookCommand = /\shook (start|stop|working|needs-input|idle|done) --agent (codex|cursor)$/;
|
|
216
|
-
/**
|
|
217
|
-
* The command a hook runs: this Node and this CLI by full path, because a harness started from the
|
|
218
|
-
* desktop (Cursor, the Codex app) often has no `orlan` or `node` on its PATH.
|
|
219
|
-
*/
|
|
220
|
-
function hookCommand(args) {
|
|
221
|
-
return `${JSON.stringify(process.execPath)} ${JSON.stringify(cliMain())} hook ${args}`;
|
|
222
|
-
}
|
|
223
|
-
/** The main file of this CLI: src/main.ts in a checkout, dist/main.js in the npm package. */
|
|
224
|
-
function cliMain() {
|
|
225
|
-
const source = fileURLToPath(import.meta.url);
|
|
226
|
-
return path.join(path.dirname(source), `main${path.extname(source)}`);
|
|
227
|
-
}
|
|
228
|
-
/** The Orlan hooks of each harness, by event, in the shape of its hooks.json. */
|
|
229
|
-
function orlanHooks(agent) {
|
|
230
|
-
if (agent === "codex") {
|
|
231
|
-
const entry = (args, timeout, statusMessage) => ({
|
|
232
|
-
hooks: [
|
|
233
|
-
{
|
|
234
|
-
type: "command",
|
|
235
|
-
command: hookCommand(`${args} --agent codex`),
|
|
236
|
-
timeout,
|
|
237
|
-
...(statusMessage ? { statusMessage } : {}),
|
|
238
|
-
},
|
|
239
|
-
],
|
|
240
|
-
});
|
|
241
|
-
return {
|
|
242
|
-
SessionStart: entry("start", 10, "Reading the Orlan brief"),
|
|
243
|
-
UserPromptSubmit: entry("working", 5),
|
|
244
|
-
PermissionRequest: entry("needs-input", 5),
|
|
245
|
-
PostToolUse: entry("working", 5),
|
|
246
|
-
Stop: entry("stop", 10, "Checking Orlan for requests"),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
const entry = (args, timeout, more = {}) => ({
|
|
250
|
-
command: hookCommand(`${args} --agent cursor`),
|
|
251
|
-
timeout,
|
|
252
|
-
...more,
|
|
253
|
-
});
|
|
254
|
-
return {
|
|
255
|
-
sessionStart: entry("start", 10),
|
|
256
|
-
beforeSubmitPrompt: entry("working", 5),
|
|
257
|
-
stop: entry("stop", 10, { loop_limit: STOP_LOOP_LIMIT }),
|
|
258
|
-
sessionEnd: entry("done", 5),
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* The line that tells a session what Orlan is and when to load the orlan skill (D088, F090):
|
|
263
|
-
* hooks/orlan-intro.txt of this package. The Claude Code plugin's session-start hook prints the same
|
|
264
|
-
* file, `orlan hook start` adds it for Codex and Cursor, and the OpenCode plugin carries it.
|
|
265
|
-
*/
|
|
266
|
-
export async function orlanIntro() {
|
|
267
|
-
return (await readFile(path.join(packageRoot(), "hooks", "orlan-intro.txt"), "utf8")).trim();
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* What `orlan hook start` prints: the context lines in the JSON each harness adds to the session.
|
|
271
|
-
* Codex reads hookSpecificOutput.additionalContext of SessionStart, Cursor additional_context of
|
|
272
|
-
* sessionStart (checked 2026-09-30 against https://learn.chatgpt.com/docs/hooks and
|
|
273
|
-
* https://cursor.com/docs/hooks).
|
|
274
|
-
*/
|
|
275
|
-
export function sessionStartOutput(agent, lines) {
|
|
276
|
-
const context = lines.join("\n");
|
|
277
|
-
return JSON.stringify(agent === "codex"
|
|
278
|
-
? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }
|
|
279
|
-
: { additional_context: context });
|
|
280
|
-
}
|
|
281
301
|
/** True for a hook entry Orlan wrote: a Cursor entry with an Orlan command, or a Codex group with one. */
|
|
282
302
|
function isOrlanHook(entry) {
|
|
283
303
|
if (!entry || typeof entry !== "object")
|
|
@@ -288,105 +308,100 @@ function isOrlanHook(entry) {
|
|
|
288
308
|
return Array.isArray(hooks) && hooks.some(isOrlanHook);
|
|
289
309
|
}
|
|
290
310
|
/**
|
|
291
|
-
* The hooks.json
|
|
292
|
-
*
|
|
293
|
-
* its place: Codex keeps a person's trust by the position of the hook.
|
|
311
|
+
* The text of a hooks.json (comments are allowed) with no Orlan hook. Every other hook stays at its
|
|
312
|
+
* place, and the comments and the format stay. An event with only Orlan hooks goes.
|
|
294
313
|
*/
|
|
295
|
-
export function
|
|
296
|
-
|
|
297
|
-
for (const event of
|
|
298
|
-
const current = events[event] ?? [];
|
|
314
|
+
export function withoutOrlanHooks(text) {
|
|
315
|
+
let edited = text;
|
|
316
|
+
for (const [event, entries] of Object.entries(field(parseJsonc(text), "hooks"))) {
|
|
299
317
|
// Not a list of hooks: not a shape Orlan writes, so it stays as it is.
|
|
300
|
-
if (!Array.isArray(
|
|
318
|
+
if (!Array.isArray(entries) || !entries.some(isOrlanHook))
|
|
301
319
|
continue;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
320
|
+
if (entries.every(isOrlanHook)) {
|
|
321
|
+
edited = without(edited, ["hooks", event]);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// From the end, so each index still names the same entry.
|
|
325
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
326
|
+
if (isOrlanHook(entries[index]))
|
|
327
|
+
edited = without(edited, ["hooks", event, index]);
|
|
328
|
+
}
|
|
311
329
|
}
|
|
312
|
-
return
|
|
330
|
+
return edited;
|
|
313
331
|
}
|
|
314
|
-
/**
|
|
315
|
-
|
|
332
|
+
/** The change that takes the old Orlan hooks out of the hooks.json of Codex or Cursor. */
|
|
333
|
+
async function hooksChange(agent) {
|
|
316
334
|
const file = hooksFile(agent);
|
|
317
335
|
const text = await readText(file);
|
|
318
|
-
if (
|
|
319
|
-
return
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
throw new OrlanError(`${file} is not plain JSON, so Orlan does not change it. Correct the file, then run this again.`);
|
|
326
|
-
}
|
|
327
|
-
const edited = editHooks(settings, install ? orlanHooks(agent) : undefined);
|
|
328
|
-
await writeText(file, `${JSON.stringify(agent === "cursor" ? { version: 1, ...edited } : edited, null, 2)}\n`);
|
|
329
|
-
return file;
|
|
336
|
+
if (!/ hook \S+ --agent (codex|cursor)/.test(text))
|
|
337
|
+
return undefined;
|
|
338
|
+
checkJsonc(file, text, `Remove the Orlan hooks (the commands with "hook ... --agent ${agent}") by hand`);
|
|
339
|
+
const edited = withoutOrlanHooks(text);
|
|
340
|
+
return edited === text ? undefined : { file, text: edited, what: `the Orlan hooks in ${file}` };
|
|
330
341
|
}
|
|
331
|
-
/**
|
|
332
|
-
* The Orlan plugin of OpenCode (F045), in its global plugins folder: ~/.config/opencode/plugins/ -
|
|
333
|
-
* https://opencode.ai/docs/plugins/ (checked 2026-09-27 against OpenCode 1.18.30).
|
|
334
|
-
*/
|
|
342
|
+
/** The OpenCode plugin file of CLI 0.2 (F045), in OpenCode's global plugins folder. */
|
|
335
343
|
export function opencodePluginFile() {
|
|
336
344
|
return path.join(xdgConfig(), "opencode", "plugins", "orlan.js");
|
|
337
345
|
}
|
|
338
|
-
/** The first line of the plugin, so
|
|
346
|
+
/** The first line of the old plugin, so only a file Orlan wrote is removed. */
|
|
339
347
|
const OPENCODE_PLUGIN_MARK = "// The Orlan plugin for OpenCode (F045).";
|
|
340
|
-
/**
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
348
|
+
/** The change that deletes the old Orlan plugin of OpenCode. */
|
|
349
|
+
async function opencodePluginChange() {
|
|
350
|
+
const file = opencodePluginFile();
|
|
351
|
+
if (!(await readText(file)).startsWith(OPENCODE_PLUGIN_MARK))
|
|
352
|
+
return undefined;
|
|
353
|
+
return { file, text: null, what: `the Orlan plugin ${file}` };
|
|
354
|
+
}
|
|
355
|
+
/** The change that removes the Orlan MCP table from the Codex config.toml. */
|
|
356
|
+
async function codexMcpChange() {
|
|
357
|
+
const file = mcpFile("codex");
|
|
358
|
+
const text = await readText(file);
|
|
359
|
+
const edited = editCodexToml(text);
|
|
360
|
+
return edited.trimEnd() === text.trimEnd() ? undefined : { file, text: edited, what: "the Orlan MCP entry" };
|
|
361
|
+
}
|
|
362
|
+
/** The changes to the settings files of the agent, in the order they are told. */
|
|
363
|
+
function fileChanges(agent) {
|
|
364
|
+
switch (agent) {
|
|
365
|
+
case "codex":
|
|
366
|
+
return Promise.all([hooksChange("codex"), codexMcpChange()]);
|
|
367
|
+
case "cursor":
|
|
368
|
+
return Promise.all([hooksChange("cursor"), jsonKeyChange(mcpFile("cursor"), "mcpServers")]);
|
|
369
|
+
case "opencode":
|
|
370
|
+
return Promise.all([opencodePluginChange(), jsonKeyChange(mcpFile("opencode"), "mcp")]);
|
|
351
371
|
}
|
|
352
|
-
return source
|
|
353
|
-
.replace(line, `const ORLAN = ${JSON.stringify([process.execPath, cliMain()])};`)
|
|
354
|
-
.replace(introLine, `const INTRO = ${JSON.stringify(await orlanIntro())};`);
|
|
355
372
|
}
|
|
356
|
-
/**
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
await writeText(file, await opencodePlugin());
|
|
373
|
+
/** Throws when this user cannot write the file's folder: the change could not be made. */
|
|
374
|
+
async function checkWritable(change) {
|
|
375
|
+
try {
|
|
376
|
+
await access(path.dirname(change.file), constants.W_OK);
|
|
361
377
|
}
|
|
362
|
-
|
|
363
|
-
|
|
378
|
+
catch (error) {
|
|
379
|
+
throw new OrlanError(`Orlan cannot change ${change.file} (${error.code}), so it changes no file. ` +
|
|
380
|
+
`Remove ${change.what} by hand, then run this again.`);
|
|
364
381
|
}
|
|
365
|
-
return file;
|
|
366
382
|
}
|
|
367
383
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
384
|
+
* Plans the removal of what CLI 0.1 and 0.2 installed for the agent besides the skills (D102): the Claude
|
|
385
|
+
* Code plugin and its local marketplace, the Codex and Cursor hooks, the OpenCode plugin, and the Orlan
|
|
386
|
+
* MCP entry (D088). Every other entry the person has stays, with its comments. It reads and checks every
|
|
387
|
+
* file first and throws before it changes any, so it never stops half done (B047). The function it
|
|
388
|
+
* gives makes the changes, and says what it removed.
|
|
370
389
|
*/
|
|
371
|
-
export async function
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return true;
|
|
390
|
+
export async function planOldInstall(agent) {
|
|
391
|
+
if (agent === "other")
|
|
392
|
+
return async () => ({ removed: [], byHand: [] });
|
|
393
|
+
if (agent === "claude-code")
|
|
394
|
+
return removeFromClaude;
|
|
395
|
+
const changes = (await fileChanges(agent)).filter((change) => change !== undefined);
|
|
396
|
+
for (const change of changes)
|
|
397
|
+
await checkWritable(change);
|
|
398
|
+
return async () => {
|
|
399
|
+
for (const change of changes) {
|
|
400
|
+
if (change.text === null)
|
|
401
|
+
await rm(change.file, { force: true });
|
|
402
|
+
else
|
|
403
|
+
await writeText(change.file, change.text);
|
|
386
404
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
case "opencode":
|
|
390
|
-
return removeJsonKey(mcpFile("opencode"), "mcp");
|
|
391
|
-
}
|
|
405
|
+
return { removed: [...new Set(changes.map((change) => change.what))], byHand: [] };
|
|
406
|
+
};
|
|
392
407
|
}
|