@ishlabs/cli 0.9.0 → 0.11.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.
Files changed (39) hide show
  1. package/README.md +54 -5
  2. package/dist/commands/ask.d.ts +12 -0
  3. package/dist/commands/ask.js +127 -2
  4. package/dist/commands/chat.d.ts +17 -0
  5. package/dist/commands/chat.js +655 -0
  6. package/dist/commands/iteration.js +134 -14
  7. package/dist/commands/secret.d.ts +20 -0
  8. package/dist/commands/secret.js +246 -0
  9. package/dist/commands/study-run.d.ts +38 -0
  10. package/dist/commands/study-run.js +199 -80
  11. package/dist/commands/study-tester.js +17 -2
  12. package/dist/commands/study.js +309 -37
  13. package/dist/commands/workspace.js +81 -0
  14. package/dist/config.d.ts +3 -0
  15. package/dist/connect.d.ts +3 -0
  16. package/dist/connect.js +346 -22
  17. package/dist/index.js +64 -6
  18. package/dist/lib/alias-hydrate.d.ts +42 -0
  19. package/dist/lib/alias-hydrate.js +175 -0
  20. package/dist/lib/alias-store.d.ts +1 -0
  21. package/dist/lib/alias-store.js +28 -1
  22. package/dist/lib/auth.js +4 -2
  23. package/dist/lib/chat-endpoint-formatters.d.ts +74 -0
  24. package/dist/lib/chat-endpoint-formatters.js +154 -0
  25. package/dist/lib/chat-endpoint-templates.d.ts +35 -0
  26. package/dist/lib/chat-endpoint-templates.js +210 -0
  27. package/dist/lib/command-helpers.d.ts +18 -0
  28. package/dist/lib/command-helpers.js +105 -3
  29. package/dist/lib/docs.js +641 -17
  30. package/dist/lib/modality.d.ts +42 -0
  31. package/dist/lib/modality.js +192 -0
  32. package/dist/lib/output.d.ts +41 -0
  33. package/dist/lib/output.js +453 -19
  34. package/dist/lib/paths.d.ts +1 -0
  35. package/dist/lib/paths.js +3 -0
  36. package/dist/lib/skill-content.d.ts +18 -0
  37. package/dist/lib/skill-content.js +223 -12
  38. package/dist/lib/types.d.ts +15 -0
  39. package/package.json +2 -2
package/dist/connect.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * Localhost connect CLI — wraps cloudflared and registers with Ish backend.
3
3
  */
4
4
  import { spawn, execSync } from "node:child_process";
5
- import { existsSync, mkdirSync, chmodSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, chmodSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { loadConfig, saveConfig } from "./config.js";
8
8
  import { refreshTokens, isTokenExpired, decodeJwtExp, AuthRefreshPermanentError } from "./auth.js";
9
- import { binDir, cloudflaredBin, simulationsDir } from "./lib/paths.js";
9
+ import { binDir, cloudflaredBin, simulationsDir, connectLockPath, ishDir } from "./lib/paths.js";
10
10
  import { c } from "./lib/colors.js";
11
11
  const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
12
12
  const HEARTBEAT_INTERVAL = 10_000;
@@ -393,25 +393,48 @@ function startCloudflared(port, binPath, json) {
393
393
  }
394
394
  // --- API calls ---
395
395
  async function registerTunnel(apiUrl, token, tunnelUrl, port) {
396
- try {
397
- const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
398
- method: "POST",
399
- headers: {
400
- Authorization: `Bearer ${token}`,
401
- "Content-Type": "application/json",
402
- },
403
- body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
404
- signal: AbortSignal.timeout(10_000),
405
- });
406
- if (!resp.ok)
407
- throw new Error(`HTTP ${resp.status}`);
408
- return true;
409
- }
410
- catch (e) {
411
- console.error(`Warning: Failed to register connection: ${e}`);
412
- console.error("Connection is still active — you can retry manually.");
413
- return false;
396
+ // M6 / Pattern G: registration sometimes 5xxs / 404s on the first POST
397
+ // after a backend bounce or a routing-cache miss; the previous
398
+ // single-shot attempt left `--detach` clients writing the lock with
399
+ // `registered: false`, then the heartbeat would 404 and burn through
400
+ // the 3-strike countdown. Retry up to 4 times with exponential backoff
401
+ // (~0.5s + 1s + 2s + 4s = ~7.5s of wall time worst-case) before
402
+ // giving up. 4xx other than 404/408/429 fail fast — they're permanent.
403
+ const maxAttempts = 4;
404
+ let lastErr;
405
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
406
+ if (attempt > 0) {
407
+ const delayMs = Math.min(4_000, 500 * Math.pow(2, attempt - 1));
408
+ await new Promise((r) => setTimeout(r, delayMs));
409
+ }
410
+ try {
411
+ const resp = await fetch(`${apiUrl}${API_BASE}/connect`, {
412
+ method: "POST",
413
+ headers: {
414
+ Authorization: `Bearer ${token}`,
415
+ "Content-Type": "application/json",
416
+ },
417
+ body: JSON.stringify({ tunnel_url: tunnelUrl, local_port: port }),
418
+ signal: AbortSignal.timeout(10_000),
419
+ });
420
+ if (resp.ok)
421
+ return true;
422
+ // 4xx (except 404/408/429) are permanent — don't keep retrying.
423
+ if (resp.status >= 400 && resp.status < 500
424
+ && resp.status !== 404 && resp.status !== 408 && resp.status !== 429) {
425
+ console.error(`Warning: Failed to register connection: HTTP ${resp.status}`);
426
+ console.error("Connection is still active — you can retry manually.");
427
+ return false;
428
+ }
429
+ lastErr = new Error(`HTTP ${resp.status}`);
430
+ }
431
+ catch (e) {
432
+ lastErr = e;
433
+ }
414
434
  }
