@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/dist/agents.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * The agents the CLI connects: where each one reads skills, its hooks or plugin, and the old Orlan MCP
3
- * entry that a connect removes (D088). The ids match the web app's connect dialog (agentClients in
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 { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
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 { fileURLToPath } from "node:url";
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
- * Removes the Orlan key under `section` of a JSON settings file. Every other key stays. Returns true
125
- * when it removed it.
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
- async function removeJsonKey(file, section) {
128
- const text = await readText(file);
129
- if (!text.includes(`"${MCP_NAME}"`))
130
- return false;
131
- let settings;
132
- try {
133
- settings = JSON.parse(text);
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
- catch {
136
- throw new OrlanError(`${file} is not plain JSON (it has comments or an error), so Orlan does not change it. ` +
137
- `Remove the "${MCP_NAME}" entry under "${section}" by hand, then run this again.`);
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
- const servers = settings[section];
140
- if (!servers || typeof servers !== "object" || !(MCP_NAME in servers))
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, reject) => {
148
- execFile("claude", args, { timeout: 60_000 }, (error, stdout, stderr) => {
149
- if (error && error.code === "ENOENT") {
150
- reject(new OrlanError("Orlan cannot find the `claude` command. Install Claude Code first."));
151
- return;
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
- * The Claude Code plugin (F044). This package is the plugin and its own marketplace: the folder with
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 false;
219
+ return [];
169
220
  try {
170
- const plugins = JSON.parse(listed.output);
171
- return Array.isArray(plugins) && plugins.some((plugin) => plugin.id === CLAUDE_PLUGIN && plugin.enabled);
221
+ const list = JSON.parse(listed.output);
222
+ return Array.isArray(list) ? list : [];
172
223
  }
173
224
  catch {
174
- return false;
225
+ return [];
175
226
  }
176
227
  }
177
228
  /**
178
- * Installs the Orlan plugin in Claude Code for this user, from this package, when it is not enabled
179
- * yet. Returns false when Claude Code refused the install.
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
- export async function installClaudePlugin() {
182
- if (await claudePluginEnabled())
183
- return true;
184
- await runClaude(["plugin", "marketplace", "add", packageRoot(), "--scope", "user"]);
185
- const installed = await runClaude(["plugin", "install", CLAUDE_PLUGIN, "--scope", "user"]);
186
- return installed.ok && (await claudePluginEnabled());
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 out of Claude Code for this user. The marketplace stays: a person can have
190
- * added it. With no claude command, or no plugin, it changes nothing.
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
- export async function uninstallClaudePlugin() {
193
- await runClaude(["plugin", "uninstall", CLAUDE_PLUGIN, "--scope", "user"]).catch(() => undefined);
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). Checked against each tool's documentation on 2026-09-26:
197
- * - Codex: $CODEX_HOME/hooks.json (default ~/.codex), events SessionStart, UserPromptSubmit,
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 settings with the Orlan hooks of `add` in place of the old ones, or with no Orlan
292
- * hooks when `add` is undefined. Every other hook stays at its place, and an Orlan hook goes back to
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 editHooks(settings, add) {
296
- const events = { ...(settings.hooks ?? {}) };
297
- for (const event of new Set([...Object.keys(events), ...Object.keys(add ?? {})])) {
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(current))
318
+ if (!Array.isArray(entries) || !entries.some(isOrlanHook))
301
319
  continue;
302
- const at = current.findIndex(isOrlanHook);
303
- const kept = current.filter((entry) => !isOrlanHook(entry));
304
- const mine = add?.[event];
305
- if (mine)
306
- kept.splice(at === -1 ? kept.length : at, 0, mine);
307
- if (kept.length > 0)
308
- events[event] = kept;
309
- else
310
- delete events[event];
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 { ...settings, hooks: events };
330
+ return edited;
313
331
  }
314
- /** Writes the Orlan hooks into the harness's hooks.json, or takes them out. Returns the file. */
315
- export async function setHooks(agent, install) {
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 (!text.trim() && !install)
319
- return file;
320
- let settings;
321
- try {
322
- settings = text.trim() ? JSON.parse(text) : {};
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 a disconnect removes only a file Orlan wrote. */
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
- * The plugin as `orlan connect` writes it: opencode/orlan.js of this package, with this Node and
342
- * this CLI by full path in place of `orlan` on the PATH, and the Orlan intro line (F090).
343
- */
344
- export async function opencodePlugin() {
345
- const source = await readFile(path.join(packageRoot(), "opencode", "orlan.js"), "utf8");
346
- const line = 'const ORLAN = ["orlan"];';
347
- const introLine = 'const INTRO = "";';
348
- if (!source.startsWith(OPENCODE_PLUGIN_MARK) || !source.includes(line) || !source.includes(introLine)) {
349
- const { name } = JSON.parse(await readFile(path.join(packageRoot(), "package.json"), "utf8"));
350
- throw new OrlanError(`The OpenCode plugin of this orlan package is damaged. Install ${name} again.`);
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
- /** Writes the Orlan plugin into OpenCode's plugins folder, or takes it out. Returns the file. */
357
- export async function setOpencodePlugin(install) {
358
- const file = opencodePluginFile();
359
- if (install) {
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
- else if ((await readText(file)).startsWith(OPENCODE_PLUGIN_MARK)) {
363
- await rm(file, { force: true });
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
- * Removes the Orlan MCP entry that an older `orlan mcp connect` wrote (D088: agents use the orlan
369
- * command, not MCP). Every other entry stays. Returns true when it removed one.
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 removeMcp(agent) {
372
- switch (agent) {
373
- case "claude-code": {
374
- // With no claude command there is no Claude Code to change.
375
- const removed = await runClaude(["mcp", "remove", MCP_NAME, "--scope", "user"]).catch(() => undefined);
376
- return removed?.ok ?? false;
377
- }
378
- case "codex": {
379
- const file = mcpFile("codex");
380
- const text = await readText(file);
381
- const edited = editCodexToml(text);
382
- if (edited.trimEnd() === text.trimEnd())
383
- return false;
384
- await writeText(file, edited);
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
- case "cursor":
388
- return removeJsonKey(mcpFile("cursor"), "mcpServers");
389
- case "opencode":
390
- return removeJsonKey(mcpFile("opencode"), "mcp");
391
- }
405
+ return { removed: [...new Set(changes.map((change) => change.what))], byHand: [] };
406
+ };
392
407
  }