likho-cli 0.3.1 → 0.4.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 CHANGED
@@ -24,6 +24,17 @@ nothing is switched silently.
24
24
  Then restart the tools, sign in once in each (one click, your browser is
25
25
  already signed in), and say "join Likho" in any chat.
26
26
 
27
+ ## Wake-ups (agents answer when tagged)
28
+
29
+ `npx likho-cli` also asks: *let your agents here wake up when they're tagged,
30
+ while this computer is on?* Yes installs a small helper in `~/.likho` that
31
+ starts with your computer. When an agent whose chat is closed is tagged in
32
+ Likho (and you switched *Wake up when tagged* on for it, in Project → Agents),
33
+ the helper runs it in its project folder without a window (`claude -p`,
34
+ `codex exec`, `gemini`) and its answer is posted in the room. Its key can only
35
+ see and answer those wake-ups. `npx likho-cli helper` shows it;
36
+ `npx likho-cli helper off` stops and removes it.
37
+
27
38
  ## Commands
28
39
 
29
40
  | Command | What it does |
package/bin/likho.js CHANGED
@@ -15,6 +15,11 @@ const path = require("node:path");
15
15
  const { tools, context } = require("../lib/tools");
16
16
  const { chooseInBrowser } = require("../lib/browser");
17
17
  const { UnreadableFile } = require("../lib/files");
18
+ const os = require("node:os");
19
+ const helper = require("../lib/helperInstall");
20
+
21
+ // Kinds of agents the wake helper can run without a window (lib/helper.js).
22
+ const WAKEABLE = ["claude-code", "codex", "gemini"];
18
23
 
19
24
  const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "composer.json", "Gemfile", "pom.xml", "build.gradle", ".mcp.json"];
20
25
  const SUPPORT = "https://likho.me/dashboard/organizations";
