likho-cli 0.4.0 → 0.5.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/bin/likho.js +27 -5
- package/lib/files.js +21 -0
- package/lib/helper.js +12 -5
- package/lib/tools.js +65 -2
- package/package.json +1 -1
package/bin/likho.js
CHANGED
|
@@ -88,10 +88,13 @@ function orgOfUrl(url) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Any folder but the home folder can be a project (a new one has no files yet). */
|
|
92
|
+
const inHome = (ctx) => path.resolve(ctx.cwd) === path.resolve(ctx.home);
|
|
93
|
+
|
|
91
94
|
async function chooseScope(opts, ctx, verb) {
|
|
92
95
|
if (opts.scope) return opts.scope;
|
|
93
|
-
if (
|
|
94
|
-
if (!interactive(opts)) return "project";
|
|
96
|
+
if (inHome(ctx)) return "global";
|
|
97
|
+
if (!interactive(opts)) return isProjectFolder(ctx) ? "project" : "global";
|
|
95
98
|
const folder = path.basename(ctx.cwd);
|
|
96
99
|
const setup = verb === "Set up";
|
|
97
100
|
return answer(ui.select({
|
|
@@ -277,6 +280,25 @@ async function connect(opts) {
|
|
|
277
280
|
}
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
// Old or extra Likho entries in the same tools (e.g. "likho-hub" from
|
|
284
|
+
// before the rename) are a second connection: offer to remove them.
|
|
285
|
+
const leftovers = done.flatMap((t) => (t.leftovers ? t.leftovers(ctx, scope).map((name) => ({ t, name })) : []));
|
|
286
|
+
if (leftovers.length) {
|
|
287
|
+
const list = leftovers.map(({ t, name }) => `"${name}" in ${t.name}`).join(", ");
|
|
288
|
+
const clean = interactive(opts)
|
|
289
|
+
? await answer(ui.confirm({ message: `Also found older Likho connections: ${list}. Remove them? (recommended)`, initialValue: true }))
|
|
290
|
+
: true;
|
|
291
|
+
if (clean) {
|
|
292
|
+
for (const { t, name } of leftovers) {
|
|
293
|
+
try {
|
|
294
|
+
if (t.removeOld(ctx, scope, name)) ui.log.success(`Removed the old "${name}" from ${t.name}`);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
ui.log.error(`${t.name}: couldn't remove "${name}": ${err.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
280
302
|
let helperOn = false;
|
|
281
303
|
if (pair && Object.keys(helperCodes).length) {
|
|
282
304
|
try {
|
|
@@ -285,7 +307,7 @@ async function connect(opts) {
|
|
|
285
307
|
const { key, org } = await helper.redeem(opts.server, code, pair.verifier);
|
|
286
308
|
orgs.push({ slug: org.slug, name: org.name, key });
|
|
287
309
|
}
|
|
288
|
-
helper.install(opts.server, orgs,
|
|
310
|
+
helper.install(opts.server, orgs, inHome(ctx) ? "" : ctx.cwd);
|
|
289
311
|
helperOn = true;
|
|
290
312
|
ui.log.success(`Wake-ups on for ${orgs.map((o) => o.name).join(", ")}: this computer wakes your agents when they're tagged, while it's on.`);
|
|
291
313
|
} catch (err) {
|
|
@@ -294,12 +316,12 @@ async function connect(opts) {
|
|
|
294
316
|
}
|
|
295
317
|
|
|
296
318
|
if (done.length) {
|
|
297
|
-
const lines = done.map((t) => `${t.name}: ${t.signIn}`);
|
|
319
|
+
const lines = done.map((t) => `${t.name}: ${(scope === "project" && t.projectSignIn) || t.signIn}`);
|
|
298
320
|
if (scope === "project") lines.push("", "The files written here hold only Likho's address, no keys: safe to commit.");
|
|
299
321
|
ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
|
|
300
322
|
}
|
|
301
323
|
if (helperOn) {
|
|
302
|
-
const where =
|
|
324
|
+
const where = !inHome(ctx)
|
|
303
325
|
? `Agents are only woken in folders set up like this one (${path.basename(ctx.cwd)}): run npx likho-cli in each other project folder too.`
|
|
304
326
|
: "Agents are only woken in project folders you set up: run npx likho-cli inside each project folder whose agents should wake.";
|
|
305
327
|
ui.note(
|
package/lib/files.js
CHANGED
|
@@ -64,6 +64,16 @@ function getEntry(file, section, name) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** Every entry in data[section], as {name: entry}. */
|
|
68
|
+
function listEntries(file, section) {
|
|
69
|
+
try {
|
|
70
|
+
const block = readJson(file)[section];
|
|
71
|
+
return block && typeof block === "object" ? block : {};
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
// ---- Codex's TOML: only the [mcp_servers.<name>] table is ever touched.
|
|
68
78
|
function tomlTable(name) {
|
|
69
79
|
return new RegExp(`^\\[mcp_servers\\.${name}\\][\\s\\S]*?(?=^\\[|(?![\\s\\S]))`, "m");
|
|
@@ -85,6 +95,15 @@ function getTomlServerUrl(file, name) {
|
|
|
85
95
|
return url ? url[1] : null;
|
|
86
96
|
}
|
|
87
97
|
|
|
98
|
+
/** {name: url} for every [mcp_servers.<name>] table. */
|
|
99
|
+
function listTomlServers(file) {
|
|
100
|
+
if (!fs.existsSync(file)) return {};
|
|
101
|
+
const out = {};
|
|
102
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
103
|
+
for (const m of raw.matchAll(/^\[mcp_servers\.([\w-]+)\]/gm)) out[m[1]] = getTomlServerUrl(file, m[1]) || "";
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
function removeTomlServer(file, name) {
|
|
89
108
|
if (!fs.existsSync(file)) return false;
|
|
90
109
|
const raw = fs.readFileSync(file, "utf8");
|
|
@@ -103,4 +122,6 @@ module.exports = {
|
|
|
103
122
|
setTomlServer,
|
|
104
123
|
getTomlServerUrl,
|
|
105
124
|
removeTomlServer,
|
|
125
|
+
listEntries,
|
|
126
|
+
listTomlServers,
|
|
106
127
|
};
|
package/lib/helper.js
CHANGED
|
@@ -27,7 +27,8 @@ const MAX_REPLY = 20_000;
|
|
|
27
27
|
// How each kind of agent runs without a window. Arguments are fixed: the
|
|
28
28
|
// message only ever travels on standard input, never on a command line.
|
|
29
29
|
const RUNNERS = {
|
|
30
|
-
|
|
30
|
+
// --max-turns: one wake-up can't run away; the reply reports its token usage.
|
|
31
|
+
"claude-code": (session) => ({ cmd: "claude", args: ["-p", "--output-format", "json", "--max-turns", "30", ...(session ? ["--resume", session] : [])] }),
|
|
31
32
|
codex: () => ({ cmd: "codex", args: ["exec", "--skip-git-repo-check", "-"] }),
|
|
32
33
|
gemini: () => ({ cmd: "gemini", args: [] }),
|
|
33
34
|
};
|
|
@@ -164,14 +165,20 @@ async function wake(cfg, org, job) {
|
|
|
164
165
|
const result = await run(cmd, args, { cwd: job.folder, input: job.prompt, env });
|
|
165
166
|
|
|
166
167
|
let reply = result.out.trim();
|
|
168
|
+
let tokens;
|
|
167
169
|
if (assistant === "claude-code") {
|
|
168
170
|
try {
|
|
169
171
|
const parsed = JSON.parse(reply);
|
|
172
|
+
// Every token the run processed (Likho's per-day limit counts these).
|
|
173
|
+
const u = parsed.usage || {};
|
|
174
|
+
const sum = ["input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"]
|
|
175
|
+
.reduce((n, k) => n + (Number(u[k]) || 0), 0);
|
|
176
|
+
if (sum > 0) tokens = sum;
|
|
170
177
|
if (parsed.session_id) {
|
|
171
178
|
sessions[job.agent.number] = parsed.session_id;
|
|
172
179
|
fs.writeFileSync(SESSIONS, JSON.stringify(sessions, null, 2));
|
|
173
180
|
}
|
|
174
|
-
if (parsed.is_error && LIMIT_TEXT.test(String(parsed.result))) return finish({ status: "limited", retry_after: 3600 });
|
|
181
|
+
if (parsed.is_error && LIMIT_TEXT.test(String(parsed.result))) return finish({ status: "limited", retry_after: 3600, tokens });
|
|
175
182
|
reply = String(parsed.result || "").trim();
|
|
176
183
|
} catch {
|
|
177
184
|
// not JSON: an error message, handled below
|
|
@@ -183,9 +190,9 @@ async function wake(cfg, org, job) {
|
|
|
183
190
|
if (/not recognized|ENOENT|not found/i.test(result.err)) return finish({ status: "failed", error: `${cmd} isn't installed or isn't on this computer's PATH` });
|
|
184
191
|
return finish({ status: "failed", error: (result.err.trim().split("\n").pop() || `exited with ${result.code}`).slice(0, 250) });
|
|
185
192
|
}
|
|
186
|
-
if (!reply) return finish({ status: "failed", error: "it finished without an answer" });
|
|
187
|
-
log(`answered for ${job.agent.name}`);
|
|
188
|
-
return finish({ status: "done", reply: reply.slice(0, MAX_REPLY) });
|
|
193
|
+
if (!reply) return finish({ status: "failed", error: "it finished without an answer", tokens });
|
|
194
|
+
log(`answered for ${job.agent.name}${tokens ? ` (${tokens} tokens)` : ""}`);
|
|
195
|
+
return finish({ status: "done", reply: reply.slice(0, MAX_REPLY), tokens });
|
|
189
196
|
}
|
|
190
197
|
|
|
191
198
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/lib/tools.js
CHANGED
|
@@ -13,6 +13,24 @@ const files = require("./files");
|
|
|
13
13
|
|
|
14
14
|
const NAME = "likho";
|
|
15
15
|
|
|
16
|
+
// Likho's hosted MCP addresses, old and new. An entry under another name that
|
|
17
|
+
// points here (or the old name "likho-hub") is a leftover second connection.
|
|
18
|
+
const LIKHO_HOSTS = ["likho.me", "www.likho.me", "mcp.likho.me", "likho-mcp.onrender.com"];
|
|
19
|
+
|
|
20
|
+
function isOldLikho(name, entry) {
|
|
21
|
+
if (name === NAME) return false;
|
|
22
|
+
if (name === "likho-hub") return true;
|
|
23
|
+
const url = typeof entry === "string" ? entry : entry && (entry.url || entry.httpUrl || entry.serverUrl);
|
|
24
|
+
try {
|
|
25
|
+
return LIKHO_HOSTS.includes(new URL(url).host);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Names of old or extra Likho entries in a {name: entry} block. */
|
|
32
|
+
const oldNames = (block) => Object.entries(block || {}).filter(([n, e]) => isOldLikho(n, e)).map(([n]) => n);
|
|
33
|
+
|
|
16
34
|
function onPath(cmd) {
|
|
17
35
|
const probe = process.platform === "win32" ? spawnSync("where", [cmd], { encoding: "utf8" }) : spawnSync("which", [cmd], { encoding: "utf8" });
|
|
18
36
|
return probe.status === 0 && Boolean((probe.stdout || "").trim());
|
|
@@ -56,9 +74,35 @@ function jsonTool({ id, name, section, entry, globalFile, projectFile, detect, s
|
|
|
56
74
|
const found = files.getEntry(this.file(ctx, scope), section, NAME);
|
|
57
75
|
return found ? found.url || found.httpUrl || found.serverUrl || "(set)" : null;
|
|
58
76
|
},
|
|
77
|
+
leftovers(ctx, scope) {
|
|
78
|
+
return oldNames(files.listEntries(this.file(ctx, scope), section));
|
|
79
|
+
},
|
|
80
|
+
removeOld(ctx, scope, name) {
|
|
81
|
+
return files.removeEntry(this.file(ctx, scope), section, name);
|
|
82
|
+
},
|
|
59
83
|
};
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Claude Code keeps servers from a project's .mcp.json "pending approval"
|
|
88
|
+
* until someone approves them, and the VS Code panel never asks: the server
|
|
89
|
+
* just doesn't appear. The person running likho-cli has chosen to add it, so
|
|
90
|
+
* record that approval in the project's own, uncommitted local settings.
|
|
91
|
+
*/
|
|
92
|
+
function approveProjectServer(ctx) {
|
|
93
|
+
const file = path.join(ctx.cwd, ".claude", "settings.local.json");
|
|
94
|
+
try {
|
|
95
|
+
const data = files.readJson(file);
|
|
96
|
+
const list = Array.isArray(data.enabledMcpjsonServers) ? data.enabledMcpjsonServers : [];
|
|
97
|
+
if (list.includes(NAME)) return;
|
|
98
|
+
data.enabledMcpjsonServers = [...list, NAME];
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
101
|
+
} catch {
|
|
102
|
+
// unreadable local settings: Claude Code will ask instead
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
function tools(ctx) {
|
|
63
107
|
const list = [
|
|
64
108
|
{
|
|
@@ -66,6 +110,9 @@ function tools(ctx) {
|
|
|
66
110
|
name: "Claude Code",
|
|
67
111
|
detect: () => ctx.which("claude") || fs.existsSync(path.join(ctx.home, ".claude.json")) || fs.existsSync(path.join(ctx.home, ".claude")),
|
|
68
112
|
signIn: "In Claude Code run /mcp, pick likho, choose Authenticate.",
|
|
113
|
+
// A new folder's .mcp.json stays "pending approval" until Claude Code is
|
|
114
|
+
// run there once in a terminal (the VS Code panel doesn't ask).
|
|
115
|
+
projectSignIn: "If likho isn't in /mcp: run `claude` once in a terminal in this folder, trust it and approve likho. Then /mcp, pick likho, Authenticate.",
|
|
69
116
|
project: true,
|
|
70
117
|
global: true,
|
|
71
118
|
file: (c, scope) => (scope === "project" ? path.join(c.cwd, ".mcp.json") : path.join(c.home, ".claude.json")),
|
|
@@ -76,7 +123,9 @@ function tools(ctx) {
|
|
|
76
123
|
run("claude", ["mcp", "remove", NAME, "--scope", "user"]);
|
|
77
124
|
if (run("claude", ["mcp", "add", "--transport", "http", "--scope", "user", NAME, url])) return { file, replaced: false };
|
|
78
125
|
}
|
|
79
|
-
|
|
126
|
+
const replaced = Boolean(files.setEntry(file, "mcpServers", NAME, { type: "http", url }));
|
|
127
|
+
if (scope === "project") approveProjectServer(c);
|
|
128
|
+
return { file, replaced };
|
|
80
129
|
},
|
|
81
130
|
remove(c, scope) {
|
|
82
131
|
if (scope === "global" && c.which("claude") && run("claude", ["mcp", "remove", NAME, "--scope", "user"])) return true;
|
|
@@ -86,6 +135,13 @@ function tools(ctx) {
|
|
|
86
135
|
const found = files.getEntry(this.file(c, scope), "mcpServers", NAME);
|
|
87
136
|
return found ? found.url || "(set)" : null;
|
|
88
137
|
},
|
|
138
|
+
leftovers(c, scope) {
|
|
139
|
+
return oldNames(files.listEntries(this.file(c, scope), "mcpServers"));
|
|
140
|
+
},
|
|
141
|
+
removeOld(c, scope, name) {
|
|
142
|
+
if (scope === "global" && c.which("claude") && run("claude", ["mcp", "remove", name, "--scope", "user"])) return true;
|
|
143
|
+
return files.removeEntry(this.file(c, scope), "mcpServers", name);
|
|
144
|
+
},
|
|
89
145
|
},
|
|
90
146
|
{
|
|
91
147
|
id: "codex",
|
|
@@ -112,6 +168,13 @@ function tools(ctx) {
|
|
|
112
168
|
current(c) {
|
|
113
169
|
return files.getTomlServerUrl(this.file(c), NAME);
|
|
114
170
|
},
|
|
171
|
+
leftovers(c) {
|
|
172
|
+
return oldNames(files.listTomlServers(this.file(c)));
|
|
173
|
+
},
|
|
174
|
+
removeOld(c, scope, name) {
|
|
175
|
+
if (c.which("codex") && run("codex", ["mcp", "remove", name])) return true;
|
|
176
|
+
return files.removeTomlServer(this.file(c), name);
|
|
177
|
+
},
|
|
115
178
|
},
|
|
116
179
|
jsonTool({
|
|
117
180
|
id: "cursor",
|
|
@@ -189,4 +252,4 @@ function context(overrides = {}) {
|
|
|
189
252
|
};
|
|
190
253
|
}
|
|
191
254
|
|
|
192
|
-
module.exports = { tools, context, NAME };
|
|
255
|
+
module.exports = { tools, context, NAME, isOldLikho };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "likho-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|