likho-cli 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,44 @@
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
+ Run it again any time: tools already connected are shown with their
20
+ organization and start unticked, so it only adds what's missing. Ticking a
21
+ connected tool asks whether to keep it or switch it to another organization;
22
+ nothing is switched silently.
23
+
24
+ Then restart the tools, sign in once in each (one click, your browser is
25
+ already signed in), and say "join Likho" in any chat.
26
+
27
+ ## Commands
28
+
29
+ | Command | What it does |
30
+ |---|---|
31
+ | `npx likho-cli` | Set up (asks this project or all projects) |
32
+ | `npx likho-cli status` | What is connected where |
33
+ | `npx likho-cli remove` | Take Likho out again |
34
+
35
+ Options: `--here` (this project only), `--global` (all projects),
36
+ `--tool codex,cursor` (only these tools), `--org <slug>` (skip the browser),
37
+ `--yes` (no questions), `--server <url>` (another Likho).
38
+
39
+ ## What it writes
40
+
41
+ Only Likho's address (`https://likho.me/mcp?org=<organization>`), never a key.
42
+ Project files (`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`,
43
+ `.gemini/settings.json`, `opencode.json`) are safe to commit. Codex and
44
+ Windsurf only have settings for all projects.
package/bin/likho.js ADDED
@@ -0,0 +1,321 @@
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 { tools, context } = require("../lib/tools");
16
+ const { chooseInBrowser } = require("../lib/browser");
17
+ const { UnreadableFile } = require("../lib/files");
18
+
19
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "composer.json", "Gemfile", "pom.xml", "build.gradle", ".mcp.json"];
20
+ const SUPPORT = "https://likho.me/dashboard/organizations";
21
+
22
+ function parseArgs(argv) {
23
+ const opts = { command: "connect", tools: null, org: "", server: process.env.LIKHO_URL || "https://likho.me", scope: "", yes: false };
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ if (a === "status" || a === "remove" || a === "help") opts.command = a;
27
+ else if (a === "--here" || a === "--project") opts.scope = "project";
28
+ else if (a === "--global" || a === "--everywhere") opts.scope = "global";
29
+ else if (a === "--yes" || a === "-y") opts.yes = true;
30
+ else if (a === "--tool") opts.tools = (argv[++i] || "").split(",").filter(Boolean);
31
+ else if (a === "--org") opts.org = argv[++i] || "";
32
+ else if (a === "--server") opts.server = (argv[++i] || opts.server).replace(/\/+$/, "");
33
+ else if (a === "-h" || a === "--help") opts.command = "help";
34
+ }
35
+ return opts;
36
+ }
37
+
38
+ function isProjectFolder(ctx) {
39
+ if (path.resolve(ctx.cwd) === path.resolve(ctx.home)) return false;
40
+ return PROJECT_MARKERS.some((m) => fs.existsSync(path.join(ctx.cwd, m)));
41
+ }
42
+
43
+ // Questions use arrow keys and space (@clack/prompts). Without a terminal to
44
+ // ask in (CI, piped) or with --yes, every question takes its default.
45
+ const interactive = (opts) => Boolean(process.stdin.isTTY && process.stdout.isTTY && !opts.yes);
46
+ let ui;
47
+ const loadUi = async () => (ui = ui || (await import("@clack/prompts")));
48
+
49
+ class Cancelled extends Error {}
50
+
51
+ async function answer(question) {
52
+ const value = await question;
53
+ if (ui.isCancel(value)) throw new Cancelled();
54
+ return value;
55
+ }
56
+
57
+ const say = (line = "") => console.log(line);
58
+ const mcpUrl = (opts, org) => `${opts.server}/mcp${org ? `?org=${encodeURIComponent(org)}` : ""}`;
59
+
60
+ /** The organization in a Likho address written by this tool ("" if none). */
61
+ function orgOfUrl(url) {
62
+ try {
63
+ return new URL(url).searchParams.get("org") || "";
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ async function chooseScope(opts, ctx, verb) {
70
+ if (opts.scope) return opts.scope;
71
+ if (!isProjectFolder(ctx)) return "global";
72
+ if (!interactive(opts)) return "project";
73
+ const folder = path.basename(ctx.cwd);
74
+ const setup = verb === "Set up";
75
+ return answer(ui.select({
76
+ message: `${verb} Likho for`,
77
+ options: [
78
+ { value: "project", label: `Just this project (${folder})`, hint: setup ? "every AI tool opened here uses one organization" : undefined },
79
+ { value: "global", label: "All projects on this computer", hint: setup ? "an organization for each tool" : undefined },
80
+ ],
81
+ initialValue: "project",
82
+ }));
83
+ }
84
+
85
+ function selectTools(opts, ctx) {
86
+ let list = tools(ctx);
87
+ if (opts.tools) list = list.filter((t) => opts.tools.includes(t.id));
88
+ return list;
89
+ }
90
+
91
+ function cancelled(err) {
92
+ if (!(err instanceof Cancelled)) throw err;
93
+ ui.cancel("Cancelled. Nothing was changed.");
94
+ return 1;
95
+ }
96
+
97
+ async function connect(opts) {
98
+ const ctx = context();
99
+ await loadUi();
100
+ try {
101
+ ui.intro("Likho: connect your AI tools");
102
+ const scope = await chooseScope(opts, ctx, "Set up");
103
+ const found = selectTools(opts, ctx).filter((t) => opts.tools || t.detect());
104
+ if (!found.length) {
105
+ ui.log.warn("No supported AI tools found on this computer.\nSupported: Claude Code, Codex, Cursor, VS Code, Gemini CLI, opencode, Windsurf, Claude Desktop.");
106
+ ui.outro(`To connect another tool by hand, see Connect in your Likho dashboard: ${SUPPORT}`);
107
+ return 1;
108
+ }
109
+ const usable = found.filter((t) => (scope === "project" ? t.project : t.global));
110
+ const elsewhere = found.filter((t) => !usable.includes(t));
111
+ if (elsewhere.length) {
112
+ ui.log.info(`${elsewhere.map((t) => t.name).join(", ")} can only be set up for all projects: run npx likho-cli --global for ${elsewhere.length === 1 ? "it" : "them"}.`);
113
+ }
114
+ if (!usable.length) {
115
+ ui.outro("Nothing to set up here.");
116
+ return 1;
117
+ }
118
+
119
+ // What is already set up, so running again adds what's missing and keeps
120
+ // what works. In a project, the all-projects setting is shown too: the
121
+ // project one overrides it in this folder.
122
+ const existing = new Map(usable.filter((t) => !t.manual).map((t) => [t.id, t.current(ctx, scope)]).filter(([, url]) => url));
123
+ const inherited = (t) => (scope === "project" && t.global && !t.manual ? t.current(ctx, "global") : null);
124
+ const hintFor = (t) => {
125
+ if (t.manual) return "you add it in its settings";
126
+ if (existing.has(t.id)) return `already connected → ${orgOfUrl(existing.get(t.id)) || "Likho"}`;
127
+ const all = inherited(t);
128
+ return all ? `connected for all projects → ${orgOfUrl(all) || "Likho"}; this would override it here` : undefined;
129
+ };
130
+
131
+ let chosen = usable.filter((t) => !existing.has(t.id));
132
+ if (opts.tools) {
133
+ chosen = usable; // named on the command line: set up as asked
134
+ } else if (interactive(opts)) {
135
+ if (!chosen.length) {
136
+ ui.log.info(`Likho is already set up here: ${usable.filter((t) => existing.has(t.id)).map((t) => `${t.name} → ${orgOfUrl(existing.get(t.id)) || "Likho"}`).join(", ")}`);
137
+ const next = await answer(ui.select({
138
+ message: "Anything to change?",
139
+ options: [
140
+ { value: "keep", label: "No, keep everything as it is" },
141
+ { value: "change", label: "Yes, switch a tool to another organization" },
142
+ ],
143
+ initialValue: "keep",
144
+ }));
145
+ if (next === "keep") {
146
+ ui.outro("Nothing to do.");
147
+ return 0;
148
+ }
149
+ }
150
+ const ids = await answer(ui.multiselect({
151
+ message: `Which AI tools should Likho connect${scope === "project" ? ` in ${path.basename(ctx.cwd)}` : ""}?`,
152
+ options: usable.map((t) => ({ value: t.id, label: t.name, hint: hintFor(t) })),
153
+ initialValues: chosen.map((t) => t.id),
154
+ required: false,
155
+ }));
156
+ chosen = usable.filter((t) => ids.includes(t.id));
157
+ // Ticking a connected tool means "switch it": confirm, never silently.
158
+ for (const t of chosen.filter((x) => existing.has(x.id))) {
159
+ const what = await answer(ui.select({
160
+ message: `${t.name} is already connected to ${orgOfUrl(existing.get(t.id)) || "Likho"}. What now?`,
161
+ options: [
162
+ { value: "keep", label: "Keep it as it is" },
163
+ { value: "switch", label: "Switch to another organization", hint: `you'll sign in again in ${t.name}` },
164
+ ],
165
+ initialValue: "keep",
166
+ }));
167
+ if (what === "keep") chosen = chosen.filter((x) => x !== t);
168
+ }
169
+ } else if (existing.size) {
170
+ ui.log.info(`Kept as they are: ${usable.filter((t) => existing.has(t.id)).map((t) => t.name).join(", ")} (already connected).`);
171
+ }
172
+ if (!chosen.length) {
173
+ ui.outro("Nothing changed.");
174
+ return 0;
175
+ }
176
+
177
+ // Which organization each lands in: picked in the browser, signed in.
178
+ let orgFor;
179
+ if (opts.org) {
180
+ orgFor = () => opts.org;
181
+ } else {
182
+ const spin = ui.spinner();
183
+ const choice = await chooseInBrowser(
184
+ opts.server,
185
+ { scope, tools: chosen.map((t) => t.id).join(","), folder: scope === "project" ? path.basename(ctx.cwd) : "" },
186
+ {
187
+ log: (line) => {
188
+ const text = line.trim();
189
+ if (text.startsWith("http")) {
190
+ ui.log.message(`If it didn't open: ${text}`);
191
+ spin.start("Pick the organization in your browser");
192
+ } else if (text) {
193
+ ui.log.step(text.replace(/\.\.\.$/, ""));
194
+ }
195
+ },
196
+ }
197
+ );
198
+ if (!choice) {
199
+ spin.stop("Cancelled in the browser", 1);
200
+ throw new Cancelled();
201
+ }
202
+ spin.stop(choice.org ? `Organization: ${choice.org}` : "Organizations chosen");
203
+ // The page can untick tools too; older pages don't send `tools`.
204
+ if (Array.isArray(choice.tools)) chosen = chosen.filter((t) => choice.tools.includes(t.id));
205
+ orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
206
+ }
207
+ if (!chosen.length) {
208
+ ui.outro("No tools picked. Nothing was changed.");
209
+ return 0;
210
+ }
211
+
212
+ const done = [];
213
+ for (const t of chosen) {
214
+ const org = orgFor(t);
215
+ const url = mcpUrl(opts, org);
216
+ if (t.manual) {
217
+ ui.log.warn(`${t.name}: add it yourself. ${t.signIn}\nURL: ${url}`);
218
+ continue;
219
+ }
220
+ try {
221
+ const result = t.install(ctx, scope, url);
222
+ const was = existing.has(t.id) ? orgOfUrl(existing.get(t.id)) : "";
223
+ ui.log.success(`${t.name}${org ? ` → ${org}` : ""}${was && was !== org ? ` (was ${was})` : result.replaced ? " (updated)" : ""}`);
224
+ done.push(t);
225
+ } catch (err) {
226
+ ui.log.error(err instanceof UnreadableFile
227
+ ? `${t.name}: ${err.message} has comments or isn't plain JSON, so it was left alone.\nAdd a server named "likho" with URL ${url} to it yourself.`
228
+ : `${t.name}: ${err.message}`);
229
+ }
230
+ }
231
+
232
+ if (done.length) {
233
+ const lines = done.map((t) => `${t.name}: ${t.signIn}`);
234
+ if (scope === "project") lines.push("", "The files written here hold only Likho's address, no keys: safe to commit.");
235
+ ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
236
+ ui.outro('Then, in any chat: "join Likho"');
237
+ } else {
238
+ ui.outro("Nothing was connected.");
239
+ }
240
+ return done.length ? 0 : 1;
241
+ } catch (err) {
242
+ return cancelled(err);
243
+ }
244
+ }
245
+
246
+ function status(opts) {
247
+ const ctx = context();
248
+ const inProject = isProjectFolder(ctx);
249
+ say("\n Likho connections\n");
250
+ for (const t of selectTools(opts, ctx)) {
251
+ if (t.manual) continue;
252
+ const rows = [];
253
+ if (t.global) rows.push(`all projects: ${t.current(ctx, "global") || "not connected"}`);
254
+ if (t.project && inProject) rows.push(`this project: ${t.current(ctx, "project") || "not connected"}`);
255
+ say(` ${t.name.padEnd(14)} ${t.detect() ? "" : "(not installed) "}${rows.join(" · ")}`);
256
+ }
257
+ say();
258
+ return 0;
259
+ }
260
+
261
+ async function remove(opts) {
262
+ const ctx = context();
263
+ await loadUi();
264
+ try {
265
+ ui.intro("Likho: remove from your AI tools");
266
+ const scope = await chooseScope(opts, ctx, "Remove");
267
+ const connected = selectTools(opts, ctx).filter((t) => !t.manual && (scope === "project" ? t.project : t.global) && t.current(ctx, scope));
268
+ if (!connected.length) {
269
+ ui.outro("Likho isn't connected there.");
270
+ return 0;
271
+ }
272
+ let chosen = connected;
273
+ if (!opts.tools && interactive(opts)) {
274
+ const ids = await answer(ui.multiselect({
275
+ message: "Remove Likho from which tools?",
276
+ options: connected.map((t) => ({ value: t.id, label: t.name })),
277
+ initialValues: connected.map((t) => t.id),
278
+ required: true,
279
+ }));
280
+ chosen = connected.filter((t) => ids.includes(t.id));
281
+ }
282
+ for (const t of chosen) {
283
+ try {
284
+ if (t.remove(ctx, scope)) ui.log.success(`Removed from ${t.name}`);
285
+ } catch (err) {
286
+ ui.log.error(`${t.name}: ${err.message}`);
287
+ }
288
+ }
289
+ ui.outro("Done. Restart those tools.");
290
+ return 0;
291
+ } catch (err) {
292
+ return cancelled(err);
293
+ }
294
+ }
295
+
296
+ function help() {
297
+ say(`
298
+ npx likho-cli Connect the AI tools on this computer to Likho.
299
+ Inside a project folder it asks: this project, or all projects.
300
+ npx likho-cli status Show what is connected where.
301
+ npx likho-cli remove Take Likho out of your tools.
302
+
303
+ --here Only this project folder.
304
+ --global All projects on this computer.
305
+ --tool codex,cursor Only these tools (claude-code, codex, cursor, vscode, gemini, opencode, windsurf).
306
+ --org <slug> Use this organization; skips the browser.
307
+ --yes Don't ask questions.
308
+ `);
309
+ return 0;
310
+ }
311
+
312
+ async function main() {
313
+ const opts = parseArgs(process.argv.slice(2));
314
+ const run = { connect, status, remove, help }[opts.command];
315
+ process.exitCode = await run(opts);
316
+ }
317
+
318
+ main().catch((err) => {
319
+ console.error(`\n likho: ${err.message}`);
320
+ process.exitCode = 1;
321
+ });
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,33 @@
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.3.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
+ "dependencies": {
31
+ "@clack/prompts": "^1.8.1"
32
+ }
33
+ }