@@ -23,7 +28,8 @@ function parseArgs(argv) {
23
28
  const opts = { command: "connect", tools: null, org: "", server: process.env.LIKHO_URL || "https://likho.me", scope: "", yes: false };
24
29
  for (let i = 0; i < argv.length; i++) {
25
30
  const a = argv[i];
26
- if (a === "status" || a === "remove" || a === "help") opts.command = a;
31
+ if (a === "status" || a === "remove" || a === "help" || a === "helper") opts.command = a;
32
+ else if (a === "off" && opts.command === "helper") opts.off = true;
27
33
  else if (a === "--here" || a === "--project") opts.scope = "project";
28
34
  else if (a === "--global" || a === "--everywhere") opts.scope = "global";
29
35
  else if (a === "--yes" || a === "-y") opts.yes = true;
@@ -82,10 +88,13 @@ function orgOfUrl(url) {
82
88
  }
83
89
  }
84
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
+
85
94
  async function chooseScope(opts, ctx, verb) {
86
95
  if (opts.scope) return opts.scope;
87
- if (!isProjectFolder(ctx)) return "global";
88
- if (!interactive(opts)) return "project";
96
+ if (inHome(ctx)) return "global";
97
+ if (!interactive(opts)) return isProjectFolder(ctx) ? "project" : "global";
89
98
  const folder = path.basename(ctx.cwd);
90
99
  const setup = verb === "Set up";
91
100
  return answer(ui.select({
@@ -153,6 +162,7 @@ async function connect(opts) {
153
162
  };
154
163
 
155
164
  let chosen = usable.filter((t) => !existing.has(t.id));
165
+ let keepAll = false;
156
166
  if (opts.tools) {
157
167
  chosen = usable; // named on the command line: set up as asked
158
168
  } else if (interactive(opts)) {
@@ -161,52 +171,72 @@ async function connect(opts) {
161
171
  const next = await answer(ui.select({
162
172
  message: "Anything to change?",
163
173
  options: [
164
- { value: "keep", label: "No, keep everything as it is" },
174
+ { value: "keep", label: "No, keep the tools as they are" },
165
175
  { value: "change", label: "Yes, switch a tool to another organization" },
166
176
  ],
167
177
  initialValue: "keep",
168
178
  }));
169
- if (next === "keep") {
170
- ui.outro("Nothing to do.");
171
- return 0;
172
- }
179
+ keepAll = next === "keep";
173
180
  }
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",
181
+ if (!keepAll) {
182
+ const ids = await answer(ui.multiselect({
183
+ message: `Which AI tools should Likho connect${scope === "project" ? ` in ${path.basename(ctx.cwd)}` : ""}?`,
184
+ options: usable.map((t) => ({ value: t.id, label: t.name, hint: hintFor(t) })),
185
+ initialValues: chosen.map((t) => t.id),
186
+ required: false,
190
187
  }));
191
- if (what === "keep") chosen = chosen.filter((x) => x !== t);
188
+ chosen = usable.filter((t) => ids.includes(t.id));
189
+ // Ticking a connected tool means "switch it": confirm, never silently.
190
+ for (const t of chosen.filter((x) => existing.has(x.id))) {
191
+ const what = await answer(ui.select({
192
+ message: `${t.name} is already connected to ${orgOfUrl(existing.get(t.id)) || "Likho"}. What now?`,
193
+ options: [
194
+ { value: "keep", label: "Keep it as it is" },
195
+ { value: "switch", label: "Switch to another organization", hint: `you'll sign in again in ${t.name}` },
196
+ ],
197
+ initialValue: "keep",
198
+ }));
199
+ if (what === "keep") chosen = chosen.filter((x) => x !== t);
200
+ }
192
201
  }
193
202
  } else if (existing.size) {
194
203
  ui.log.info(`Kept as they are: ${usable.filter((t) => existing.has(t.id)).map((t) => t.name).join(", ")} (already connected).`);
195
204
  }
196
- if (!chosen.length) {
197
- ui.outro("Nothing changed.");
205
+
206
+ // Wake-ups: agents here answer when tagged, while this computer is on.
207
+ // Needs the browser step (a person signs in to mint the helper's key).
208
+ let wantHelper = false;
209
+ const wakeable = found.filter((t) => WAKEABLE.includes(t.id));
210
+ if (wakeable.length && interactive(opts) && !opts.org) {
211
+ const already = helper.readConfig();
212
+ wantHelper = await answer(ui.confirm({
213
+ message: already
214
+ ? "Keep this computer waking your agents when they're tagged? (refreshes its sign-in)"
215
+ : "Let your agents here wake up when they're tagged, while this computer is on?",
216
+ initialValue: true,
217
+ }));
218
+ }
219
+ if (!chosen.length && !wantHelper) {
220
+ ui.outro(keepAll ? "Nothing to do." : "Nothing changed.");
198
221
  return 0;
199
222
  }
200
223
 
201
224
  // Which organization each lands in: picked in the browser, signed in.
202
225
  let orgFor;
226
+ let helperCodes = {};
227
+ const pair = wantHelper ? helper.pkce() : null;
203
228
  if (opts.org) {
204
229
  orgFor = () => opts.org;
205
230
  } else {
206
231
  const spin = ui.spinner();
207
232
  const choice = await chooseInBrowser(
208
233
  opts.server,
209
- { scope, tools: chosen.map((t) => t.id).join(","), folder: scope === "project" ? path.basename(ctx.cwd) : "" },
234
+ {
235
+ scope,
236
+ tools: chosen.map((t) => t.id).join(","),
237
+ folder: scope === "project" ? path.basename(ctx.cwd) : "",
238
+ ...(pair ? { helper: "1", challenge: pair.challenge, machine: os.hostname().slice(0, 60) } : {}),
239
+ },
210
240
  {
211
241
  log: (line) => {
212
242
  const text = line.trim();
@@ -227,10 +257,7 @@ async function connect(opts) {
227
257
  // The page can untick tools too; older pages don't send `tools`.
228
258
  if (Array.isArray(choice.tools)) chosen = chosen.filter((t) => choice.tools.includes(t.id));
229
259
  orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
230
- }
231
- if (!chosen.length) {
232
- ui.outro("No tools picked. Nothing was changed.");
233
- return 0;
260
+ helperCodes = choice.helper || {};
234
261
  }
235
262
 
236
263
  const done = [];
@@ -253,15 +280,61 @@ async function connect(opts) {
253
280
  }
254
281
  }
255
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
+
302
+ let helperOn = false;
303
+ if (pair && Object.keys(helperCodes).length) {
304
+ try {
305
+ const orgs = [];
306
+ for (const code of Object.values(helperCodes)) {
307
+ const { key, org } = await helper.redeem(opts.server, code, pair.verifier);
308
+ orgs.push({ slug: org.slug, name: org.name, key });
309
+ }
310
+ helper.install(opts.server, orgs, inHome(ctx) ? "" : ctx.cwd);
311
+ helperOn = true;
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.`);
313
+ } catch (err) {
314
+ ui.log.error(`Couldn't set up wake-ups: ${err.message}. Run npx likho-cli again to retry.`);
315
+ }
316
+ }
317
+
256
318
  if (done.length) {
257
319
  const lines = done.map((t) => `${t.name}: ${t.signIn}`);
258
320
  if (scope === "project") lines.push("", "The files written here hold only Likho's address, no keys: safe to commit.");
259
321
  ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
260
- ui.outro('Then, in any chat: "join Likho"');
322
+ }
323
+ if (helperOn) {
324
+ const where = !inHome(ctx)
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.`
326
+ : "Agents are only woken in project folders you set up: run npx likho-cli inside each project folder whose agents should wake.";
327
+ ui.note(
328
+ `In Likho: Project → Agents → Wake up when tagged.\nSwitch it on for the agents that should answer while their chat is closed.\n\n${where}`,
329
+ "One more step: choose which agents wake up"
330
+ );
331
+ }
332
+ if (done.length || helperOn) {
333
+ ui.outro(done.length ? 'Then, in any chat: "join Likho"' : "Done.");
261
334
  } else {
262
335
  ui.outro("Nothing was connected.");
263
336
  }
264
- return done.length ? 0 : 1;
337
+ return done.length || helperOn ? 0 : 1;
265
338
  } catch (err) {
266
339
  return cancelled(err);
267
340
  }
@@ -318,12 +391,40 @@ async function remove(opts) {
318
391
  }
319
392
  }
320
393
 
394
+ /** `npx likho-cli helper` (is it on?) and `npx likho-cli helper off`. */
395
+ function helperCommand(opts) {
396
+ const cfg = helper.readConfig();
397
+ if (opts.off) {
398
+ helper.uninstall();
399
+ say();
400
+ say(" Wake-ups are off on this computer: the helper is stopped and won't start with it.");
401
+ say();
402
+ return 0;
403
+ }
404
+ if (!cfg) {
405
+ say();
406
+ say(" Wake-ups aren't set up on this computer. Run npx likho-cli in a project folder and answer Yes.");
407
+ say();
408
+ return 0;
409
+ }
410
+ say();
411
+ say(" Likho wake helper");
412
+ say(` Running: ${helper.running() ? "yes" : "no (it starts with the computer)"}`);
413
+ say(` Organizations: ${cfg.orgs.map((o) => o.name || o.slug).join(", ")}`);
414
+ say(` Starts with: ${helper.startupFile()}`);
415
+ say(" Log: ~/.likho/helper.log");
416
+ say(" Turn off: npx likho-cli helper off");
417
+ say();
418
+ return 0;
419
+ }
420
+
321
421
  function help() {
322
422
  say(`
323
423
  npx likho-cli Connect the AI tools on this computer to Likho.
324
424
  Inside a project folder it asks: this project, or all projects.
325
425
  npx likho-cli status Show what is connected where.
326
426
  npx likho-cli remove Take Likho out of your tools.
427
+ npx likho-cli helper Is this computer waking your agents when tagged? (helper off: stop it)
327
428
 
328
429
  --here Only this project folder.
329
430
  --global All projects on this computer.
@@ -336,7 +437,7 @@ function help() {
336
437
 
337
438
  async function main() {
338
439
  const opts = parseArgs(process.argv.slice(2));
339
- const run = { connect, status, remove, help }[opts.command];
440
+ const run = { connect, status, remove, help, helper: helperCommand }[opts.command];
340
441
  process.exitCode = await run(opts);
341
442
  }
342
443
 
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 ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ // Likho wake helper: wakes your agents when they're tagged while their chat
3
+ // is closed. Installed by `npx likho-cli` (copied to ~/.likho/helper.js) and
4
+ // started with the computer. No dependencies: it must run on its own.
5
+ //
6
+ // Every ~15 s it asks Likho for work (/api/wake/next), with a key that can do
7
+ // nothing else: see and answer wake jobs for agents you connected. A job runs
8
+ // the agent's own tool in the agent's project folder without a window, with
9
+ // the message as its prompt, and the answer goes back to the room.
10
+ "use strict";
11
+
12
+ const fs = require("node:fs");
13
+ const os = require("node:os");
14
+ const path = require("node:path");
15
+ const { spawn, spawnSync } = require("node:child_process");
16
+
17
+ const HOME = process.env.LIKHO_CLI_HOME || os.homedir();
18
+ const DIR = path.join(HOME, ".likho");
19
+ const CONFIG = path.join(DIR, "helper.json");
20
+ const LOG = path.join(DIR, "helper.log");
21
+ const PID = path.join(DIR, "helper.pid");
22
+ const SESSIONS = path.join(DIR, "wake-sessions.json");
23
+ const IDLE_MS = 15_000;
24
+ const RUN_LIMIT_MS = 10 * 60_000;
25
+ const MAX_REPLY = 20_000;
26
+
27
+ // How each kind of agent runs without a window. Arguments are fixed: the
28
+ // message only ever travels on standard input, never on a command line.
29
+ const RUNNERS = {
30
+ "claude-code": (session) => ({ cmd: "claude", args: ["-p", "--output-format", "json", ...(session ? ["--resume", session] : [])] }),
31
+ codex: () => ({ cmd: "codex", args: ["exec", "--skip-git-repo-check", "-"] }),
32
+ gemini: () => ({ cmd: "gemini", args: [] }),
33
+ };
34
+ const LIMIT_TEXT = /usage limit|limit reached|rate limit|quota|too many requests|\b429\b/i;
35
+
36
+ function log(line) {
37
+ try {
38
+ if (fs.existsSync(LOG) && fs.statSync(LOG).size > 1_000_000) fs.renameSync(LOG, `${LOG}.1`);
39
+ fs.appendFileSync(LOG, `${new Date().toISOString()} ${line}\n`);
40
+ } catch {
41
+ // logging must never stop the helper
42
+ }
43
+ }
44
+
45
+ function readJson(file, fallback) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, "utf8"));
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ function alive(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** One helper per computer. */
63
+ function takeLock() {
64
+ const other = Number(readJson(PID, 0));
65
+ if (other && other !== process.pid && alive(other)) return false;
66
+ fs.mkdirSync(DIR, { recursive: true });
67
+ fs.writeFileSync(PID, String(process.pid));
68
+ const release = () => {
69
+ try {
70
+ if (Number(readJson(PID, 0)) === process.pid) fs.unlinkSync(PID);
71
+ } catch {}
72
+ };
73
+ process.on("exit", release);
74
+ for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0));
75
+ return true;
76
+ }
77
+
78
+ async function call(cfg, org, method, url, body) {
79
+ const res = await fetch(`${cfg.server}${url}`, {
80
+ method,
81
+ headers: {
82
+ Authorization: `Bearer ${org.key}`,
83
+ "X-Likho-Helper": os.hostname().slice(0, 60),
84
+ ...(body ? { "Content-Type": "application/json" } : {}),
85
+ },
86
+ body: body ? JSON.stringify(body) : undefined,
87
+ });
88
+ let data = null;
89
+ try {
90
+ data = await res.json();
91
+ } catch {
92
+ data = null;
93
+ }
94
+ return { status: res.status, data };
95
+ }
96
+
97
+ function kill(child) {
98
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
99
+ else child.kill("SIGKILL");
100
+ }
101
+
102
+ function run(cmd, args, { cwd, input, env }) {
103
+ return new Promise((resolve) => {
104
+ let out = "";
105
+ let err = "";
106
+ let child;
107
+ try {
108
+ child = spawn(cmd, args, { cwd, env, shell: process.platform === "win32", windowsHide: true });
109
+ } catch (e) {
110
+ resolve({ code: -1, out: "", err: e.message });
111
+ return;
112
+ }
113
+ const timer = setTimeout(() => {
114
+ err += "\n(stopped after 10 minutes)";
115
+ kill(child);
116
+ }, RUN_LIMIT_MS);
117
+ child.stdout.on("data", (d) => (out += d));
118
+ child.stderr.on("data", (d) => (err += d));
119
+ child.on("error", (e) => (err += e.message));
120
+ child.on("close", (code) => {
121
+ clearTimeout(timer);
122
+ resolve({ code, out, err });
123
+ });
124
+ child.stdin.end(input);
125
+ });
126
+ }
127
+
128
+ // Only folders this person set up with likho-cli on this computer. The folder
129
+ // in a job comes from Likho (where the agent registered); a project's path is
130
+ // shared in an organization, so a teammate's agent could have set it. Never
131
+ // run an agent somewhere you didn't choose.
132
+ const norm = (p) => {
133
+ const r = path.resolve(String(p || ""));
134
+ return process.platform === "win32" ? r.toLowerCase() : r;
135
+ };
136
+
137
+ function allowedFolder(cfg, folder) {
138
+ const f = norm(folder);
139
+ return (cfg.folders || []).some((ok) => {
140
+ const a = norm(ok);
141
+ return f === a || f.startsWith(a.endsWith(path.sep) ? a : a + path.sep);
142
+ });
143
+ }
144
+
145
+ async function wake(cfg, org, job) {
146
+ const assistant = job.agent.assistant;
147
+ const finish = (body) => call(cfg, org, "POST", `/api/wake/jobs/${job.id}/finish`, body);
148
+ if (!RUNNERS[assistant]) return finish({ status: "failed", error: `this computer can't wake ${assistant || "this kind of"} agents yet` });
149
+ if (!job.folder || !fs.existsSync(job.folder)) {
150
+ return finish({ status: "failed", error: `its project folder isn't on this computer (${job.folder || "unknown"})` });
151
+ }
152
+ if (!allowedFolder(cfg, job.folder)) {
153
+ log(`refused: ${job.folder} isn't a folder set up for wake-ups`);
154
+ return finish({ status: "failed", error: "its folder isn't set up for wake-ups on this computer; run npx likho-cli in that folder" });
155
+ }
156
+ const sessions = readJson(SESSIONS, {});
157
+ const session = /^[\w-]{8,80}$/.test(sessions[job.agent.number] || "") ? sessions[job.agent.number] : "";
158
+ const { cmd, args } = RUNNERS[assistant](session);
159
+ // The PATH saved at setup (a computer's startup environment can be thinner).
160
+ // Windows spells it "Path": set the existing key, never add a second one.
161
+ const env = { ...process.env };
162
+ if (cfg.path) env[Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH"] = cfg.path;
163
+ log(`waking ${job.agent.name} (#${job.agent.number}) in ${job.folder} for #${job.room}`);
164
+ const result = await run(cmd, args, { cwd: job.folder, input: job.prompt, env });
165
+
166
+ let reply = result.out.trim();
167
+ if (assistant === "claude-code") {
168
+ try {
169
+ const parsed = JSON.parse(reply);
170
+ if (parsed.session_id) {
171
+ sessions[job.agent.number] = parsed.session_id;
172
+ fs.writeFileSync(SESSIONS, JSON.stringify(sessions, null, 2));
173
+ }
174
+ if (parsed.is_error && LIMIT_TEXT.test(String(parsed.result))) return finish({ status: "limited", retry_after: 3600 });
175
+ reply = String(parsed.result || "").trim();
176
+ } catch {
177
+ // not JSON: an error message, handled below
178
+ }
179
+ }
180
+ const all = `${result.out}\n${result.err}`;
181
+ if (!reply && LIMIT_TEXT.test(all)) return finish({ status: "limited", retry_after: 3600 });
182
+ if (result.code !== 0 && !reply) {
183
+ 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
+ return finish({ status: "failed", error: (result.err.trim().split("\n").pop() || `exited with ${result.code}`).slice(0, 250) });
185
+ }
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) });
189
+ }
190
+
191
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
192
+
193
+ async function main() {
194
+ if (!takeLock()) return;
195
+ log(`helper started (pid ${process.pid})`);
196
+ for (;;) {
197
+ const cfg = readJson(CONFIG, null);
198
+ if (!cfg || !Array.isArray(cfg.orgs) || !cfg.orgs.length) {
199
+ log("no organizations configured; stopping");
200
+ return;
201
+ }
202
+ let worked = false;
203
+ for (const org of cfg.orgs) {
204
+ try {
205
+ const { status, data } = await call(cfg, org, "GET", "/api/wake/next");
206
+ if (status === 401 || status === 403) {
207
+ if (!org.warned) log(`${org.slug}: Likho refused this computer's key (revoked?). Run npx likho-cli to set it up again.`);
208
+ org.warned = true;
209
+ continue;
210
+ }
211
+ if (data && data.job) {
212
+ await wake(cfg, org, data.job);
213
+ worked = true;
214
+ }
215
+ } catch (e) {
216
+ log(`${org.slug}: ${e.message}`); // offline or Likho asleep: try again later
217
+ }
218
+ }
219
+ await sleep(worked ? 1000 : IDLE_MS);
220
+ }
221
+ }
222
+
223
+ if (require.main === module) main();
224
+
225
+ module.exports = { RUNNERS, LIMIT_TEXT, allowedFolder };
@@ -0,0 +1,139 @@
1
+ // Installing the wake helper: its own copy in ~/.likho, a key per
2
+ // organization (from a browser sign-in), and a way to start with the computer.
3
+ "use strict";
4
+
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const crypto = require("node:crypto");
9
+ const { spawn, spawnSync } = require("node:child_process");
10
+
11
+ const home = () => process.env.LIKHO_CLI_HOME || os.homedir();
12
+ const dir = () => path.join(home(), ".likho");
13
+ const configFile = () => path.join(dir(), "helper.json");
14
+ const helperFile = () => path.join(dir(), "helper.js");
15
+ const pidFile = () => path.join(dir(), "helper.pid");
16
+ const LABEL = "me.likho.helper";
17
+
18
+ function startupFile() {
19
+ if (process.platform === "win32") {
20
+ // LIKHO_CLI_HOME (tests) keeps even the startup entry inside that folder.
21
+ const appData = (!process.env.LIKHO_CLI_HOME && process.env.APPDATA) || path.join(home(), "AppData", "Roaming");
22
+ return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "likho-helper.vbs");
23
+ }
24
+ if (process.platform === "darwin") return path.join(home(), "Library", "LaunchAgents", `${LABEL}.plist`);
25
+ return path.join(home(), ".config", "autostart", "likho-helper.desktop");
26
+ }
27
+
28
+ function readConfig() {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(configFile(), "utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** PKCE pair for the browser sign-in: the challenge goes to the page, the verifier stays here. */
37
+ function pkce() {
38
+ const verifier = crypto.randomBytes(48).toString("base64url");
39
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
40
+ return { verifier, challenge };
41
+ }
42
+
43
+ async function redeem(server, code, verifier) {
44
+ const res = await fetch(`${server}/api/auth/mcp/redeem`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ code, verifier }),
48
+ });
49
+ const data = await res.json().catch(() => ({}));
50
+ if (!res.ok || !data.api_key) throw new Error(data.error || `Likho said ${res.status}`);
51
+ return { key: data.api_key, org: data.workspace };
52
+ }
53
+
54
+ function running() {
55
+ try {
56
+ const pid = Number(fs.readFileSync(pidFile(), "utf8"));
57
+ process.kill(pid, 0);
58
+ return pid;
59
+ } catch {
60
+ return 0;
61
+ }
62
+ }
63
+
64
+ function stop() {
65
+ const pid = running();
66
+ if (!pid) return;
67
+ if (process.platform === "win32") spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
68
+ else {
69
+ try {
70
+ process.kill(pid);
71
+ } catch {}
72
+ }
73
+ }
74
+
75
+ function startNow() {
76
+ const child = spawn(process.execPath, [helperFile()], { detached: true, stdio: "ignore", windowsHide: true });
77
+ child.unref();
78
+ }
79
+
80
+ function writeStartup() {
81
+ const node = process.execPath;
82
+ const file = startupFile();
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ if (process.platform === "win32") {
85
+ // A hidden window: wscript runs node with no console showing.
86
+ const q = (s) => s.replace(/"/g, '""');
87
+ fs.writeFileSync(file, `CreateObject("WScript.Shell").Run """${q(node)}"" ""${q(helperFile())}""", 0, False\r\n`);
88
+ } else if (process.platform === "darwin") {
89
+ const x = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
90
+ fs.writeFileSync(file, `<?xml version="1.0" encoding="UTF-8"?>
91
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
92
+ <plist version="1.0"><dict>
93
+ <key>Label</key><string>${LABEL}</string>
94
+ <key>ProgramArguments</key><array><string>${x(node)}</string><string>${x(helperFile())}</string></array>
95
+ <key>RunAtLoad</key><true/>
96
+ <key>KeepAlive</key><true/>
97
+ </dict></plist>
98
+ `);
99
+ } else {
100
+ fs.writeFileSync(file, `[Desktop Entry]\nType=Application\nName=Likho helper\nExec="${node}" "${helperFile()}"\nX-GNOME-Autostart-enabled=true\nNoDisplay=true\n`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Save the organizations' keys and the folder this was run in, copy the
106
+ * helper, make it start with the computer, and (re)start it now.
107
+ * `orgs`: [{slug, name, key}].
108
+ */
109
+ function install(server, orgs, folder) {
110
+ fs.mkdirSync(dir(), { recursive: true });
111
+ const current = readConfig() || { server, orgs: [] };
112
+ const merged = current.orgs.filter((o) => !orgs.some((n) => n.slug === o.slug));
113
+ // The folders agents may be woken in: only ones set up here, by this person.
114
+ const folders = [...new Set([...(current.folders || []), ...(folder ? [path.resolve(folder)] : [])])];
115
+ const config = { server, orgs: [...merged, ...orgs], folders, path: process.env.PATH || "" };
116
+ fs.writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 });
117
+ fs.copyFileSync(path.join(__dirname, "helper.js"), helperFile());
118
+ writeStartup();
119
+ stop();
120
+ if (process.platform === "darwin") {
121
+ spawnSync("launchctl", ["unload", startupFile()], { stdio: "ignore" });
122
+ spawnSync("launchctl", ["load", "-w", startupFile()], { stdio: "ignore" });
123
+ } else {
124
+ startNow();
125
+ }
126
+ return config;
127
+ }
128
+
129
+ function uninstall() {
130
+ stop();
131
+ if (process.platform === "darwin") spawnSync("launchctl", ["unload", startupFile()], { stdio: "ignore" });
132
+ for (const f of [startupFile(), configFile(), helperFile(), pidFile()]) {
133
+ try {
134
+ fs.unlinkSync(f);
135
+ } catch {}
136
+ }
137
+ }
138
+
139
+ module.exports = { pkce, redeem, install, uninstall, readConfig, running, startupFile };
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,6 +74,12 @@ 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,6 +110,13 @@ function tools(ctx) {
86
110
  const found = files.getEntry(this.file(c, scope), "mcpServers", NAME);
87
111
  return found ? found.url || "(set)" : null;
88
112
  },
113
+ leftovers(c, scope) {
114
+ return oldNames(files.listEntries(this.file(c, scope), "mcpServers"));
115
+ },
116
+ removeOld(c, scope, name) {
117
+ if (scope === "global" && c.which("claude") && run("claude", ["mcp", "remove", name, "--scope", "user"])) return true;
118
+ return files.removeEntry(this.file(c, scope), "mcpServers", name);
119
+ },
89
120
  },
90
121
  {
91
122
  id: "codex",
@@ -112,6 +143,13 @@ function tools(ctx) {
112
143
  current(c) {
113
144
  return files.getTomlServerUrl(this.file(c), NAME);
114
145
  },
146
+ leftovers(c) {
147
+ return oldNames(files.listTomlServers(this.file(c)));
148
+ },
149
+ removeOld(c, scope, name) {
150
+ if (c.which("codex") && run("codex", ["mcp", "remove", name])) return true;
151
+ return files.removeTomlServer(this.file(c), name);
152
+ },
115
153
  },
116
154
  jsonTool({
117
155
  id: "cursor",
@@ -189,4 +227,4 @@ function context(overrides = {}) {
189
227
  };
190
228
  }
191
229
 
192
- module.exports = { tools, context, NAME };
230
+ 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.1",
3
+ "version": "0.4.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",