@ishlabs/cli 0.12.0 → 0.12.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/dist/auth.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import * as http from "node:http";
21
21
  import * as crypto from "node:crypto";
22
- import { execFile } from "node:child_process";
22
+ import { spawn } from "node:child_process";
23
23
  const LOGIN_TIMEOUT = 5 * 60 * 1000; // 5 minutes
24
24
  const CLIENT_NAME = "ish CLI";
25
25
  const DEFAULT_APP_URL = "https://app.ishlabs.io";
@@ -88,15 +88,15 @@ export function resolveSupabaseProjectFromToken(accessToken) {
88
88
  }
89
89
  // --- Browser open ---
90
90
  function openBrowser(url) {
91
- if (process.platform === "win32") {
92
- execFile("cmd", ["/c", "start", "", url]);
93
- }
94
- else if (process.platform === "darwin") {
95
- execFile("open", [url]);
96
- }
97
- else {
98
- execFile("xdg-open", [url]);
99
- }
91
+ // detached + unref so the child doesn't keep our event loop alive after the
92
+ // login flow finishes. stdio: "ignore" so we don't pipe-buffer its output.
93
+ const opts = { detached: true, stdio: "ignore" };
94
+ const child = process.platform === "win32"
95
+ ? spawn("cmd", ["/c", "start", "", url], opts)
96
+ : process.platform === "darwin"
97
+ ? spawn("open", [url], opts)
98
+ : spawn("xdg-open", [url], opts);
99
+ child.unref();
100
100
  }
101
101
  // --- JWT decode ---
102
102
  export function decodeJwtExp(token) {
@@ -130,10 +130,15 @@ function startCallbackServer() {
130
130
  const callbackPromise = new Promise((res) => {
131
131
  resolveCallback = res;
132
132
  });
133
+ // Track every socket the server accepts so we can destroy them on close.
134
+ // Browsers open multiple keep-alive connections (favicon prefetch, parallel
135
+ // request slots) and Node's server.close() waits for those to drain — that
136
+ // wait is what was hanging `ish login` after success.
137
+ const sockets = new Set();
133
138
  const server = http.createServer((req, res) => {
134
139
  const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
135
140
  if (reqUrl.pathname !== "/callback") {
136
- res.writeHead(404);
141
+ res.writeHead(404, { Connection: "close" });
137
142
  res.end();
138
143
  return;
139
144
  }
@@ -148,11 +153,21 @@ function startCallbackServer() {
148
153
  ? `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`
149
154
  : "You can close this window and return to your terminal.";
150
155
  const html = `<!doctype html><html><head><meta charset="utf-8"><title>${headline}</title></head><body style="font-family:system-ui,-apple-system,sans-serif;padding:3em;text-align:center;color:#1f2937"><h1 style="font-weight:600;margin-bottom:1em">${headline}</h1><p style="color:#6b7280">${subline}</p></body></html>`;
151
- res.writeHead(cb.error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" });
156
+ // Connection: close so the browser tears down the socket immediately;
157
+ // otherwise HTTP/1.1 keep-alive holds the socket open and prevents
158
+ // server.close() from freeing the event loop, hanging `ish login`.
159
+ res.writeHead(cb.error ? 400 : 200, {
160
+ "Content-Type": "text/html; charset=utf-8",
161
+ Connection: "close",
162
+ });
152
163
  res.end(html);
153
164
  if (resolveCallback)
154
165
  resolveCallback(cb);
155
166
  });
167
+ server.on("connection", (socket) => {
168
+ sockets.add(socket);
169
+ socket.on("close", () => sockets.delete(socket));
170
+ });
156
171
  server.on("error", reject);
157
172
  server.listen(0, "127.0.0.1", () => {
158
173
  const addr = server.address();
@@ -165,6 +180,14 @@ function startCallbackServer() {
165
180
  waitForCallback: () => callbackPromise,
166
181
  close: () => {
167
182
  server.close();
183
+ // Force-destroy every socket — Connection: close + closeAllConnections
184
+ // alone weren't enough on macOS, where the browser kept enough idle
185
+ // sockets alive to hold the event loop open.
186
+ server.closeAllConnections?.();
187
+ for (const socket of sockets)
188
+ socket.destroy();
189
+ sockets.clear();
190
+ server.unref();
168
191
  },
169
192
  });
170
193
  });
@@ -218,10 +241,20 @@ export async function login() {
218
241
  console.error(`If the browser doesn't open, visit:\n ${authorizeUrl.toString()}\n`);
219
242
  openBrowser(authorizeUrl.toString());
220
243
  console.error("Waiting for authentication...");
244
+ let timeoutHandle;
221
245
  const timeoutPromise = new Promise((_, reject) => {
222
- setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
246
+ timeoutHandle = setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
223
247
  });
224
- const cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
248
+ let cb;
249
+ try {
250
+ cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
251
+ }
252
+ finally {
253
+ // Without this, the 5-minute timer keeps the event loop alive long
254
+ // after the callback fires, hanging `ish login` until expiry.
255
+ if (timeoutHandle)
256
+ clearTimeout(timeoutHandle);
257
+ }
225
258
  if (cb.error) {
226
259
  throw new Error(`OAuth error: ${cb.error}${cb.errorDescription ? ` — ${cb.errorDescription}` : ""}`);
227
260
  }
@@ -619,6 +619,69 @@ Examples:
619
619
  });
620
620
  });
