likho-cli 0.3.0 → 0.4.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 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;
@@ -57,6 +63,22 @@ async function answer(question) {
57
63
  const say = (line = "") => console.log(line);
58
64
  const mcpUrl = (opts, org) => `${opts.server}/mcp${org ? `?org=${encodeURIComponent(org)}` : ""}`;
59
65
 
66
+ /**
67
+ * The host a "likho" entry really points at, when that isn't this Likho
68
+ * ("" when it is). A shared or downloaded project can carry a settings file
69
+ * whose "likho" leads to someone else's server; it's never trusted silently.
70
+ */
71
+ function foreignHost(url, opts) {
72
+ try {
73
+ const u = new URL(url);
74
+ const ours = new URL(opts.server);
75
+ if (u.host === ours.host && u.protocol === ours.protocol && u.pathname.replace(/\/+$/, "") === "/mcp") return "";
76
+ return u.protocol === "https:" ? u.host : `${u.protocol}//${u.host} (not https)`;
77
+ } catch {
78
+ return String(url).slice(0, 60) || "an unreadable address";
79
+ }
80
+ }
81
+
60
82
  /** The organization in a Likho address written by this tool ("" if none). */
61
83
  function orgOfUrl(url) {
62
84
  try {
@@ -120,15 +142,24 @@ async function connect(opts) {
120
142
  // what works. In a project, the all-projects setting is shown too: the
121
143
  // project one overrides it in this folder.
122
144
  const existing = new Map(usable.filter((t) => !t.manual).map((t) => [t.id, t.current(ctx, scope)]).filter(([, url]) => url));
145
+ // Entries named "likho" that point elsewhere are replaced, not kept.
146
+ const foreign = new Map([...existing].map(([id, url]) => [id, foreignHost(url, opts)]).filter(([, host]) => host));
147
+ for (const id of foreign.keys()) existing.delete(id);
148
+ if (foreign.size) {
149
+ 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.`);
150
+ ui.log.warn(lines.join("\n"));
151
+ }
123
152
  const inherited = (t) => (scope === "project" && t.global && !t.manual ? t.current(ctx, "global") : null);
124
153
  const hintFor = (t) => {
125
154
  if (t.manual) return "you add it in its settings";
155
+ if (foreign.has(t.id)) return `⚠ points to ${foreign.get(t.id)}, not Likho: will be replaced`;
126
156
  if (existing.has(t.id)) return `already connected → ${orgOfUrl(existing.get(t.id)) || "Likho"}`;
127
157
  const all = inherited(t);
128
158
  return all ? `connected for all projects → ${orgOfUrl(all) || "Likho"}; this would override it here` : undefined;
129
159
  };
130
160
 
131
161
  let chosen = usable.filter((t) => !existing.has(t.id));
162
+ let keepAll = false;
132
163
  if (opts.tools) {
133
164
  chosen = usable; // named on the command line: set up as asked
134
165
  } else if (interactive(opts)) {
@@ -137,52 +168,72 @@ async function connect(opts) {
137
168
  const next = await answer(ui.select({
138
169
  message: "Anything to change?",
139
170
  options: [
140
- { value: "keep", label: "No, keep everything as it is" },
171
+ { value: "keep", label: "No, keep the tools as they are" },
141
172
  { value: "change", label: "Yes, switch a tool to another organization" },
142
173
  ],
143
174
  initialValue: "keep",
144
175
  }));
145
- if (next === "keep") {
146
- ui.outro("Nothing to do.");
147
- return 0;
148
- }
176
+ keepAll = next === "keep";
149
177
  }
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",
178
+ if (!keepAll) {
179
+ const ids = await answer(ui.multiselect({
180
+ message: `Which AI tools should Likho connect${scope === "project" ? ` in ${path.basename(ctx.cwd)}` : ""}?`,
181
+ options: usable.map((t) => ({ value: t.id, label: t.name, hint: hintFor(t) })),
182
+ initialValues: chosen.map((t) => t.id),
183
+ required: false,
166
184
  }));
167
- if (what === "keep") chosen = chosen.filter((x) => x !== t);
185
+ chosen = usable.filter((t) => ids.includes(t.id));
186
+ // Ticking a connected tool means "switch it": confirm, never silently.
187
+ for (const t of chosen.filter((x) => existing.has(x.id))) {
188
+ const what = await answer(ui.select({
189
+ message: `${t.name} is already connected to ${orgOfUrl(existing.get(t.id)) || "Likho"}. What now?`,
190
+ options: [
191
+ { value: "keep", label: "Keep it as it is" },
192
+ { value: "switch", label: "Switch to another organization", hint: `you'll sign in again in ${t.name}` },
193
+ ],
194
+ initialValue: "keep",
195
+ }));
196
+ if (what === "keep") chosen = chosen.filter((x) => x !== t);
197
+ }
168
198
  }
