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.
- package/README.md +5 -0
- package/bin/likho.js +165 -81
- 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
|
-
|
|
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
|
+
/** 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
|
-
|
|
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";
|
|
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
|
-
|
|
99
|
+
await loadUi();
|
|
90
100
|
try {
|
|
91
|
-
|
|
92
|
-
const scope = await chooseScope(opts, ctx,
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 (
|
|
110
|
-
chosen =
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
173
|
+
ui.outro("Nothing changed.");
|
|
117
174
|
return 0;
|
|
118
175
|
}
|
|
119
176
|
|
|
120
|
-
// Which organization each
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
}
|
|
169
|
-
|
|
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
|
-
|
|
263
|
+
await loadUi();
|
|
191
264
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
286
|
+
ui.log.error(`${t.name}: ${err.message}`);
|
|
203
287
|
}
|
|
204
288
|
}
|
|
205
|
-
|
|
289
|
+
ui.outro("Done. Restart those tools.");
|
|
206
290
|
return 0;
|
|
207
|
-
}
|
|
208
|
-
|
|
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.
|
|
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
|
}
|