likho-cli 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # likho-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect every AI tool on your computer to [Likho](https://likho.me) in one
4
+ command.
5
+
6
+ ```
7
+ npx likho-cli
8
+ ```
9
+
10
+ 1. Inside a project folder it asks: **just this project**, or **all projects
11
+ on this computer**.
12
+ 2. It finds your AI tools: Claude Code, Codex, Cursor, VS Code, Gemini CLI,
13
+ opencode, Windsurf (and tells you how to add Claude Desktop).
14
+ 3. Your browser opens on likho.me to choose the organization: one for the
15
+ project, or one per tool.
16
+ 4. It adds Likho to each tool's MCP settings, next to your other servers, and
17
+ keeps a `.likho-backup` of any file it changes.
18
+
19
+ Then restart the tools, sign in once in each (one click, your browser is
20
+ already signed in), and say "join Likho" in any chat.
21
+
22
+ ## Commands
23
+
24
+ | Command | What it does |
25
+ |---|---|
26
+ | `npx likho-cli` | Set up (asks this project or all projects) |
27
+ | `npx likho-cli status` | What is connected where |
28
+ | `npx likho-cli remove` | Take Likho out again |
29
+
30
+ Options: `--here` (this project only), `--global` (all projects),
31
+ `--tool codex,cursor` (only these tools), `--org <slug>` (skip the browser),
32
+ `--yes` (no questions), `--server <url>` (another Likho).
33
+
34
+ ## What it writes
35
+
36
+ Only Likho's address (`https://likho.me/mcp?org=<organization>`), never a key.
37
+ Project files (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`,
38
+ `.gemini/settings.json`, `opencode.json`) are safe to commit. Codex and
39
+ Windsurf only have settings for all projects.
package/bin/likho.js ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // npx likho-cli: connect the AI tools on this computer to Likho.
3
+ //
4
+ // npx likho-cli set up (asks: this project, or all projects?)
5
+ // npx likho-cli status what is connected where
6
+ // npx likho-cli remove take Likho out again
7
+ //
8
+ // Options: --here (this project only), --global (all projects),
9
+ // --tool codex,cursor (only these), --org <slug> (skip the browser step),
10
+ // --yes (no questions), --server <url> (another Likho, default https://likho.me).
11
+ "use strict";
12
+
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+ const readline = require("node:readline/promises");
16
+ const { tools, context } = require("../lib/tools");
17
+ const { chooseInBrowser } = require("../lib/browser");
18
+ const { UnreadableFile } = require("../lib/files");
19
+
20
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "composer.json", "Gemfile", "pom.xml", "build.gradle", ".mcp.json"];
21
+ const SUPPORT = "https://likho.me/dashboard/organizations";
22
+
23
+ function parseArgs(argv) {
24
+ const opts = { command: "connect", tools: null, org: "", server: process.env.LIKHO_URL || "https://likho.me", scope: "", yes: false };
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === "status" || a === "remove" || a === "help") opts.command = a;
28
+ else if (a === "--here" || a === "--project") opts.scope = "project";
29
+ else if (a === "--global" || a === "--everywhere") opts.scope = "global";
30
+ else if (a === "--yes" || a === "-y") opts.yes = true;
31
+ else if (a === "--tool") opts.tools = (argv[++i] || "").split(",").filter(Boolean);
32
+ else if (a === "--org") opts.org = argv[++i] || "";
33
+ else if (a === "--server") opts.server = (argv[++i] || opts.server).replace(/\/+$/, "");
34
+ else if (a === "-h" || a === "--help") opts.command = "help";
35
+ }
36
+ return opts;
37
+ }
38
+
39
+ function isProjectFolder(ctx) {
40
+ if (path.resolve(ctx.cwd) === path.resolve(ctx.home)) return false;
41
+ return PROJECT_MARKERS.some((m) => fs.existsSync(path.join(ctx.cwd, m)));
42
+ }
43
+
44
+ function makeAsk(opts) {
45
+ const interactive = process.stdin.isTTY && !opts.yes;
46
+ let rl = null;
47
+ return {
48
+ async question(text, fallback) {
49
+ if (!interactive) return fallback;
50
+ rl = rl || readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ const answer = (await rl.question(text)).trim();
52
+ return answer || fallback;
53
+ },
54
+ close() {
55
+ if (rl) rl.close();
56
+ },
57
+ };
58
+ }
59
+
60
+ const say = (line = "") => console.log(line);
61
+ const mcpUrl = (opts, org) => `${opts.server}/mcp${org ? `?org=${encodeURIComponent(org)}` : ""}`;
62
+
63
+ async function chooseScope(opts, ctx, ask, verb) {
64
+ if (opts.scope) return opts.scope;
65
+ if (!isProjectFolder(ctx)) return "global";
66
+ const folder = path.basename(ctx.cwd);
67
+ say(` This folder looks like a project: ${folder}`);
68
+ say(` ${verb} Likho for:`);
69
+ if (verb === "Remove") {
70
+ say(` 1) Just this project (${folder}).`);
71
+ say(" 2) All projects on this computer.");
72
+ } else {
73
+ say(` 1) Just this project. Every AI tool opened in ${folder} uses one organization.`);
74
+ say(" 2) All projects on this computer. You choose an organization for each tool.");
75
+ }
76
+ const answer = await ask.question(" Choose 1 or 2 [1]: ", "1");
77
+ say();
78
+ return answer === "2" ? "global" : "project";
79
+ }
80
+
81
+ function selectTools(opts, ctx) {
82
+ let list = tools(ctx);
83
+ if (opts.tools) list = list.filter((t) => opts.tools.includes(t.id));
84
+ return list;
85
+ }
86
+
87
+ async function connect(opts) {
88
+ const ctx = context();
89
+ const ask = makeAsk(opts);
90
+ try {
91
+ say("\n Likho: connect your AI tools\n");
92
+ const scope = await chooseScope(opts, ctx, ask, "Set up");
93
+ const found = selectTools(opts, ctx).filter((t) => opts.tools || t.detect());
94
+ if (!found.length) {
95
+ say(" No supported AI tools found on this computer.");
96
+ say(" Supported: Claude Code, Codex, Cursor, VS Code, Gemini CLI, opencode, Windsurf, Claude Desktop.");
97
+ say(` To connect another tool by hand, see Connect in your Likho dashboard: ${SUPPORT}`);
98
+ return 1;
99
+ }
100
+ const usable = found.filter((t) => (scope === "project" ? t.project : t.global));
101
+ const elsewhere = found.filter((t) => !usable.includes(t));
102
+
103
+ say(` Found on this computer${scope === "project" ? `, for ${path.basename(ctx.cwd)} only` : ""}:`);
104
+ usable.forEach((t) => say(` - ${t.name}`));
105
+ elsewhere.forEach((t) => say(` - ${t.name} (can't be set up per project; run npx likho-cli --global for it)`));
106
+ if (!usable.length) return 1;
107
+
108
+ let chosen = usable;
109
+ if ((await ask.question("\n Connect these? [Y/n]: ", "y")).toLowerCase().startsWith("n")) {
110
+ chosen = [];
111
+ for (const t of usable) {
112
+ if (!(await ask.question(` ${t.name}? [Y/n]: `, "y")).toLowerCase().startsWith("n")) chosen.push(t);
113
+ }
114
+ }
115
+ if (!chosen.length) {
116
+ say("\n Nothing to do.");
117
+ return 0;
118
+ }
119
+
120
+ // Which organization each tool lands in.
121
+ let orgFor;
122
+ if (opts.org) {
123
+ orgFor = () => opts.org;
124
+ } else {
125
+ const choice = await chooseInBrowser(opts.server, {
126
+ scope,
127
+ tools: chosen.map((t) => t.id).join(","),
128
+ folder: scope === "project" ? path.basename(ctx.cwd) : "",
129
+ });
130
+ if (!choice) {
131
+ say(" Cancelled. Nothing was changed.");
132
+ return 1;
133
+ }
134
+ orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
135
+ }
136
+
137
+ say(" Connecting...");
138
+ const done = [];
139
+ for (const t of chosen) {
140
+ const org = orgFor(t);
141
+ const url = mcpUrl(opts, org);
142
+ if (t.manual) {
143
+ say(` ! ${t.name}: add it yourself. ${t.signIn}`);
144
+ say(` URL: ${url}`);
145
+ continue;
146
+ }
147
+ try {
148
+ const result = t.install(ctx, scope, url);
149
+ say(` ✓ ${t.name}${org ? ` → ${org}` : ""}${result.replaced ? " (updated)" : ""}`);
150
+ done.push(t);
151
+ } catch (err) {
152
+ if (err instanceof UnreadableFile) {
153
+ say(` ! ${t.name}: ${err.message} has comments or isn't plain JSON, so it was left alone.`);
154
+ say(` Add a server named "likho" with URL ${url} to it yourself.`);
155
+ } else {
156
+ say(` ! ${t.name}: ${err.message}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ if (done.length) {
162
+ say("\n Done. Restart the tools above, then sign in once in each (one click, the browser is already signed in):");
163
+ done.forEach((t) => say(` ${t.name}: ${t.signIn}`));
164
+ say('\n Then, in any chat: "join Likho".');
165
+ if (scope === "project") say(" The files written here hold only Likho's address, no keys: safe to commit.");
166
+ }
167
+ return done.length ? 0 : 1;
168
+ } finally {
169
+ ask.close();
170
+ }
171
+ }
172
+
173
+ function status(opts) {
174
+ const ctx = context();
175
+ const inProject = isProjectFolder(ctx);
176
+ say("\n Likho connections\n");
177
+ for (const t of selectTools(opts, ctx)) {
178
+ if (t.manual) continue;
179
+ const rows = [];
180
+ if (t.global) rows.push(`all projects: ${t.current(ctx, "global") || "not connected"}`);
181
+ if (t.project && inProject) rows.push(`this project: ${t.current(ctx, "project") || "not connected"}`);
182
+ say(` ${t.name.padEnd(14)} ${t.detect() ? "" : "(not installed) "}${rows.join(" · ")}`);
183
+ }
184
+ say();
185
+ return 0;
186
+ }
187
+
188
+ async function remove(opts) {
189
+ const ctx = context();
190
+ const ask = makeAsk(opts);
191
+ try {
192
+ const scope = await chooseScope(opts, ctx, ask, "Remove");
193
+ let removed = 0;
194
+ for (const t of selectTools(opts, ctx)) {
195
+ if (t.manual || !(scope === "project" ? t.project : t.global)) continue;
196
+ try {
197
+ if (t.remove(ctx, scope)) {
198
+ say(` ✓ removed from ${t.name}`);
199
+ removed += 1;
200
+ }
201
+ } catch (err) {
202
+ say(` ! ${t.name}: ${err.message}`);
203
+ }
204
+ }
205
+ say(removed ? "\n Done. Restart those tools." : " Likho wasn't connected there.");
206
+ return 0;
207
+ } finally {
208
+ ask.close();
209
+ }
210
+ }
211
+
212
+ function help() {
213
+ say(`
214
+ npx likho-cli Connect the AI tools on this computer to Likho.
215
+ Inside a project folder it asks: this project, or all projects.
216
+ npx likho-cli status Show what is connected where.
217
+ npx likho-cli remove Take Likho out of your tools.
218
+
219
+ --here Only this project folder.
220
+ --global All projects on this computer.
221
+ --tool codex,cursor Only these tools (claude-code, codex, cursor, vscode, gemini, opencode, windsurf).
222
+ --org <slug> Use this organization; skips the browser.
223
+ --yes Don't ask questions.
224
+ `);
225
+ return 0;
226
+ }
227
+
228
+ async function main() {
229
+ const opts = parseArgs(process.argv.slice(2));
230
+ const run = { connect, status, remove, help }[opts.command];
231
+ process.exitCode = await run(opts);
232
+ }
233
+
234
+ main().catch((err) => {
235
+ console.error(`\n likho: ${err.message}`);
236
+ process.exitCode = 1;
237
+ });
package/lib/browser.js ADDED
@@ -0,0 +1,78 @@
1
+ // Choosing organizations in the browser, where the person is signed in to
2
+ // Likho. The CLI never holds a Likho credential: it opens likho.me, the page
3
+ // sends back only the chosen organization names to a one-off address on this
4
+ // computer (127.0.0.1), and each AI tool signs in on its own later.
5
+ "use strict";
6
+
7
+ const http = require("node:http");
8
+ const crypto = require("node:crypto");
9
+ const { spawn } = require("node:child_process");
10
+
11
+ function openBrowser(url) {
12
+ try {
13
+ const [cmd, args] =
14
+ process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
15
+ : process.platform === "darwin" ? ["open", [url]]
16
+ : ["xdg-open", [url]];
17
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ const DONE_PAGE = (title, body) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
25
+ <style>body{font:15px system-ui,sans-serif;background:#121212;color:#ededed;display:grid;place-items:center;height:100vh;margin:0}
26
+ main{max-width:420px;text-align:center}h1{font-size:20px;font-weight:500}p{color:#a0a0a0}</style></head>
27
+ <body><main><h1>${title}</h1><p>${body}</p></main></body></html>`;
28
+
29
+ /**
30
+ * Open `${server}/cli/connect` and wait for the person's choice.
31
+ * Resolves to the parsed choice ({orgs: {tool: slug}} or {org: slug}), or null
32
+ * when they cancel or time runs out.
33
+ */
34
+ function chooseInBrowser(server, params, { timeoutMs = 5 * 60 * 1000, log = console.log } = {}) {
35
+ const state = crypto.randomBytes(16).toString("hex");
36
+ return new Promise((resolve) => {
37
+ let finished = false;
38
+ const finish = (value) => {
39
+ if (finished) return;
40
+ finished = true;
41
+ clearTimeout(timer);
42
+ server_.close();
43
+ resolve(value);
44
+ };
45
+ const server_ = http.createServer((req, res) => {
46
+ const url = new URL(req.url, "http://127.0.0.1");
47
+ if (url.pathname !== "/done" || url.searchParams.get("state") !== state) {
48
+ res.writeHead(404).end();
49
+ return;
50
+ }
51
+ if (url.searchParams.get("cancel")) {
52
+ res.writeHead(200, { "content-type": "text/html" }).end(DONE_PAGE("Cancelled", "Nothing was changed. You can close this tab."));
53
+ finish(null);
54
+ return;
55
+ }
56
+ let choice = null;
57
+ try {
58
+ choice = JSON.parse(Buffer.from(url.searchParams.get("choice") || "", "base64url").toString("utf8"));
59
+ } catch {
60
+ choice = null;
61
+ }
62
+ res.writeHead(200, { "content-type": "text/html" }).end(
63
+ DONE_PAGE("All set", "Go back to your terminal to finish. You can close this tab.")
64
+ );
65
+ finish(choice);
66
+ });
67
+ const timer = setTimeout(() => finish(null), timeoutMs);
68
+ server_.listen(0, "127.0.0.1", () => {
69
+ const { port } = server_.address();
70
+ const link = `${server}/cli/connect?${new URLSearchParams({ ...params, port: String(port), state })}`;
71
+ log("\n Opening your browser to choose the organization...");
72
+ if (!openBrowser(link)) log(" Couldn't open it. Open this link yourself:");
73
+ log(` ${link}\n`);
74
+ });
75
+ });
76
+ }
77
+
78
+ module.exports = { chooseInBrowser, openBrowser };
package/lib/files.js ADDED
@@ -0,0 +1,106 @@
1
+ // Reading and writing the settings files of AI tools, carefully: never touch
2
+ // anything but Likho's own entry, keep a backup of a file before its first
3
+ // change, and refuse (rather than guess) when a file can't be read as JSON.
4
+ "use strict";
5
+
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+
9
+ class UnreadableFile extends Error {}
10
+
11
+ function readJson(file) {
12
+ if (!fs.existsSync(file)) return {};
13
+ const raw = fs.readFileSync(file, "utf8").replace(/^/, "");
14
+ if (!raw.trim()) return {};
15
+ try {
16
+ const data = JSON.parse(raw);
17
+ if (data && typeof data === "object" && !Array.isArray(data)) return data;
18
+ } catch {
19
+ // Comments or a trailing comma (VS Code and opencode allow them): we
20
+ // don't rewrite what we can't read exactly.
21
+ }
22
+ throw new UnreadableFile(file);
23
+ }
24
+
25
+ const backedUp = new Set();
26
+
27
+ function backup(file) {
28
+ if (backedUp.has(file) || !fs.existsSync(file)) return;
29
+ fs.copyFileSync(file, `${file}.likho-backup`);
30
+ backedUp.add(file);
31
+ }
32
+
33
+ function writeJson(file, data) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ backup(file);
36
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
37
+ }
38
+
39
+ /** Put `entry` at data[section][name]; returns the previous entry, if any. */
40
+ function setEntry(file, section, name, entry) {
41
+ const data = readJson(file);
42
+ const block = data[section] && typeof data[section] === "object" ? data[section] : {};
43
+ const previous = block[name];
44
+ data[section] = { ...block, [name]: entry };
45
+ writeJson(file, data);
46
+ return previous;
47
+ }
48
+
49
+ /** Remove data[section][name]; true when something was removed. */
50
+ function removeEntry(file, section, name) {
51
+ if (!fs.existsSync(file)) return false;
52
+ const data = readJson(file);
53
+ if (!data[section] || !(name in data[section])) return false;
54
+ delete data[section][name];
55
+ writeJson(file, data);
56
+ return true;
57
+ }
58
+
59
+ function getEntry(file, section, name) {
60
+ try {
61
+ return (readJson(file)[section] || {})[name] || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // ---- Codex's TOML: only the [mcp_servers.<name>] table is ever touched.
68
+ function tomlTable(name) {
69
+ return new RegExp(`^\\[mcp_servers\\.${name}\\][\\s\\S]*?(?=^\\[|(?![\\s\\S]))`, "m");
70
+ }
71
+
72
+ function setTomlServer(file, name, url) {
73
+ const raw = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
74
+ const table = `[mcp_servers.${name}]\nurl = ${JSON.stringify(url)}\n\n`;
75
+ const next = tomlTable(name).test(raw) ? raw.replace(tomlTable(name), table) : `${raw.replace(/\s*$/, "\n\n")}${table}`;
76
+ fs.mkdirSync(path.dirname(file), { recursive: true });
77
+ backup(file);
78
+ fs.writeFileSync(file, next.replace(/^\n+/, ""), "utf8");
79
+ }
80
+
81
+ function getTomlServerUrl(file, name) {
82
+ if (!fs.existsSync(file)) return null;
83
+ const match = fs.readFileSync(file, "utf8").match(tomlTable(name));
84
+ const url = match && match[0].match(/^url\s*=\s*"([^"]*)"/m);
85
+ return url ? url[1] : null;
86
+ }
87
+
88
+ function removeTomlServer(file, name) {
89
+ if (!fs.existsSync(file)) return false;
90
+ const raw = fs.readFileSync(file, "utf8");
91
+ if (!tomlTable(name).test(raw)) return false;
92
+ backup(file);
93
+ fs.writeFileSync(file, raw.replace(tomlTable(name), ""), "utf8");
94
+ return true;
95
+ }
96
+
97
+ module.exports = {
98
+ UnreadableFile,
99
+ readJson,
100
+ setEntry,
101
+ removeEntry,
102
+ getEntry,
103
+ setTomlServer,
104
+ getTomlServerUrl,
105
+ removeTomlServer,
106
+ };
package/lib/tools.js ADDED
@@ -0,0 +1,192 @@
1
+ // The AI tools likho-cli knows how to connect, and where each keeps its MCP
2
+ // settings. Two places per tool when it has them:
3
+ // global every folder on this computer (the tool's own settings)
4
+ // project one folder only (a settings file inside the project)
5
+ // Formats follow each tool's documentation; they are also what Likho's
6
+ // Connect page shows for doing it by hand (frontend/src/ConnectionsCards.jsx).
7
+ "use strict";
8
+
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ const { spawnSync } = require("node:child_process");
12
+ const files = require("./files");
13
+
14
+ const NAME = "likho";
15
+
16
+ function onPath(cmd) {
17
+ const probe = process.platform === "win32" ? spawnSync("where", [cmd], { encoding: "utf8" }) : spawnSync("which", [cmd], { encoding: "utf8" });
18
+ return probe.status === 0 && Boolean((probe.stdout || "").trim());
19
+ }
20
+
21
+ function run(cmd, args) {
22
+ // .cmd shims on Windows need a shell; every argument here is ours, not the user's.
23
+ const result = spawnSync(cmd, args, { encoding: "utf8", shell: process.platform === "win32" });
24
+ return result.status === 0;
25
+ }
26
+
27
+ function vscodeUserDir(ctx) {
28
+ if (ctx.platform === "win32") return path.join(ctx.appData, "Code", "User");
29
+ if (ctx.platform === "darwin") return path.join(ctx.home, "Library", "Application Support", "Code", "User");
30
+ return path.join(ctx.home, ".config", "Code", "User");
31
+ }
32
+
33
+ function claudeDesktopDir(ctx) {
34
+ if (ctx.platform === "win32") return path.join(ctx.appData, "Claude");
35
+ if (ctx.platform === "darwin") return path.join(ctx.home, "Library", "Application Support", "Claude");
36
+ return path.join(ctx.home, ".config", "Claude");
37
+ }
38
+
39
+ /** A tool whose settings are one JSON file per place. */
40
+ function jsonTool({ id, name, section, entry, globalFile, projectFile, detect, signIn }) {
41
+ return {
42
+ id,
43
+ name,
44
+ detect,
45
+ signIn,
46
+ project: Boolean(projectFile),
47
+ global: Boolean(globalFile),
48
+ file: (ctx, scope) => (scope === "project" ? path.join(ctx.cwd, projectFile) : globalFile(ctx)),
49
+ install(ctx, scope, url) {
50
+ return { file: this.file(ctx, scope), replaced: Boolean(files.setEntry(this.file(ctx, scope), section, NAME, entry(url))) };
51
+ },
52
+ remove(ctx, scope) {
53
+ return files.removeEntry(this.file(ctx, scope), section, NAME);
54
+ },
55
+ current(ctx, scope) {
56
+ const found = files.getEntry(this.file(ctx, scope), section, NAME);
57
+ return found ? found.url || found.httpUrl || found.serverUrl || "(set)" : null;
58
+ },
59
+ };
60
+ }
61
+
62
+ function tools(ctx) {
63
+ const list = [
64
+ {
65
+ id: "claude-code",
66
+ name: "Claude Code",
67
+ detect: () => ctx.which("claude") || fs.existsSync(path.join(ctx.home, ".claude.json")) || fs.existsSync(path.join(ctx.home, ".claude")),
68
+ signIn: "In Claude Code run /mcp, pick likho, choose Authenticate.",
69
+ project: true,
70
+ global: true,
71
+ file: (c, scope) => (scope === "project" ? path.join(c.cwd, ".mcp.json") : path.join(c.home, ".claude.json")),
72
+ install(c, scope, url) {
73
+ const file = this.file(c, scope);
74
+ if (scope === "global" && c.which("claude")) {
75
+ // Claude Code rewrites ~/.claude.json while it runs: let its own CLI edit it.
76
+ run("claude", ["mcp", "remove", NAME, "--scope", "user"]);
77
+ if (run("claude", ["mcp", "add", "--transport", "http", "--scope", "user", NAME, url])) return { file, replaced: false };
78
+ }
79
+ return { file, replaced: Boolean(files.setEntry(file, "mcpServers", NAME, { type: "http", url })) };
80
+ },
81
+ remove(c, scope) {
82
+ if (scope === "global" && c.which("claude") && run("claude", ["mcp", "remove", NAME, "--scope", "user"])) return true;
83
+ return files.removeEntry(this.file(c, scope), "mcpServers", NAME);
84
+ },
85
+ current(c, scope) {
86
+ const found = files.getEntry(this.file(c, scope), "mcpServers", NAME);
87
+ return found ? found.url || "(set)" : null;
88
+ },
89
+ },
90
+ {
91
+ id: "codex",
92
+ name: "Codex",
93
+ detect: () => ctx.which("codex") || fs.existsSync(path.join(ctx.home, ".codex")),
94
+ signIn: "Run: codex mcp login likho",
95
+ project: false,
96
+ global: true,
97
+ file: (c) => path.join(c.home, ".codex", "config.toml"),
98
+ install(c, scope, url) {
99
+ const file = this.file(c);
100
+ if (c.which("codex")) {
101
+ run("codex", ["mcp", "remove", NAME]);
102
+ if (run("codex", ["mcp", "add", NAME, "--url", url])) return { file, replaced: false };
103
+ }
104
+ const replaced = Boolean(files.getTomlServerUrl(file, NAME));
105
+ files.setTomlServer(file, NAME, url);
106
+ return { file, replaced };
107
+ },
108
+ remove(c) {
109
+ if (c.which("codex") && run("codex", ["mcp", "remove", NAME])) return true;
110
+ return files.removeTomlServer(this.file(c), NAME);
111
+ },
112
+ current(c) {
113
+ return files.getTomlServerUrl(this.file(c), NAME);
114
+ },
115
+ },
116
+ jsonTool({
117
+ id: "cursor",
118
+ name: "Cursor",
119
+ section: "mcpServers",
120
+ entry: (url) => ({ url }),
121
+ globalFile: (c) => path.join(c.home, ".cursor", "mcp.json"),
122
+ projectFile: path.join(".cursor", "mcp.json"),
123
+ detect: () => ctx.which("cursor") || fs.existsSync(path.join(ctx.home, ".cursor")),
124
+ signIn: "Open Cursor Settings, MCP, and click Connect next to likho.",
125
+ }),
126
+ jsonTool({
127
+ id: "vscode",
128
+ name: "VS Code",
129
+ section: "servers",
130
+ entry: (url) => ({ type: "http", url }),
131
+ globalFile: (c) => path.join(vscodeUserDir(c), "mcp.json"),
132
+ projectFile: path.join(".vscode", "mcp.json"),
133
+ detect: () => ctx.which("code") || fs.existsSync(vscodeUserDir(ctx)),
134
+ signIn: "In VS Code open the MCP servers list and click Start on likho.",
135
+ }),
136
+ jsonTool({
137
+ id: "gemini",
138
+ name: "Gemini CLI",
139
+ section: "mcpServers",
140
+ entry: (url) => ({ httpUrl: url }),
141
+ globalFile: (c) => path.join(c.home, ".gemini", "settings.json"),
142
+ projectFile: path.join(".gemini", "settings.json"),
143
+ detect: () => ctx.which("gemini") || fs.existsSync(path.join(ctx.home, ".gemini")),
144
+ signIn: "In Gemini CLI run: /mcp auth likho",
145
+ }),
146
+ jsonTool({
147
+ id: "opencode",
148
+ name: "opencode",
149
+ section: "mcp",
150
+ entry: (url) => ({ type: "remote", url, enabled: true }),
151
+ globalFile: (c) => path.join(c.home, ".config", "opencode", "opencode.json"),
152
+ projectFile: "opencode.json",
153
+ detect: () => ctx.which("opencode") || fs.existsSync(path.join(ctx.home, ".config", "opencode")),
154
+ signIn: "Run: opencode mcp auth likho",
155
+ }),
156
+ jsonTool({
157
+ id: "windsurf",
158
+ name: "Windsurf",
159
+ section: "mcpServers",
160
+ entry: (url) => ({ serverUrl: url }),
161
+ globalFile: (c) => path.join(c.home, ".codeium", "windsurf", "mcp_config.json"),
162
+ projectFile: null,
163
+ detect: () => fs.existsSync(path.join(ctx.home, ".codeium", "windsurf")),
164
+ signIn: "In Windsurf open Cascade's MCP list and refresh; sign in when asked.",
165
+ }),
166
+ ];
167
+ // Claude Desktop only takes remote servers through its Connectors screen.
168
+ list.push({
169
+ id: "claude-desktop",
170
+ name: "Claude Desktop",
171
+ manual: true,
172
+ detect: () => fs.existsSync(claudeDesktopDir(ctx)),
173
+ signIn: "In Claude Desktop: Settings, Connectors, Add custom connector, paste the URL.",
174
+ project: false,
175
+ global: true,
176
+ });
177
+ return list;
178
+ }
179
+
180
+ function context(overrides = {}) {
181
+ const home = process.env.LIKHO_CLI_HOME || require("node:os").homedir();
182
+ return {
183
+ home,
184
+ cwd: process.cwd(),
185
+ platform: process.platform,
186
+ appData: process.env.APPDATA || path.join(home, "AppData", "Roaming"),
187
+ which: onPath,
188
+ ...overrides,
189
+ };
190
+ }
191
+
192
+ module.exports = { tools, context, NAME };
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "likho-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Connect every AI tool on your computer (Claude Code, Codex, Cursor, VS Code, Gemini CLI, opencode, Windsurf) to Likho in one command.",
5
+ "license": "MIT",
6
+ "homepage": "https://likho.me",
7
+ "bin": {
8
+ "likho": "bin/likho.js",
9
+ "likho-cli": "bin/likho.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "lib",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test \"test/*.test.js\""
21
+ },
22
+ "keywords": [
23
+ "likho",
24
+ "mcp",
25
+ "claude-code",
26
+ "codex",
27
+ "cursor",
28
+ "ai-agents"
29
+ ]
30
+ }