likho-cli 0.1.0 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/bin/likho.js +192 -83
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -16,6 +16,11 @@ npx likho-cli
16
16
  4. It adds Likho to each tool's MCP settings, next to your other servers, and
17
17
  keeps a `.likho-backup` of any file it changes.
18
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
+
19
24
  Then restart the tools, sign in once in each (one click, your browser is
20
25
  already signed in), and say "join Likho" in any chat.
21
26
 
package/bin/likho.js CHANGED
@@ -12,7 +12,6 @@
12
12
 
13
13
  const fs = require("node:fs");
14
14
  const path = require("node:path");
15
- const readline = require("node:readline/promises");
16
15
  const { tools, context } = require("../lib/tools");
17
16
  const { chooseInBrowser } = require("../lib/browser");
18
17
  const { UnreadableFile } = require("../lib/files");
@@ -41,41 +40,62 @@ function isProjectFolder(ctx) {
41
40
  return PROJECT_MARKERS.some((m) => fs.existsSync(path.join(ctx.cwd, m)));
42
41
  }
43
42
 
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
- };
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;
58
55
  }
59
56
 
60
57
  const say = (line = "") => console.log(line);
61
58
  const mcpUrl = (opts, org) => `${opts.server}/mcp${org ? `?org=${encodeURIComponent(org)}` : ""}`;
62
59
 
63
- async function chooseScope(opts, ctx, ask, verb) {
60
+ /**
61
+ * The host a "likho" entry really points at, when that isn't this Likho
62
+ * ("" when it is). A shared or downloaded project can carry a settings file
63
+ * whose "likho" leads to someone else's server; it's never trusted silently.
64
+ */
65
+ function foreignHost(url, opts) {
66
+ try {
67
+ const u = new URL(url);
68
+ const ours = new URL(opts.server);
69
+ if (u.host === ours.host && u.protocol === ours.protocol && u.pathname.replace(/\/+$/, "") === "/mcp") return "";
70
+ return u.protocol === "https:" ? u.host : `${u.protocol}//${u.host} (not https)`;
71
+ } catch {
72
+ return String(url).slice(0, 60) || "an unreadable address";
73
+ }
74
+ }
75
+
76
+ /** The organization in a Likho address written by this tool ("" if none). */
77
+ function orgOfUrl(url) {
78
+ try {
79
+ return new URL(url).searchParams.get("org") || "";
80
+ } catch {
81
+ return "";
82
+ }
83
+ }
84
+
85
+ async function chooseScope(opts, ctx, verb) {
64
86
  if (opts.scope) return opts.scope;
65
87
  if (!isProjectFolder(ctx)) return "global";
88
+ if (!interactive(opts)) return "project";
66
89
  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";
90
+ const setup = verb === "Set up";
91
+ return answer(ui.select({
92
+ message: `${verb} Likho for`,
93
+ options: [
94
+ { value: "project", label: `Just this project (${folder})`, hint: setup ? "every AI tool opened here uses one organization" : undefined },
95
+ { value: "global", label: "All projects on this computer", hint: setup ? "an organization for each tool" : undefined },
96
+ ],
97
+ initialValue: "project",
98
+ }));
79
99
  }
80
100
 
81
101
  function selectTools(opts, ctx) {
@@ -84,89 +104,166 @@ function selectTools(opts, ctx) {
84
104
  return list;
85
105
  }
86
106
 
107
+ function cancelled(err) {
108
+ if (!(err instanceof Cancelled)) throw err;
109
+ ui.cancel("Cancelled. Nothing was changed.");
110
+ return 1;
111
+ }
112
+
87
113
  async function connect(opts) {
88
114
  const ctx = context();
89
- const ask = makeAsk(opts);
115
+ await loadUi();
90
116
  try {
91
- say("\n Likho: connect your AI tools\n");
92
- const scope = await chooseScope(opts, ctx, ask, "Set up");
117
+ ui.intro("Likho: connect your AI tools");
118
+ const scope = await chooseScope(opts, ctx, "Set up");
93
119
  const found = selectTools(opts, ctx).filter((t) => opts.tools || t.detect());
94
120
  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}`);
121
+ ui.log.warn("No supported AI tools found on this computer.\nSupported: Claude Code, Codex, Cursor, VS Code, Gemini CLI, opencode, Windsurf, Claude Desktop.");
122
+ ui.outro(`To connect another tool by hand, see Connect in your Likho dashboard: ${SUPPORT}`);
98
123
  return 1;
99
124
  }
100
125
  const usable = found.filter((t) => (scope === "project" ? t.project : t.global));
101
126
  const elsewhere = found.filter((t) => !usable.includes(t));
127
+ if (elsewhere.length) {
128
+ 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"}.`);
129
+ }
130
+ if (!usable.length) {
131
+ ui.outro("Nothing to set up here.");
132
+ return 1;
133
+ }
102
134
 
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;
135
+ // What is already set up, so running again adds what's missing and keeps
136
+ // what works. In a project, the all-projects setting is shown too: the
137
+ // project one overrides it in this folder.
138
+ const existing = new Map(usable.filter((t) => !t.manual).map((t) => [t.id, t.current(ctx, scope)]).filter(([, url]) => url));
139
+ // Entries named "likho" that point elsewhere are replaced, not kept.
140
+ const foreign = new Map([...existing].map(([id, url]) => [id, foreignHost(url, opts)]).filter(([, host]) => host));
141
+ for (const id of foreign.keys()) existing.delete(id);
142
+ if (foreign.size) {
143
+ const lines = usable.filter((t) => foreign.has(t.id)).map((t) => `${t.name}'s "likho" entry points to ${foreign.get(t.id)}, not Likho. It will be replaced.`);
144
+ ui.log.warn(lines.join("\n"));
145
+ }
146
+ const inherited = (t) => (scope === "project" && t.global && !t.manual ? t.current(ctx, "global") : null);
147
+ const hintFor = (t) => {
148
+ if (t.manual) return "you add it in its settings";
149
+ if (foreign.has(t.id)) return `⚠ points to ${foreign.get(t.id)}, not Likho: will be replaced`;
150
+ if (existing.has(t.id)) return `already connected → ${orgOfUrl(existing.get(t.id)) || "Likho"}`;
151
+ const all = inherited(t);
152
+ return all ? `connected for all projects → ${orgOfUrl(all) || "Likho"}; this would override it here` : undefined;
153
+ };
107
154
 
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);
155
+ let chosen = usable.filter((t) => !existing.has(t.id));
156
+ if (opts.tools) {
157
+ chosen = usable; // named on the command line: set up as asked
158
+ } else if (interactive(opts)) {
159
+ if (!chosen.length) {
160
+ 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(", ")}`);
161
+ const next = await answer(ui.select({
162
+ message: "Anything to change?",
163
+ options: [
164
+ { value: "keep", label: "No, keep everything as it is" },
165
+ { value: "change", label: "Yes, switch a tool to another organization" },
166
+ ],
167
+ initialValue: "keep",
168
+ }));
169
+ if (next === "keep") {
170
+ ui.outro("Nothing to do.");
171
+ return 0;
172
+ }
173
+ }
174
+ const ids = await answer(ui.multiselect({
175
+ message: `Which AI tools should Likho connect${scope === "project" ? ` in ${path.basename(ctx.cwd)}` : ""}?`,
176
+ options: usable.map((t) => ({ value: t.id, label: t.name, hint: hintFor(t) })),
177
+ initialValues: chosen.map((t) => t.id),
178
+ required: false,
179
+ }));
180
+ chosen = usable.filter((t) => ids.includes(t.id));
181
+ // Ticking a connected tool means "switch it": confirm, never silently.
182
+ for (const t of chosen.filter((x) => existing.has(x.id))) {
183
+ const what = await answer(ui.select({
184
+ message: `${t.name} is already connected to ${orgOfUrl(existing.get(t.id)) || "Likho"}. What now?`,
185
+ options: [
186
+ { value: "keep", label: "Keep it as it is" },
187
+ { value: "switch", label: "Switch to another organization", hint: `you'll sign in again in ${t.name}` },
188
+ ],
189
+ initialValue: "keep",
190
+ }));
191
+ if (what === "keep") chosen = chosen.filter((x) => x !== t);
113
192
  }