621
621
  }
622
+ function attachChatEndpointMap(parent) {
623
+ parent
624
+ .command("map")
625
+ .description("Map a chatbot contract from documentation (and optionally a saved endpoint draft) via /chat/test-and-map")
626
+ .option("--docs <file>", 'Path to documentation file (curl, JSON, OpenAPI snippet, freeform). Use "-" for stdin')
627
+ .option("--endpoint <id>", "Saved endpoint alias or UUID — its config seeds the draft endpoint. Optional.")
628
+ .option("--workspace <id>", "Workspace ID")
629
+ .addHelpText("after", `
630
+ Use this when you have documentation for a customer's chatbot (a curl example,
631
+ an OpenAPI excerpt, a README chunk, freeform docs) and want a fully-mapped
632
+ ChatbotEndpointConfig back. The backend wraps the same inference engine
633
+ behind \`init\` but layers two things on top: it fuses your draft endpoint's
634
+ URL / headers with the documentation, and when the URL is probable it can
635
+ run an iterative LLM agent that probes the live bot to refine the mapping.
636
+
637
+ The output is read-only — pipe \`inferred_config\` into
638
+ \`ish chat endpoint create --endpoint-config -\` to persist, or into
639
+ \`update --endpoint-config -\` to overwrite an existing endpoint.
640
+
641
+ Examples:
642
+ $ ish chat endpoint map --docs ./api.md | jq '.inferred_config'
643
+ $ ish chat endpoint map --docs - < bot.curl
644
+ $ ish chat endpoint map --endpoint ep-abc --docs ./refined.md`)
645
+ .action(async (opts, cmd) => {
646
+ await withClient(cmd, async (client, globals) => {
647
+ if (!opts.docs) {
648
+ throw new Error("Pass --docs <file> (or --docs - for stdin).");
649
+ }
650
+ const ws = resolveWorkspace(opts.workspace);
651
+ const documentation = await readFileOrStdin(opts.docs);
652
+ if (!documentation.trim()) {
653
+ throw new Error("Documentation is empty. Provide a non-empty paste.");
654
+ }
655
+ let draftEndpoint = {};
656
+ if (opts.endpoint !== undefined) {
657
+ const rid = resolveChatEndpoint(undefined, opts.endpoint);
658
+ const saved = await client.get(`/chatbot-endpoints/${rid}`, undefined, { timeout: 30_000 });
659
+ if (saved.id)
660
+ tagAlias(ALIAS_PREFIX.chatEndpoint, saved.id);
661
+ draftEndpoint = {
662
+ ...(saved.config ?? {}),
663
+ isTunnelBacked: saved.isTunnelBacked,
664
+ };
665
+ }
666
+ const res = await client.post(`/products/${ws}/chat/test-and-map`, { endpoint: draftEndpoint, documentation }, { timeout: 180_000 });
667
+ if (res.kind === "failure") {
668
+ const err = new Error(res.errorMessage ?? "test-and-map failed.");
669
+ err.error_kind = res.errorKind;
670
+ throw err;
671
+ }
672
+ const result = {
673
+ success: true,
674
+ inferred_config: res.inferredConfig,
675
+ confidence: res.confidence ?? null,
676
+ missing_signals: res.missingSignals ?? [],
677
+ tunnel_backed_detected: res.tunnelBackedDetected ?? false,
678
+ raw_response: res.rawResponse ?? null,
679
+ dispatched_body: res.dispatchedBody ?? null,
680
+ };
681
+ output(result, globals.json, { writePath: true });
682
+ });
683
+ });
684
+ }
622
685
  // ---------------------------------------------------------------------------
623
686
  // Command registration
624
687
  // ---------------------------------------------------------------------------