169
199
  } else if (existing.size) {
170
200
  ui.log.info(`Kept as they are: ${usable.filter((t) => existing.has(t.id)).map((t) => t.name).join(", ")} (already connected).`);
171
201
  }
172
- if (!chosen.length) {
173
- ui.outro("Nothing changed.");
202
+
203
+ // Wake-ups: agents here answer when tagged, while this computer is on.
204
+ // Needs the browser step (a person signs in to mint the helper's key).
205
+ let wantHelper = false;
206
+ const wakeable = found.filter((t) => WAKEABLE.includes(t.id));
207
+ if (wakeable.length && interactive(opts) && !opts.org) {
208
+ const already = helper.readConfig();
209
+ wantHelper = await answer(ui.confirm({
210
+ message: already
211
+ ? "Keep this computer waking your agents when they're tagged? (refreshes its sign-in)"
212
+ : "Let your agents here wake up when they're tagged, while this computer is on?",
213
+ initialValue: true,
214
+ }));
215
+ }
216
+ if (!chosen.length && !wantHelper) {
217
+ ui.outro(keepAll ? "Nothing to do." : "Nothing changed.");
174
218
  return 0;
175
219
  }
176
220
 
177
221
  // Which organization each lands in: picked in the browser, signed in.
178
222
  let orgFor;
223
+ let helperCodes = {};
224
+ const pair = wantHelper ? helper.pkce() : null;
179
225
  if (opts.org) {
180
226
  orgFor = () => opts.org;
181
227
  } else {
182
228
  const spin = ui.spinner();
183
229
  const choice = await chooseInBrowser(
184
230
  opts.server,
185
- { scope, tools: chosen.map((t) => t.id).join(","), folder: scope === "project" ? path.basename(ctx.cwd) : "" },
231
+ {
232
+ scope,
233
+ tools: chosen.map((t) => t.id).join(","),
234
+ folder: scope === "project" ? path.basename(ctx.cwd) : "",
235
+ ...(pair ? { helper: "1", challenge: pair.challenge, machine: os.hostname().slice(0, 60) } : {}),
236
+ },
186
237
  {
187
238
  log: (line) => {
188
239
  const text = line.trim();
@@ -203,10 +254,7 @@ async function connect(opts) {
203
254
  // The page can untick tools too; older pages don't send `tools`.
204
255
  if (Array.isArray(choice.tools)) chosen = chosen.filter((t) => choice.tools.includes(t.id));
205
256
  orgFor = (t) => (choice.orgs && choice.orgs[t.id]) || choice.org || "";
206
- }
207
- if (!chosen.length) {
208
- ui.outro("No tools picked. Nothing was changed.");
209
- return 0;
257
+ helperCodes = choice.helper || {};
210
258
  }
211
259
 
212
260
  const done = [];
@@ -229,15 +277,42 @@ async function connect(opts) {
229
277
  }
230
278
  }
231
279
 
280
+ let helperOn = false;
281
+ if (pair && Object.keys(helperCodes).length) {
282
+ try {
283
+ const orgs = [];
284
+ for (const code of Object.values(helperCodes)) {
285
+ const { key, org } = await helper.redeem(opts.server, code, pair.verifier);
286
+ orgs.push({ slug: org.slug, name: org.name, key });
287
+ }
288
+ helper.install(opts.server, orgs, isProjectFolder(ctx) ? ctx.cwd : "");
289
+ helperOn = true;
290
+ 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
+ } catch (err) {
292
+ ui.log.error(`Couldn't set up wake-ups: ${err.message}. Run npx likho-cli again to retry.`);
293
+ }
294
+ }
295
+
232
296
  if (done.length) {
233
297
  const lines = done.map((t) => `${t.name}: ${t.signIn}`);
234
298
  if (scope === "project") lines.push("", "The files written here hold only Likho's address, no keys: safe to commit.");
235
299
  ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
236
- ui.outro('Then, in any chat: "join Likho"');
300
+ }
301
+ if (helperOn) {
302
+ const where = isProjectFolder(ctx)
303
+ ? `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
+ : "Agents are only woken in project folders you set up: run npx likho-cli inside each project folder whose agents should wake.";
305
+ ui.note(
306
+ `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}`,
307
+ "One more step: choose which agents wake up"
308
+ );
309
+ }
310
+ if (done.length || helperOn) {
311
+ ui.outro(done.length ? 'Then, in any chat: "join Likho"' : "Done.");
237
312
  } else {
238
313
  ui.outro("Nothing was connected.");
239
314
  }
240
- return done.length ? 0 : 1;
315
+ return done.length || helperOn ? 0 : 1;
241
316
  } catch (err) {
242
317
  return cancelled(err);
243
318
  }
@@ -250,8 +325,9 @@ function status(opts) {
250
325
  for (const t of selectTools(opts, ctx)) {
251
326
  if (t.manual) continue;
252
327
  const rows = [];
253
- if (t.global) rows.push(`all projects: ${t.current(ctx, "global") || "not connected"}`);
254
- if (t.project && inProject) rows.push(`this project: ${t.current(ctx, "project") || "not connected"}`);
328
+ const show = (url) => (!url ? "not connected" : foreignHost(url, opts) ? `⚠ ${url} (not Likho: run npx likho-cli to replace it)` : url);
329
+ if (t.global) rows.push(`all projects: ${show(t.current(ctx, "global"))}`);
330
+ if (t.project && inProject) rows.push(`this project: ${show(t.current(ctx, "project"))}`);
255
331
  say(` ${t.name.padEnd(14)} ${t.detect() ? "" : "(not installed) "}${rows.join(" · ")}`);
256
332
  }
257
333
  say();
@@ -293,12 +369,40 @@ async function remove(opts) {
293
369
  }
294
370
  }
295
371
 
372
+ /** `npx likho-cli helper` (is it on?) and `npx likho-cli helper off`. */
373
+ function helperCommand(opts) {
374
+ const cfg = helper.readConfig();
375
+ if (opts.off) {
376
+ helper.uninstall();
377
+ say();
378
+ say(" Wake-ups are off on this computer: the helper is stopped and won't start with it.");
379
+ say();
380
+ return 0;
381
+ }
382
+ if (!cfg) {
383
+ say();
384
+ say(" Wake-ups aren't set up on this computer. Run npx likho-cli in a project folder and answer Yes.");
385
+ say();
386
+ return 0;
387
+ }
388
+ say();
389
+ say(" Likho wake helper");
390
+ say(` Running: ${helper.running() ? "yes" : "no (it starts with the computer)"}`);
391
+ say(` Organizations: ${cfg.orgs.map((o) => o.name || o.slug).join(", ")}`);
392
+ say(` Starts with: ${helper.startupFile()}`);
393
+ say(" Log: ~/.likho/helper.log");
394
+ say(" Turn off: npx likho-cli helper off");
395
+ say();
396
+ return 0;
397
+ }
398
+
296
399
  function help() {
297
400
  say(`
298
401
  npx likho-cli Connect the AI tools on this computer to Likho.
299
402
  Inside a project folder it asks: this project, or all projects.
300
403
  npx likho-cli status Show what is connected where.
301
404
  npx likho-cli remove Take Likho out of your tools.
405
+ npx likho-cli helper Is this computer waking your agents when tagged? (helper off: stop it)
302
406
 
303
407
  --here Only this project folder.
304
408
  --global All projects on this computer.
@@ -311,7 +415,7 @@ function help() {
311
415
 
312
416
  async function main() {
313
417
  const opts = parseArgs(process.argv.slice(2));
314
- const run = { connect, status, remove, help }[opts.command];
418
+ const run = { connect, status, remove, help, helper: helperCommand }[opts.command];
315
419
  process.exitCode = await run(opts);
316
420
  }
317
421
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "likho-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",