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 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 (!isProjectFolder(ctx)) return "global";
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, isProjectFolder(ctx) ? ctx.cwd : "");
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 = isProjectFolder(ctx)
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
- "claude-code": (session) => ({ cmd: "claude", args: ["-p", "--output-format", "json", ...(session ? ["--resume", session] : [])] }),
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
- return { file, replaced: Boolean(files.setEntry(file, "mcpServers", NAME, { type: "http", url })) };
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.4.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",