193
+ } else if (existing.size) {
194
+ ui.log.info(`Kept as they are: ${usable.filter((t) => existing.has(t.id)).map((t) => t.name).join(", ")} (already connected).`);
114
195
  }
115
196
  if (!chosen.length) {
116
- say("\n Nothing to do.");
197
+ ui.outro("Nothing changed.");
117
198
  return 0;
118
199
  }
119
200
 
120
- // Which organization each tool lands in.
201
+ // Which organization each lands in: picked in the browser, signed in.
121
202
  let orgFor;
122
203
  if (opts.org) {
123
204
  orgFor = () => opts.org;
124
205
  } 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
- });
206
+ const spin = ui.spinner();
207
+ const choice = await chooseInBrowser(
208
+ opts.server,
209
+ { scope, tools: chosen.map((t) => t.id).join(","), folder: scope === "project" ? path.basename(ctx.cwd) : "" },
210
+ {
211
+ log: (line) => {
212
+ const text = line.trim();
213
+ if (text.startsWith("http")) {
214
+ ui.log.message(`If it didn't open: ${text}`);
215
+ spin.start("Pick the organization in your browser");
216
+ } else if (text) {
217
+ ui.log.step(text.replace(/\.\.\.$/, ""));
218
+ }
219
+ },
220
+ }
221
+ );
130
222
  if (!choice) {
131
- say(" Cancelled. Nothing was changed.");
132
- return 1;
223
+ spin.stop("Cancelled in the browser", 1);
224
+ throw new Cancelled();
133
225
  }
226
+ spin.stop(choice.org ? `Organization: ${choice.org}` : "Organizations chosen");
227
+ // The page can untick tools too; older pages don't send `tools`.
228
+ if (Array.isArray(choice.tools)) chosen = chosen.filter((t) => choice.tools.includes(t.id));
134
229
  orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