@@ -650,6 +713,7 @@ mirrors that editing model.`);
650
713
  attachChatEndpointCommands(endpoint);
651
714
  attachChatEndpointInit(endpoint);
652
715
  attachChatEndpointTest(endpoint);
716
+ attachChatEndpointMap(endpoint);
653
717
  }
654
718
  // Re-exported for tests / external integration if needed.
655
719
  export { envelopeFromRow };
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { program, Option } from "commander";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import * as os from "node:os";
3
6
  import { runTunnel, runDetached, connectStatus, disconnect } from "./connect.js";
4
7
  import { login, decodeJwtClaims } from "./auth.js";
5
8
  import { loadConfig, saveConfig } from "./config.js";
@@ -22,6 +25,7 @@ import { ApiClient } from "./lib/api-client.js";
22
25
  import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
23
26
  import { output } from "./lib/output.js";
24
27
  import { ishDir } from "./lib/paths.js";
28
+ import { findInstalledSkill } from "./lib/skill-content.js";
25
29
  import pkg from "../package.json" with { type: "json" };
26
30
  const { version } = pkg;
27
31
  program
@@ -69,7 +73,7 @@ program.exitOverride((err) => {
69
73
  program
70
74
  .option("-t, --token <token>", "Auth token (or set ISH_TOKEN env var)")
71
75
  .option("--token-file <path>", "Read auth token from a file (preferred over --token / ISH_TOKEN)")
72
- .option("--api-url <url>", "Backend API URL (default: ISH_API_URL or https://api.ishlabs.io)")
76
+ .addOption(new Option("--api-url <url>", "Override backend API URL (internal also via ISH_API_URL)").hideHelp())
73
77
  .addOption(new Option("--dev", "Use local dev API (http://localhost:8000)").hideHelp())
74
78
  .option("--workspace <id>", "Default workspace ID; per-subcommand --workspace overrides")
75
79
  .option("--json", "Output as JSON (auto-enabled when piped)")
@@ -185,11 +189,22 @@ program
185
189
  catch { /* keep id+alias only */ }
186
190
  }
187
191
  }
192
+ // Best-effort skill detection: walks parent directories from cwd so an
193
+ // agent invoked from a subfolder still sees a project-level skill.
194
+ const skillHit = findInstalledSkill(process.cwd(), fs, path, os.homedir());
195
+ const skill = skillHit
196
+ ? { installed: true, path: skillHit.root, target: skillHit.target.key }
197
+ : {
198
+ installed: false,
199
+ hint: "Run `ish init` in your project root to install the agent skill " +
200
+ "(.claude/skills/ish or .agents/skills/ish).",
201
+ };
188
202
  const payload = {
189
203
  user,
190
204
  workspace,
191
205
  study,
192
206
  ask,
207
+ skill,
193
208
  api_url: apiUrl,
194
209
  home: ishDir(),
195
210
  };
@@ -213,6 +228,7 @@ program
213
228
  console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
214
229
  console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
215
230
  console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
231
+ console.log(`Skill: ${skillHit ? skillHit.root : "not installed (run `ish init` to install the agent skill)"}`);
216
232
  console.log(`Home: ${ishDir()}`);
217
233
  console.log(`API: ${apiUrl}`);
218
234
  if (tokenError && !token)
package/dist/lib/docs.js CHANGED
@@ -2244,7 +2244,7 @@ export function getPage(slug) {
2244
2244
  export const AGENT_HELP_FOOTER = "\nFor agents: run `ish docs overview` for the mental model, " +
2245
2245
  "`ish docs list` for every concept page, " +
2246
2246
  "or `ish docs search <query>` for keyword lookup. Every command supports `--json`.\n\n" +
2247
- "Global flags (--json, --fields, --verbose, --quiet, --token, --api-url) apply to all subcommands. " +
2247
+ "Global flags (--json, --fields, --verbose, --quiet, --token) apply to all subcommands. " +
2248
2248
  "Run `ish --help` to see them.";
2249
2249
  /**
2250
2250
  * Substring search over title + description + body. Title hits score
@@ -842,7 +842,6 @@ is expected. Full UUIDs always work too. See
842
842
  | \`--verbose\` | Include UUIDs + timestamps in JSON |
843
843
  | \`-q, --quiet\` | Suppress progress messages on stderr |
844
844
  | \`-t, --token\` | Auth token (else ISH_TOKEN env, else \`ish login\` saved) |
845
- | \`--api-url\` | Override backend (default https://api.ishlabs.io) |
846
845
 
847
846
  See \`ish docs get-page reference/json-mode\` for the full display-vs-
848
847
  capture-vs-chain decision rule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {