likho-cli 0.4.0 → 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/bin/likho.js CHANGED
@@ -88,10 +88,13 @@ function orgOfUrl(url) {
88
88
  }
89
89
  }
90
90
 
91
+ /** Any folder but the home folder can be a project (a new one has no files yet). */
92
+ const inHome = (ctx) => path.resolve(ctx.cwd) === path.resolve(ctx.home);
93
+
91
94
  async function chooseScope(opts, ctx, verb) {
92
95
  if (opts.scope) return opts.scope;
93
- if (!isProjectFolder(ctx)) return "global";
94
- if (!interactive(opts)) return "project";
96
+ if (inHome(ctx)) return "global";
97
+ if (!interactive(opts)) return isProjectFolder(ctx) ? "project" : "global";
95
98
  const folder = path.basename(ctx.cwd);
96
99
  const setup = verb === "Set up";
97
100
  return answer(ui.select({
@@ -277,6 +280,25 @@ async function connect(opts) {
277
280
  }
278
281
  }
279
282
 
283
+ // Old or extra Likho entries in the same tools (e.g. "likho-hub" from
284
+ // before the rename) are a second connection: offer to remove them.
285
+ const leftovers = done.flatMap((t) => (t.leftovers ? t.leftovers(ctx, scope).map((name) => ({ t, name })) : []));
286
+ if (leftovers.length) {
287
+ const list = leftovers.map(({ t, name }) => `"${name}" in ${t.name}`).join(", ");
288
+ const clean = interactive(opts)
289
+ ? await answer(ui.confirm({ message: `Also found older Likho connections: ${list}. Remove them? (recommended)`, initialValue: true }))
290
+ : true;
291
+ if (clean) {
292
+ for (const { t, name } of leftovers) {
293
+ try {
294
+ if (t.removeOld(ctx, scope, name)) ui.log.success(`Removed the old "${name}" from ${t.name}`);
295
+ } catch (err) {
296
+ ui.log.error(`${t.name}: couldn't remove "${name}": ${err.message}`);
297
+ }
298
+ }
299
+ }
300
+ }
301
+
280
302
  let helperOn = false;
281
303
  if (pair && Object.keys(helperCodes).length) {
282
304
  try {
@@ -285,7 +307,7 @@ async function connect(opts) {
285
307
  const { key, org } = await helper.redeem(opts.server, code, pair.verifier);
286
308
  orgs.push({ slug: org.slug, name: org.name, key });
287
309
  }
288
- helper.install(opts.server, orgs, isProjectFolder(ctx) ? ctx.cwd : "");
310
+ helper.install(opts.server, orgs, inHome(ctx) ? "" : ctx.cwd);
289
311
  helperOn = true;
290
312
  ui.log.success(`Wake-ups on for ${orgs.map((o) => o.name).join(", ")}: this computer wakes your agents when they're tagged, while it's on.`);
291
313
  } catch (err) {
@@ -299,7 +321,7 @@ async function connect(opts) {
299
321
  ui.note(lines.join("\n"), "Restart these tools, then sign in once in each");
300
322
  }
301
323
  if (helperOn) {
302
- const where = isProjectFolder(ctx)
324
+ const where = !inHome(ctx)
303
325
  ? `Agents are only woken in folders set up like this one (${path.basename(ctx.cwd)}): run npx likho-cli in each other project folder too.`
304
326
  : "Agents are only woken in project folders you set up: run npx likho-cli inside each project folder whose agents should wake.";
305
327
  ui.note(
package/lib/files.js CHANGED
@@ -64,6 +64,16 @@ function getEntry(file, section, name) {
64
64
  }
65
65
  }
66
66
 
67
+ /** Every entry in data[section], as {name: entry}. */
68
+ function listEntries(file, section) {
69
+ try {
70
+ const block = readJson(file)[section];
71
+ return block && typeof block === "object" ? block : {};
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+
67
77
  // ---- Codex's TOML: only the [mcp_servers.<name>] table is ever touched.
68
78
  function tomlTable(name) {
69
79
  return new RegExp(`^\\[mcp_servers\\.${name}\\][\\s\\S]*?(?=^\\[|(?![\\s\\S]))`, "m");
@@ -85,6 +95,15 @@ function getTomlServerUrl(file, name) {
85
95
  return url ? url[1] : null;
86
96
  }
87
97
 
98
+ /** {name: url} for every [mcp_servers.<name>] table. */
99
+ function listTomlServers(file) {
100
+ if (!fs.existsSync(file)) return {};
101
+ const out = {};
102
+ const raw = fs.readFileSync(file, "utf8");
103
+ for (const m of raw.matchAll(/^\[mcp_servers\.([\w-]+)\]/gm)) out[m[1]] = getTomlServerUrl(file, m[1]) || "";
104
+ return out;
105
+ }
106
+
88
107
  function removeTomlServer(file, name) {
89
108
  if (!fs.existsSync(file)) return false;
90
109
  const raw = fs.readFileSync(file, "utf8");
@@ -103,4 +122,6 @@ module.exports = {
103
122
  setTomlServer,
104
123
  getTomlServerUrl,
105
124
  removeTomlServer,
125
+ listEntries,
126
+ listTomlServers,
106
127
  };
package/lib/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.4.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",