435
+ console.error(`Warning: Failed to register connection after ${maxAttempts} attempts: ${lastErr}`);
436
+ console.error("Connection is still active — you can retry manually.");
437
+ return false;
415
438
  }
416
439
  /**
417
440
  * Best-effort deregister. When `suppressAuthFailures` is true (we're already
@@ -474,9 +497,21 @@ function processHeartbeatResponse(resp, renderCards) {
474
497
  // Non-fatal: response parsing failed, silently continue
475
498
  });
476
499
  }
477
- function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json) {
500
+ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal, onAuthPermanent, json, reRegister) {
478
501
  let consecutiveFailures = 0;
479
502
  let stopped = false;
503
+ // D1 / PC-M2: track repeated 404→re-register cycles. If the backend
504
+ // keeps forgetting the registration between heartbeats (route bug,
505
+ // misconfigured DB, racing supervisor, etc.) we'd loop forever
506
+ // because every successful re-register resets the strike counter.
507
+ // After REREGISTER_CYCLE_BUDGET cycles, log a clear diagnostic and
508
+ // stop the strike countdown — the tunnel itself is still healthy,
509
+ // it just isn't being recognised by the backend's heartbeat path.
510
+ // The user / agent gets a single actionable message instead of a
511
+ // loop spamming "Tunnel registration restored." every 10s.
512
+ let reRegisterCycles = 0;
513
+ let reRegisterDiagnosticPrinted = false;
514
+ const REREGISTER_CYCLE_BUDGET = 6;
480
515
  const interval = setInterval(async () => {
481
516
  if (stopped)
482
517
  return;
@@ -486,6 +521,34 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
486
521
  headers: { Authorization: `Bearer ${getToken()}` },
487
522
  signal: AbortSignal.timeout(10_000),
488
523
  });
524
+ // M6 / Pattern G: a 404 here means the backend forgot the
525
+ // tunnel registration (cache miss, restart, or the original
526
+ // POST /connect raced with a backend bounce). Re-register and
527
+ // reset the strike count rather than counting it as a failure
528
+ // — `cfProcess` is still healthy, the *registration* is the
529
+ // soft state that needs repair.
530
+ if (resp.status === 404 && reRegister) {
531
+ reRegisterCycles += 1;
532
+ const ok = await reRegister();
533
+ if (ok) {
534
+ consecutiveFailures = 0;
535
+ // First few cycles are normal (transient backend hiccup).
536
+ // After the budget, the route or registration model is
537
+ // fundamentally broken — keep the tunnel up but say so.
538
+ if (reRegisterCycles >= REREGISTER_CYCLE_BUDGET && !reRegisterDiagnosticPrinted) {
539
+ reRegisterDiagnosticPrinted = true;
540
+ console.error(`Notice: heartbeat path /api/v1/connect/heartbeat is repeatedly 404'ing despite successful re-registration ` +
541
+ `(${reRegisterCycles} cycles). Backend appears to drop the active connection between heartbeats. ` +
542
+ `Tunnel + cloudflared are still healthy; subsequent simulations may still hit TunnelInactive on dispatch. ` +
543
+ `Investigate the backend's /connect route or restart the API service.`);
544
+ }
545
+ else if (!json) {
546
+ console.error("Tunnel registration restored.");
547
+ }
548
+ return;
549
+ }
550
+ // Fall through to the strike-count path if re-register fails.
551
+ }
489
552
  // If 401 and we can refresh, try once
490
553
  if (resp.status === 401 && doRefresh) {
491
554
  try {
@@ -656,13 +719,15 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
656
719
  }
657
720
  await deregisterTunnel(apiUrl, currentToken, json, true);
658
721
  cfProcess.kill();
722
+ clearLock();
659
723
  process.exit(3);
660
724
  };
661
725
  heartbeat = startHeartbeat(apiUrl, () => currentToken, serializedRefresh, onTokenRefreshed, async () => {
662
726
  await deregisterTunnel(apiUrl, currentToken, json);
663
727
  cfProcess.kill();
728
+ clearLock();
664
729
  process.exit(1);
665
- }, onAuthPermanent, json);
730
+ }, onAuthPermanent, json, () => registerTunnel(apiUrl, currentToken, tunnelUrl, port));
666
731
  proactiveRefresh = scheduleProactiveRefresh(currentToken, serializedRefresh, onTokenRefreshed, onAuthPermanent, json);
667
732
  const shutdown = async () => {
668
733
  if (shuttingDown)
@@ -674,6 +739,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
674
739
  proactiveRefresh?.stop();
675
740
  cfProcess.kill();
676
741
  await deregisterTunnel(apiUrl, currentToken, json);
742
+ clearLock();
677
743
  process.exit(0);
678
744
  };
679
745
  process.on("SIGINT", shutdown);
@@ -686,7 +752,265 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
686
752
  heartbeat?.stop();
687
753
  proactiveRefresh?.stop();
688
754
  await deregisterTunnel(apiUrl, currentToken, json);
755
+ clearLock();
689
756
  process.exit(0);
690
757
  }
691
758
  });
759
+ // Detached mode: when this process was spawned by `connect --detach`, the
760
+ // parent needs the registration confirmation we already emitted as a
761
+ // {status:"connected", ...} line on stdout. Write the lock file now so
762
+ // `ish connect status` and `ish disconnect` can find this PID.
763
+ if (process.env.ISH_CONNECT_DETACHED === "1") {
764
+ writeLock({
765
+ pid: process.pid,
766
+ tunnel_url: tunnelUrl,
767
+ local_port: port,
768
+ registered_at: new Date().toISOString(),
769
+ api_url: apiUrl,
770
+ });
771
+ }
772
+ }
773
+ function writeLock(lock) {
774
+ mkdirSync(ishDir(), { recursive: true });
775
+ writeFileSync(connectLockPath(), JSON.stringify(lock, null, 2) + "\n");
776
+ }
777
+ function readLock() {
778
+ const path = connectLockPath();
779
+ if (!existsSync(path))
780
+ return null;
781
+ try {
782
+ const raw = readFileSync(path, "utf-8");
783
+ const parsed = JSON.parse(raw);
784
+ if (typeof parsed.pid !== "number")
785
+ return null;
786
+ return parsed;
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
792
+ function clearLock() {
793
+ const path = connectLockPath();
794
+ if (existsSync(path)) {
795
+ try {
796
+ unlinkSync(path);
797
+ }
798
+ catch { /* best-effort */ }
799
+ }
800
+ }
801
+ function pidIsAlive(pid) {
802
+ try {
803
+ process.kill(pid, 0);
804
+ return true;
805
+ }
806
+ catch {
807
+ return false;
808
+ }
809
+ }
810
+ // ---------------------------------------------------------------------------
811
+ // Detached connect — spawn a child running the same `ish connect <port>`
812
+ // in JSON mode, wait for its first `{status:"connected", ...}` line on
813
+ // stdout, then exit the parent. The child writes the lock file on its
814
+ // own once detached (see ISH_CONNECT_DETACHED branch in runTunnel).
815
+ // ---------------------------------------------------------------------------
816
+ export async function runDetached(port, apiUrlArg, tokenArg, tokenFileArg) {
817
+ const existing = readLock();
818
+ if (existing && pidIsAlive(existing.pid)) {
819
+ throw new Error(`An active tunnel is already running (pid ${existing.pid}, ${existing.tunnel_url}). Run \`ish disconnect\` first.`);
820
+ }
821
+ if (existing) {
822
+ // Stale lock — process is gone. Clear before continuing.
823
+ clearLock();
824
+ }
825
+ // Re-invoke the same CLI binary with `--json connect <port>` minus
826
+ // `--detach`, in a detached child process so it survives this parent.
827
+ const exe = process.execPath;
828
+ const script = process.argv[1];
829
+ const childArgs = [];
830
+ // Preserve global flags the user passed at the program root (e.g.
831
+ // --api-url, --token-file). We rebuild minimally instead of slicing
832
+ // process.argv to avoid carrying --detach forward.
833
+ if (apiUrlArg)
834
+ childArgs.push("--api-url", apiUrlArg);
835
+ if (tokenArg)
836
+ childArgs.push("--token", tokenArg);
837
+ if (tokenFileArg)
838
+ childArgs.push("--token-file", tokenFileArg);
839
+ childArgs.push("--json", "connect", String(port));
840
+ const child = spawn(exe, [script, ...childArgs], {
841
+ detached: true,
842
+ stdio: ["ignore", "pipe", "pipe"],
843
+ env: { ...process.env, ISH_CONNECT_DETACHED: "1" },
844
+ });
845
+ // Buffer the child's stdout until we see the connected JSON line.
846
+ // M6 / Pattern G: hold for `registered: true` specifically — a
847
+ // `registered: false` connected line means the cloudflared tunnel is
848
+ // up but the backend never accepted the registration POST. Returning
849
+ // before registration confirms used to leave the parent's reported
850
+ // `tunnel_url` orphaned (subsequent API calls would 404 against it),
851
+ // and the child's heartbeat would burn through the 3-strike countdown
852
+ // and exit before the agent even tried to use the URL. The child's
853
+ // `registerTunnel` already retries with backoff, so by the time it
854
+ // emits `registered: false` the situation is permanent on its end —
855
+ // surface it as a startup error rather than a half-success.
856
+ const connected = await new Promise((resolve, reject) => {
857
+ let buffered = "";
858
+ const onData = (chunk) => {
859
+ buffered += chunk.toString("utf-8");
860
+ let nl;
861
+ while ((nl = buffered.indexOf("\n")) !== -1) {
862
+ const line = buffered.slice(0, nl).trim();
863
+ buffered = buffered.slice(nl + 1);
864
+ if (!line)
865
+ continue;
866
+ try {
867
+ const parsed = JSON.parse(line);
868
+ if (parsed.status === "connected" && typeof parsed.tunnel_url === "string") {
869
+ if (!parsed.registered) {
870
+ child.stdout?.off("data", onData);
871
+ child.off("exit", onExit);
872
+ reject(new Error(`Tunnel started (${parsed.tunnel_url}) but backend registration failed. ` +
873
+ "Check API connectivity and re-run; the orphaned cloudflared process has been terminated."));
874
+ return;
875
+ }
876
+ child.stdout?.off("data", onData);
877
+ child.off("exit", onExit);
878
+ resolve({
879
+ tunnel_url: parsed.tunnel_url,
880
+ registered: Boolean(parsed.registered),
881
+ });
882
+ return;
883
+ }
884
+ }
885
+ catch {
886
+ // Non-JSON line — ignore (older builds may emit progress).
887
+ }
888
+ }
889
+ };
890
+ const onExit = (code) => {
891
+ reject(new Error(`connect child exited before first heartbeat (code ${code ?? "?"}).`));
892
+ };
893
+ child.stdout?.on("data", onData);
894
+ child.on("exit", onExit);
895
+ // Surface child stderr so a connection failure isn't silent.
896
+ child.stderr?.on("data", (chunk) => {
897
+ process.stderr.write(chunk);
898
+ });
899
+ // Hard ceiling — cloudflared startup timeout is 30s; give the
900
+ // backend register call another 15s on top.
901
+ setTimeout(() => {
902
+ child.stdout?.off("data", onData);
903
+ child.off("exit", onExit);
904
+ reject(new Error("Timed out waiting for tunnel to come up."));
905
+ }, CLOUDFLARED_STARTUP_TIMEOUT + 15_000);
906
+ }).catch((err) => {
907
+ try {
908
+ child.kill();
909
+ }
910
+ catch { /* best-effort */ }
911
+ throw err;
912
+ });
913
+ // Detach the child from this parent so it survives. We deliberately
914
+ // don't unref stdio — the child's own SIGTERM handler does graceful
915
+ // shutdown via deregister.
916
+ child.unref();
917
+ console.log(JSON.stringify({
918
+ pid: child.pid,
919
+ tunnel_url: connected.tunnel_url,
920
+ registered: connected.registered,
921
+ }));
922
+ }
923
+ // ---------------------------------------------------------------------------
924
+ // Status — read-only check of the active tunnel.
925
+ // ---------------------------------------------------------------------------
926
+ export function connectStatus(json) {
927
+ const lock = readLock();
928
+ if (!lock || !pidIsAlive(lock.pid)) {
929
+ if (lock && !pidIsAlive(lock.pid))
930
+ clearLock();
931
+ if (json) {
932
+ console.log(JSON.stringify({ active: false }));
933
+ }
934
+ else {
935
+ console.log("No active tunnel.");
936
+ }
937
+ return;
938
+ }
939
+ if (json) {
940
+ console.log(JSON.stringify({
941
+ active: true,
942
+ pid: lock.pid,
943
+ tunnel_url: lock.tunnel_url,
944
+ local_port: lock.local_port,
945
+ registered_at: lock.registered_at,
946
+ api_url: lock.api_url,
947
+ }));
948
+ }
949
+ else {
950
+ console.log(`Active tunnel: ${lock.tunnel_url} → http://localhost:${lock.local_port}`);
951
+ console.log(` pid: ${lock.pid}`);
952
+ console.log(` registered_at: ${lock.registered_at}`);
953
+ console.log(` api: ${lock.api_url}`);
954
+ }
955
+ }
956
+ // ---------------------------------------------------------------------------
957
+ // Disconnect — SIGTERM the tracked PID; the child's own handler does the
958
+ // graceful deregister + cloudflared cleanup. We poll for exit, then clear
959
+ // the lock if the child didn't already.
960
+ // ---------------------------------------------------------------------------
961
+ export async function disconnect(json) {
962
+ const lock = readLock();
963
+ if (!lock) {
964
+ if (json) {
965
+ console.log(JSON.stringify({ status: "no_active_tunnel" }));
966
+ }
967
+ else {
968
+ console.log("No active tunnel.");
969
+ }
970
+ return;
971
+ }
972
+ if (!pidIsAlive(lock.pid)) {
973
+ clearLock();
974
+ if (json) {
975
+ console.log(JSON.stringify({ status: "stale_lock_cleared", pid: lock.pid }));
976
+ }
977
+ else {
978
+ console.log(`Stale lock cleared (pid ${lock.pid} was not running).`);
979
+ }
980
+ return;
981
+ }
982
+ try {
983
+ process.kill(lock.pid, "SIGTERM");
984
+ }
985
+ catch (err) {
986
+ throw new Error(`Failed to signal pid ${lock.pid}: ${err instanceof Error ? err.message : err}`);
987
+ }
988
+ // Poll for graceful exit. The child's own shutdown handler does the
989
+ // backend deregister; once it exits, the lock file is the next thing
990
+ // we clear.
991
+ const deadline = Date.now() + 5_000;
992
+ while (Date.now() < deadline) {
993
+ if (!pidIsAlive(lock.pid))
994
+ break;
995
+ await new Promise((resolve) => setTimeout(resolve, 100));
996
+ }
997
+ if (pidIsAlive(lock.pid)) {
998
+ // Hard fallback: if SIGTERM didn't take, escalate to SIGKILL so the
999
+ // user isn't left with an orphan tunnel.
1000
+ try {
1001
+ process.kill(lock.pid, "SIGKILL");
1002
+ }
1003
+ catch { /* best-effort */ }
1004
+ }
1005
+ clearLock();
1006
+ if (json) {
1007
+ console.log(JSON.stringify({
1008
+ status: "disconnected",
1009
+ pid: lock.pid,
1010
+ tunnel_url: lock.tunnel_url,
1011
+ }));
1012
+ }
1013
+ else {
1014
+ console.log(`Disconnected (pid ${lock.pid}).`);
1015
+ }
692
1016
  }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { program, Option } from "commander";
