@ishlabs/cli 0.12.0 → 0.12.2
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.d.ts +5 -3
- package/dist/auth.js +69 -30
- package/dist/commands/chat.js +64 -0
- package/dist/index.js +18 -2
- package/dist/lib/docs.js +1 -1
- package/dist/lib/skill-content.js +0 -1
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* server's `local` mode keeps reading them without changes.
|
|
19
19
|
*/
|
|
20
20
|
export declare function getAppUrl(): string;
|
|
21
|
-
export declare function getSupabaseUrl(): string;
|
|
22
|
-
export declare function getSupabaseAnonKey(): string;
|
|
21
|
+
export declare function getSupabaseUrl(dev?: boolean): string;
|
|
22
|
+
export declare function getSupabaseAnonKey(dev?: boolean): string;
|
|
23
23
|
/**
|
|
24
24
|
* Resolve the Supabase project (URL + anon key) that issued the given JWT.
|
|
25
25
|
* Falls back to env vars / production defaults when the token is unparsable
|
|
@@ -32,7 +32,9 @@ export declare function resolveSupabaseProjectFromToken(accessToken: string | un
|
|
|
32
32
|
export declare function decodeJwtExp(token: string): number;
|
|
33
33
|
export declare function decodeJwtClaims(token: string): Record<string, unknown> | undefined;
|
|
34
34
|
export declare function isTokenExpired(token: string, bufferSeconds?: number): boolean;
|
|
35
|
-
export declare function login(
|
|
35
|
+
export declare function login(opts?: {
|
|
36
|
+
dev?: boolean;
|
|
37
|
+
}): Promise<{
|
|
36
38
|
accessToken: string;
|
|
37
39
|
refreshToken: string;
|
|
38
40
|
clientId: string;
|
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 {
|
|
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";
|
|
@@ -29,26 +29,32 @@ const DEFAULT_APP_URL = "https://app.ishlabs.io";
|
|
|
29
29
|
// must send refresh requests back to that same project (with its matching
|
|
30
30
|
// publishable/anon key) or Supabase will reject them.
|
|
31
31
|
const SUPABASE_PROJECTS = {
|
|
32
|
-
//
|
|
33
|
-
"muqvgnqyubmqnfnqwxuk.supabase.co": {
|
|
34
|
-
url: "https://muqvgnqyubmqnfnqwxuk.supabase.co",
|
|
35
|
-
anonKey: "sb_publishable_pxXwY9EaWFwkR7h728NWvQ_NFqGfh8K",
|
|
36
|
-
},
|
|
37
|
-
// Production
|
|
32
|
+
// Production (default — end users hit this)
|
|
38
33
|
"hngymyxdyamokpbeakps.supabase.co": {
|
|
39
34
|
url: "https://hngymyxdyamokpbeakps.supabase.co",
|
|
40
35
|
anonKey: "sb_publishable_JlS-HfwNyDqLNbrfbrkUlw_PSdZJdo2",
|
|
41
36
|
},
|
|
37
|
+
// Development (opt-in via --dev or ISH_SUPABASE_URL/_ANON_KEY env vars)
|
|
38
|
+
"muqvgnqyubmqnfnqwxuk.supabase.co": {
|
|
39
|
+
url: "https://muqvgnqyubmqnfnqwxuk.supabase.co",
|
|
40
|
+
anonKey: "sb_publishable_pxXwY9EaWFwkR7h728NWvQ_NFqGfh8K",
|
|
41
|
+
},
|
|
42
42
|
};
|
|
43
|
-
const
|
|
43
|
+
const PROD_SUPABASE_PROJECT = SUPABASE_PROJECTS["hngymyxdyamokpbeakps.supabase.co"];
|
|
44
|
+
const DEV_SUPABASE_PROJECT = SUPABASE_PROJECTS["muqvgnqyubmqnfnqwxuk.supabase.co"];
|
|
44
45
|
export function getAppUrl() {
|
|
45
46
|
return process.env.ISH_APP_URL ?? DEFAULT_APP_URL;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// Precedence (matches resolveApiUrl): --dev > ISH_SUPABASE_URL env > prod default.
|
|
49
|
+
export function getSupabaseUrl(dev) {
|
|
50
|
+
if (dev)
|
|
51
|
+
return DEV_SUPABASE_PROJECT.url;
|
|
52
|
+
return process.env.ISH_SUPABASE_URL ?? PROD_SUPABASE_PROJECT.url;
|
|
49
53
|
}
|
|
50
|
-
export function getSupabaseAnonKey() {
|
|
51
|
-
|
|
54
|
+
export function getSupabaseAnonKey(dev) {
|
|
55
|
+
if (dev)
|
|
56
|
+
return DEV_SUPABASE_PROJECT.anonKey;
|
|
57
|
+
return process.env.ISH_SUPABASE_ANON_KEY ?? PROD_SUPABASE_PROJECT.anonKey;
|
|
52
58
|
}
|
|
53
59
|
/**
|
|
54
60
|
* Resolve the Supabase project (URL + anon key) that issued the given JWT.
|
|
@@ -73,7 +79,7 @@ export function resolveSupabaseProjectFromToken(accessToken) {
|
|
|
73
79
|
// default key for apikey (best-effort; will likely fail without env override).
|
|
74
80
|
return {
|
|
75
81
|
url: `https://${host}`,
|
|
76
|
-
anonKey: envKey ??
|
|
82
|
+
anonKey: envKey ?? PROD_SUPABASE_PROJECT.anonKey,
|
|
77
83
|
};
|
|
78
84
|
}
|
|
79
85
|
}
|
|
@@ -82,21 +88,21 @@ export function resolveSupabaseProjectFromToken(accessToken) {
|
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
90
|
return {
|
|
85
|
-
url: envUrl ??
|
|
86
|
-
anonKey: envKey ??
|
|
91
|
+
url: envUrl ?? PROD_SUPABASE_PROJECT.url,
|
|
92
|
+
anonKey: envKey ?? PROD_SUPABASE_PROJECT.anonKey,
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
// --- Browser open ---
|
|
90
96
|
function openBrowser(url) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// detached + unref so the child doesn't keep our event loop alive after the
|
|
98
|
+
// login flow finishes. stdio: "ignore" so we don't pipe-buffer its output.
|
|
99
|
+
const opts = { detached: true, stdio: "ignore" };
|
|
100
|
+
const child = process.platform === "win32"
|
|
101
|
+
? spawn("cmd", ["/c", "start", "", url], opts)
|
|
102
|
+
: process.platform === "darwin"
|
|
103
|
+
? spawn("open", [url], opts)
|
|
104
|
+
: spawn("xdg-open", [url], opts);
|
|
105
|
+
child.unref();
|
|
100
106
|
}
|
|
101
107
|
// --- JWT decode ---
|
|
102
108
|
export function decodeJwtExp(token) {
|
|
@@ -130,10 +136,15 @@ function startCallbackServer() {
|
|
|
130
136
|
const callbackPromise = new Promise((res) => {
|
|
131
137
|
resolveCallback = res;
|
|
132
138
|
});
|
|
139
|
+
// Track every socket the server accepts so we can destroy them on close.
|
|
140
|
+
// Browsers open multiple keep-alive connections (favicon prefetch, parallel
|
|
141
|
+
// request slots) and Node's server.close() waits for those to drain — that
|
|
142
|
+
// wait is what was hanging `ish login` after success.
|
|
143
|
+
const sockets = new Set();
|
|
133
144
|
const server = http.createServer((req, res) => {
|
|
134
145
|
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
135
146
|
if (reqUrl.pathname !== "/callback") {
|
|
136
|
-
res.writeHead(404);
|
|
147
|
+
res.writeHead(404, { Connection: "close" });
|
|
137
148
|
res.end();
|
|
138
149
|
return;
|
|
139
150
|
}
|
|
@@ -148,11 +159,21 @@ function startCallbackServer() {
|
|
|
148
159
|
? `${cb.error}${cb.errorDescription ? `: ${cb.errorDescription}` : ""}`
|
|
149
160
|
: "You can close this window and return to your terminal.";
|
|
150
161
|
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
|
-
|
|
162
|
+
// Connection: close so the browser tears down the socket immediately;
|
|
163
|
+
// otherwise HTTP/1.1 keep-alive holds the socket open and prevents
|
|
164
|
+
// server.close() from freeing the event loop, hanging `ish login`.
|
|
165
|
+
res.writeHead(cb.error ? 400 : 200, {
|
|
166
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
167
|
+
Connection: "close",
|
|
168
|
+
});
|
|
152
169
|
res.end(html);
|
|
153
170
|
if (resolveCallback)
|
|
154
171
|
resolveCallback(cb);
|
|
155
172
|
});
|
|
173
|
+
server.on("connection", (socket) => {
|
|
174
|
+
sockets.add(socket);
|
|
175
|
+
socket.on("close", () => sockets.delete(socket));
|
|
176
|
+
});
|
|
156
177
|
server.on("error", reject);
|
|
157
178
|
server.listen(0, "127.0.0.1", () => {
|
|
158
179
|
const addr = server.address();
|
|
@@ -165,6 +186,14 @@ function startCallbackServer() {
|
|
|
165
186
|
waitForCallback: () => callbackPromise,
|
|
166
187
|
close: () => {
|
|
167
188
|
server.close();
|
|
189
|
+
// Force-destroy every socket — Connection: close + closeAllConnections
|
|
190
|
+
// alone weren't enough on macOS, where the browser kept enough idle
|
|
191
|
+
// sockets alive to hold the event loop open.
|
|
192
|
+
server.closeAllConnections?.();
|
|
193
|
+
for (const socket of sockets)
|
|
194
|
+
socket.destroy();
|
|
195
|
+
sockets.clear();
|
|
196
|
+
server.unref();
|
|
168
197
|
},
|
|
169
198
|
});
|
|
170
199
|
});
|
|
@@ -198,8 +227,8 @@ function generatePkcePair() {
|
|
|
198
227
|
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
199
228
|
return { verifier, challenge };
|
|
200
229
|
}
|
|
201
|
-
export async function login() {
|
|
202
|
-
const supabaseUrl = getSupabaseUrl();
|
|
230
|
+
export async function login(opts = {}) {
|
|
231
|
+
const supabaseUrl = getSupabaseUrl(opts.dev);
|
|
203
232
|
const server = await startCallbackServer();
|
|
204
233
|
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
205
234
|
try {
|
|
@@ -218,10 +247,20 @@ export async function login() {
|
|
|
218
247
|
console.error(`If the browser doesn't open, visit:\n ${authorizeUrl.toString()}\n`);
|
|
219
248
|
openBrowser(authorizeUrl.toString());
|
|
220
249
|
console.error("Waiting for authentication...");
|
|
250
|
+
let timeoutHandle;
|
|
221
251
|
const timeoutPromise = new Promise((_, reject) => {
|
|
222
|
-
setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
|
|
252
|
+
timeoutHandle = setTimeout(() => reject(new Error("Login timed out. Please try again.")), LOGIN_TIMEOUT);
|
|
223
253
|
});
|
|
224
|
-
|
|
254
|
+
let cb;
|
|
255
|
+
try {
|
|
256
|
+
cb = await Promise.race([server.waitForCallback(), timeoutPromise]);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
// Without this, the 5-minute timer keeps the event loop alive long
|
|
260
|
+
// after the callback fires, hanging `ish login` until expiry.
|
|
261
|
+
if (timeoutHandle)
|
|
262
|
+
clearTimeout(timeoutHandle);
|
|
263
|
+
}
|
|
225
264
|
if (cb.error) {
|
|
226
265
|
throw new Error(`OAuth error: ${cb.error}${cb.errorDescription ? ` — ${cb.errorDescription}` : ""}`);
|
|
227
266
|
}
|
package/dist/commands/chat.js
CHANGED
|
@@ -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
|
-
.
|
|
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)")
|
|
@@ -85,7 +89,7 @@ program
|
|
|
85
89
|
.description("Authenticate with ish via your browser")
|
|
86
90
|
.action(async (_opts, cmd) => {
|
|
87
91
|
await runInline(cmd, async (globals) => {
|
|
88
|
-
const tokens = await login();
|
|
92
|
+
const tokens = await login({ dev: globals.dev });
|
|
89
93
|
const config = loadConfig();
|
|
90
94
|
config.access_token = tokens.accessToken;
|
|
91
95
|
config.refresh_token = tokens.refreshToken;
|
|
@@ -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
|
|
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.
|