likho-cli 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/bin/likho.js +165 -81
  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,46 @@ 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
+ /** 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) {
64
70
  if (opts.scope) return opts.scope;
65
71
  if (!isProjectFolder(ctx)) return "global";
72
+ if (!interactive(opts)) return "project";
66
73
  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";
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
+ }));
79
83
  }
80
84
 
81
85
  function selectTools(opts, ctx) {
@@ -84,89 +88,158 @@ function selectTools(opts, ctx) {
84
88
  return list;
85
89
  }
86
90
 
91
+ function cancelled(err) {
92
+ if (!(err instanceof Cancelled)) throw err;
93
+ ui.cancel("Cancelled. Nothing was changed.");
94
+ return 1;
95
+ }
96
+
87
97
  async function connect(opts) {
88
98
  const ctx = context();
89
- const ask = makeAsk(opts);
99
+ await loadUi();
90
100
  try {
91
- say("\n Likho: connect your AI tools\n");
92
- const scope = await chooseScope(opts, ctx, ask, "Set up");
101
+ ui.intro("Likho: connect your AI tools");
102
+ const scope = await chooseScope(opts, ctx, "Set up");
93
103
  const found = selectTools(opts, ctx).filter((t) => opts.tools || t.detect());
94
104
  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}`);
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}`);
98
107
  return 1;
99
108
  }
100
109
  const usable = found.filter((t) => (scope === "project" ? t.project : t.global));
101
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
+ }
102
118
 
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;
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
+ };
107
130
 
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);
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);
113
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).`);
114
171
  }
115
172
  if (!chosen.length) {
116
- say("\n Nothing to do.");
173
+ ui.outro("Nothing changed.");
117
174
  return 0;
118
175
  }
119
176
 
120
- // Which organization each tool lands in.
177
+ // Which organization each lands in: picked in the browser, signed in.
121
178
  let orgFor;
122
179
  if (opts.org) {
123
180
  orgFor = () => opts.org;
124
181
  } 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
- });
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
+ );
130
198
  if (!choice) {
131
- say(" Cancelled. Nothing was changed.");
132
- return 1;
199
+ spin.stop("Cancelled in the browser", 1);
200
+ throw new Cancelled();
133
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));
134
205
  orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
135
206
  }
207
+ if (!chosen.length) {
208
+ ui.outro("No tools picked. Nothing was changed.");
209
+ return 0;
210
+ }
136
211
 
137
- say(" Connecting...");
138
212
  const done = [];
139
213
  for (const t of chosen) {
140
214
  const org = orgFor(t);
141
215
  const url = mcpUrl(opts, org);
142
216
  if (t.manual) {
143
- say(` ! ${t.name}: add it yourself. ${t.signIn}`);
144
- say(` URL: ${url}`);
217
+ ui.log.warn(`${t.name}: add it yourself. ${t.signIn}\nURL: ${url}`);
145
218
  continue;
146
219
  }
147
220
  try {
148
221
  const result = t.install(ctx, scope, url);
149
- say(` ✓ ${t.name}${org ? ` → ${org}` : ""}${result.replaced ? " (updated)" : ""}`);
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)" : ""}`);
150
224
  done.push(t);
151
225
  } 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
- }
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}`);
158
229
  }
159
230
  }
160
231
 
161
232
  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.");
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.");
166
239
  }
167
240
  return done.length ? 0 : 1;
168
- } finally {
169
- ask.close();
241
+ } catch (err) {
242
+ return cancelled(err);
170
243
  }
171
244
  }
172
245
 
@@ -187,25 +260,36 @@ function status(opts) {
187
260
 
188
261
  async function remove(opts) {
189
262
  const ctx = context();
190
- const ask = makeAsk(opts);
263
+ await loadUi();
191
264
  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;
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) {
196
283
  try {
197
- if (t.remove(ctx, scope)) {
198
- say(` ✓ removed from ${t.name}`);
199
- removed += 1;
200
- }
284
+ if (t.remove(ctx, scope)) ui.log.success(`Removed from ${t.name}`);
201
285
  } catch (err) {
202
- say(` ! ${t.name}: ${err.message}`);
286
+ ui.log.error(`${t.name}: ${err.message}`);
203
287
  }
204
288
  }
205
- say(removed ? "\n Done. Restart those tools." : " Likho wasn't connected there.");
289
+ ui.outro("Done. Restart those tools.");
206
290
  return 0;
207
- } finally {
208
- ask.close();
291
+ } catch (err) {
292
+ return cancelled(err);
209
293
  }
210
294
  }
211
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "likho-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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
  }