3
- import { runTunnel } from "./connect.js";
3
+ import { runTunnel, runDetached, connectStatus, disconnect } from "./connect.js";
4
4
  import { login, decodeJwtClaims } from "./auth.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
6
  import { upgrade } from "./upgrade.js";
@@ -11,8 +11,10 @@ import { registerProfileCommands } from "./commands/profile.js";
11
11
  import { registerSourceCommands } from "./commands/source.js";
12
12
  import { registerConfigCommands } from "./commands/config.js";
13
13
  import { registerAskCommands } from "./commands/ask.js";
14
+ import { registerChatCommand } from "./commands/chat.js";
14
15
  import { registerDocsCommands } from "./commands/docs.js";
15
16
  import { registerInitCommands } from "./commands/init.js";
17
+ import { registerSecretCommands } from "./commands/secret.js";
16
18
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
17
19
  import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
18
20
  import { resolveApiUrl, resolveToken } from "./lib/auth.js";
@@ -80,7 +82,7 @@ program
80
82
  // --- Inline commands (from upstream) ---
81
83
  program
82
84
  .command("login")
83
- .description("Authenticate with Ish via your browser")
85
+ .description("Authenticate with ish via your browser")
84
86
  .action(async (_opts, cmd) => {
85
87
  await runInline(cmd, async (globals) => {
86
88
  const tokens = await login();
@@ -217,24 +219,58 @@ program
217
219
  console.error(`\nHint: ${payload.hint}`);
218
220
  });
219
221
  });
