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 +11 -0
- package/bin/likho.js +139 -35
- package/lib/helper.js +225 -0
- package/lib/helperInstall.js +139 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
146
|
-
ui.outro("Nothing to do.");
|
|
147
|
-
return 0;
|
|
148
|
-
}
|
|
176
|
+
keepAll = next === "keep";
|
|
149
177
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
if (t.
|
|
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, "&").replace(/</g, "<");
|
|
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
|
+
"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",
|