135
230
  }
231
+ if (!chosen.length) {
232
+ ui.outro("No tools picked. Nothing was changed.");
233
+ return 0;
234
+ }
136
235
 
137
- say(" Connecting...");
138
236
  const done = [];
139
237
  for (const t of chosen) {
140
238
  const org = orgFor(t);
141
239
  const url = mcpUrl(opts, org);
142
240
  if (t.manual) {
143
- say(` ! ${t.name}: add it yourself. ${t.signIn}`);
144
- say(` URL: ${url}`);
241
+ ui.log.warn(`${t.name}: add it yourself. ${t.signIn}\nURL: ${url}`);
145
242
  continue;
146
243
  }
147
244
  try {
148
245
  const result = t.install(ctx, scope, url);
149
- say(` ✓ ${t.name}${org ? ` → ${org}` : ""}${result.replaced ? " (updated)" : ""}`);
246
+ const was = existing.has(t.id) ? orgOfUrl(existing.get(t.id)) : "";
247
+ ui.log.success(`${t.name}${org ? ` → ${org}` : ""}${was && was !== org ? ` (was ${was})` : result.replaced ? " (updated)" : ""}`);
150
248
  done.push(t);
151
249
  } 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
- }
250
+ ui.log.error(err instanceof UnreadableFile
251
+ ? `${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.`
252
+ : `${t.name}: ${err.message}`);
158
253
  }
159
254
  }
160
255
 
161
256
  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.");
257
+ const lines = done.map((t) => `${t.name}: ${t.signIn}`);
258
+ if (scope === "project") lines.push("", "The files written here hold only Likho's address, no keys: safe to commit.");
259
+ ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
260
+ ui.outro('Then, in any chat: "join Likho"');
261
+ } else {
262
+ ui.outro("Nothing was connected.");
166
263
  }
167
264
  return done.length ? 0 : 1;
168
- } finally {
169
- ask.close();
265
+ } catch (err) {
266
+ return cancelled(err);
170
267
  }
171
268
  }
172
269
 
@@ -177,8 +274,9 @@ function status(opts) {
177
274
  for (const t of selectTools(opts, ctx)) {
178
275
  if (t.manual) continue;
179
276
  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"}`);
277
+ const show = (url) => (!url ? "not connected" : foreignHost(url, opts) ? `⚠ ${url} (not Likho: run npx likho-cli to replace it)` : url);
278
+ if (t.global) rows.push(`all projects: ${show(t.current(ctx, "global"))}`);
279
+ if (t.project && inProject) rows.push(`this project: ${show(t.current(ctx, "project"))}`);
182
280
  say(` ${t.name.padEnd(14)} ${t.detect() ? "" : "(not installed) "}${rows.join(" · ")}`);
183
281
  }
184
282
  say();
@@ -187,25 +285,36 @@ function status(opts) {
187
285
 
188
286
  async function remove(opts) {
189
287
  const ctx = context();
190
- const ask = makeAsk(opts);
288
+ await loadUi();
191
289
  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;
290
+ ui.intro("Likho: remove from your AI tools");
291
+ const scope = await chooseScope(opts, ctx, "Remove");
292
+ const connected = selectTools(opts, ctx).filter((t) => !t.manual && (scope === "project" ? t.project : t.global) && t.current(ctx, scope));
293
+ if (!connected.length) {
294
+ ui.outro("Likho isn't connected there.");
295
+ return 0;
296
+ }
297
+ let chosen = connected;
298
+ if (!opts.tools && interactive(opts)) {
299
+ const ids = await answer(ui.multiselect({
300
+ message: "Remove Likho from which tools?",
301
+ options: connected.map((t) => ({ value: t.id, label: t.name })),
302
+ initialValues: connected.map((t) => t.id),
303
+ required: true,
304
+ }));
305
+ chosen = connected.filter((t) => ids.includes(t.id));
306
+ }
307
+ for (const t of chosen) {
196
308
  try {
197
- if (t.remove(ctx, scope)) {
198
- say(` ✓ removed from ${t.name}`);
199
- removed += 1;
200
- }
309
+ if (t.remove(ctx, scope)) ui.log.success(`Removed from ${t.name}`);
201
310
  } catch (err) {
202
- say(` ! ${t.name}: ${err.message}`);
311
+ ui.log.error(`${t.name}: ${err.message}`);
203
312
  }
204
313
  }
205
- say(removed ? "\n Done. Restart those tools." : " Likho wasn't connected there.");
314
+ ui.outro("Done. Restart those tools.");
206
315
  return 0;
207
- } finally {
208
- ask.close();
316
+ } catch (err) {
317
+ return cancelled(err);
209
318
  }
210
319
  }
211
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "likho-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
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
5
  "license": "MIT",
6
6
  "homepage": "https://likho.me",
@@ -26,5 +26,8 @@
26
26
  "codex",
27
27
  "cursor",
28
28
  "ai-agents"
29
- ]
29
+ ],
30
+ "dependencies": {
31
+ "@clack/prompts": "^1.8.1"
32
+ }
30
33
  }