220
- program
222
+ const connectCmd = program
221
223
  .command("connect")
222
224
  .description("Expose your localhost to Ish via a Cloudflare tunnel")
223
- .argument("<port>", "Local port to connect (e.g. 3000)")
224
- .addHelpText("after", "\nNote: --json emits structured one-line JSON for connected/disconnected events. --fields and --quiet have limited effect; use --json for machine-readable output.")
225
- .action(async (port, _opts, cmd) => {
225
+ .argument("[port]", "Local port to connect (e.g. 3000)")
226
+ .option("--detach", "Fork after first heartbeat success; print {pid, tunnel_url, registered} JSON and exit")
227
+ .addHelpText("after", `
228
+ Subcommands:
229
+ ish connect status Read-only check of the active detached tunnel
230
+ ish disconnect Graceful shutdown of the active detached tunnel
231
+
232
+ Note: --json emits structured one-line JSON for connected/disconnected events.
233
+ --fields and --quiet have limited effect; use --json for machine-readable output.
234
+
235
+ Detach mode tracks the active tunnel in ~/.ish/connect.lock so \`ish connect
236
+ status\` and \`ish disconnect\` can find it. Foreground (non-detached)
237
+ connects do not write a lock — Ctrl+C is the shutdown verb in that mode.`)
238
+ .action(async (port, opts, cmd) => {
226
239
  await runInline(cmd, async (globals) => {
240
+ if (!port) {
241
+ throw new Error("Pass a port: `ish connect <port>` (e.g. `ish connect 3000`).");
242
+ }
227
243
  const portNum = parseInt(port, 10);
228
244
  if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
229
245
  throw new Error(`Invalid port: ${port}`);
230
246
  }
231
247
  const apiUrl = globals.dev ? "http://localhost:8000" : globals.apiUrl;
248
+ if (opts.detach) {
249
+ await runDetached(portNum, apiUrl, globals.token, globals.tokenFile);
250
+ return;
251
+ }
232
252
  await runTunnel(portNum, globals.token, apiUrl, globals.tokenFile, {
233
253
  json: globals.json,
234
254
  quiet: globals.quiet,
235
255
  });
236
256
  });
237
257
  });
258
+ connectCmd
259
+ .command("status")
260
+ .description("Show the active detached tunnel (or {active:false} when none)")
261
+ .action(async (_opts, cmd) => {
262
+ await runInline(cmd, async (globals) => {
263
+ connectStatus(globals.json);
264
+ });
265
+ });
266
+ program
267
+ .command("disconnect")
268
+ .description("Stop the active detached tunnel (graceful SIGTERM + backend deregister)")
269
+ .action(async (_opts, cmd) => {
270
+ await runInline(cmd, async (globals) => {
271
+ await disconnect(globals.json);
272
+ });
273
+ });
238
274
  // --- Modular command groups ---
239
275
  registerWorkspaceCommands(program);
240
276
  registerStudyCommands(program);
@@ -243,8 +279,10 @@ registerProfileCommands(program);
243
279
  registerSourceCommands(program);
244
280
  registerConfigCommands(program);
245
281
  registerAskCommands(program);
282
+ registerChatCommand(program);
246
283
  registerDocsCommands(program);
247
284
  registerInitCommands(program);
285
+ registerSecretCommands(program);
248
286
  program
249
287
  .command("upgrade")
250
288
  .description("Update ish to the latest version")
@@ -254,4 +292,24 @@ program
254
292
  await upgrade(version, options.release);
255
293
  });
