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 +11 -0
- package/bin/likho.js +136 -35
- package/lib/files.js +21 -0
- package/lib/helper.js +225 -0
- package/lib/helperInstall.js +139 -0
- package/lib/tools.js +39 -1
- 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;
|
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
170
|
-
ui.outro("Nothing to do.");
|
|
171
|
-
return 0;
|
|
172
|
-
}
|
|
179
|
+
keepAll = next === "keep";
|
|
173
180
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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, "&").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/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
|
+
"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",
|