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.
- package/README.md +5 -0
- package/bin/likho.js +192 -83
- 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
115
|
+
await loadUi();
|
|
90
116
|
try {
|
|
91
|
-
|
|
92
|
-
const scope = await chooseScope(opts, ctx,
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 (
|
|
110
|
-
chosen =
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
197
|
+
ui.outro("Nothing changed.");
|
|
117
198
|
return 0;
|
|
118
199
|
}
|
|
119
200
|
|
|
120
|
-
// Which organization each
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
}
|
|
169
|
-
|
|
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
|
-
|
|
181
|
-
if (t.
|
|
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
|
-
|
|
288
|
+
await loadUi();
|
|
191
289
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
311
|
+
ui.log.error(`${t.name}: ${err.message}`);
|
|
203
312
|
}
|
|
204
313
|
}
|
|
205
|
-
|
|
314
|
+
ui.outro("Done. Restart those tools.");
|
|
206
315
|
return 0;
|
|
207
|
-
}
|
|
208
|
-
|
|
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
|
|
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
|
}
|