256
294
  injectGlobalWorkspaceOption(program);
295
+ // L3 / Pattern J: surface --get and --fields on every verb's --help.
296
+ // They're declared at the program root, so the per-verb help would
297
+ // otherwise omit them — agents reach for `jq` instead of discovering the
298
+ // in-CLI projection flags. This walks the command tree once at startup
299
+ // and adds a one-line "Tips:" footer to every leaf verb that doesn't
300
+ // already mention `--get`.
301
+ function injectAgentTipsFooter(cmd) {
302
+ for (const sub of cmd.commands) {
303
+ injectAgentTipsFooter(sub);
304
+ // Skip groups (commands that themselves carry subcommands) — their own
305
+ // verbs render the footer; the group help would duplicate it.
306
+ if (sub.commands.length > 0)
307
+ continue;
308
+ const existing = sub.helpInformation();
309
+ if (existing.includes("--get <path>") || existing.includes("--get <field>"))
310
+ continue;
311
+ sub.addHelpText("after", `\nTips:\n Use \`--get <path>\` to capture a single value (e.g. \`--get id\`),\n \`--fields a,b,c\` to project the JSON output to listed fields.\n See \`ish docs get-page reference/json-mode\` for the full output rules.`);
312
+ }
313
+ }
314
+ injectAgentTipsFooter(program);
257
315
  program.parse();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Best-effort alias-cache hydration on alias-miss (Pattern F).
3
+ *
4
+ * The CLI persists aliases to ``~/.ish/aliases.json``, so the cache survives
5
+ * across processes. Where it bites: the file is missing/empty (fresh install,
6
+ * `rm ~/.ish/aliases.json`, agent running in a sandbox with a fresh
7
+ * `ISH_HOME`) and the agent has an alias from a prior process or the docs.
8
+ *
9
+ * ``resolveIdAsync(input, client, hints?)`` mirrors the sync ``resolveId``
10
+ * contract but, on alias-miss, attempts a single ``GET /list`` to repopulate
11
+ * the cache before retrying. The hydrate is BEST-EFFORT: a failing list
12
+ * call is swallowed and the canonical "Unknown alias" error fires with the
13
+ * actionable suggestion in the message.
14
+ *
15
+ * For prefixes whose list endpoint requires a parent ID (study/iteration/
16
+ * ask/etc), the caller passes ``hints`` carrying the parent (workspaceId,
17
+ * studyId). Without a parent we skip the hydrate — global N+1 fan-out is
18
+ * too expensive for a papercut.
19
+ */
20
+ import { ApiClient } from "./api-client.js";
21
+ export interface HydrateHints {
22
+ workspaceId?: string;
23
+ studyId?: string;
24
+ }
25
+ /**
26
+ * Best-effort hydrate of the alias cache for ``alias``'s entity type. Returns
27
+ * silently on success or any failure — the caller checks the cache after
28
+ * this returns.
29
+ */
30
+ export declare function hydrateForAlias(client: ApiClient, alias: string, hints?: HydrateHints): Promise<void>;
31
+ /**
32
+ * Async sibling of ``resolveId``: same UUID-or-alias contract, but on
33
+ * alias-miss attempts a best-effort hydrate via the matching list endpoint
34
+ * before retrying. Falls back to the canonical "Unknown alias" error
35
+ * (with named list-command suggestion) when the alias is still missing
36
+ * after the hydrate.
37
+ *
38
+ * Use this at command-handler entry points where the agent supplies an
39
+ * alias and the same call carries enough context (workspace / study) to
40
+ * scope the hydrate cheaply.
41
+ */
42
+ export declare function resolveIdAsync(input: string, client: ApiClient, hints?: HydrateHints): Promise<string>;