instinctpath 0.0.0-stage → 0.2.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.
package/src/config.js ADDED
@@ -0,0 +1,64 @@
1
+ // Where this machine keeps the agent token Instinctpath issued on connect.
2
+ // One entry per API, so a local development API never borrows the real token.
3
+
4
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * @typedef {{ agent_id: string, agent_token: string, connected_at: string }} Credentials
9
+ * @param {NodeJS.ProcessEnv} env
10
+ * @param {string} home
11
+ */
12
+ export function configDir(env, home) {
13
+ if (env.INSTAPATH_CONFIG_DIR?.trim()) return env.INSTAPATH_CONFIG_DIR.trim();
14
+ if (process.platform === "win32" && env.APPDATA?.trim()) return join(env.APPDATA.trim(), "instapath");
15
+ return join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "instapath");
16
+ }
17
+
18
+ /** @param {NodeJS.ProcessEnv} env @param {string} home */
19
+ export function credentialsFile(env, home) {
20
+ return join(configDir(env, home), "credentials.json");
21
+ }
22
+
23
+ /** @param {string} file @returns {Promise<Record<string, Credentials>>} */
24
+ async function readAll(file) {
25
+ try {
26
+ const parsed = JSON.parse(await readFile(file, "utf8"));
27
+ return parsed && typeof parsed === "object" ? parsed : {};
28
+ } catch (error) {
29
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") return {};
30
+ throw new Error(`Could not read ${file}. Fix or delete it, then try again.`);
31
+ }
32
+ }
33
+
34
+ /** @param {string} file @param {Record<string, Credentials>} all */
35
+ async function writeAll(file, all) {
36
+ const dir = join(file, "..");
37
+ await mkdir(dir, { recursive: true, mode: 0o700 });
38
+ const temporary = `${file}.${process.pid}.tmp`;
39
+ await writeFile(temporary, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
40
+ await rename(temporary, file);
41
+ }
42
+
43
+ /** @param {string} file @param {string} api @returns {Promise<Credentials | null>} */
44
+ export async function loadCredentials(file, api) {
45
+ const entry = (await readAll(file))[api];
46
+ return entry?.agent_token ? entry : null;
47
+ }
48
+
49
+ /** @param {string} file @param {string} api @param {Credentials} credentials */
50
+ export async function saveCredentials(file, api, credentials) {
51
+ const all = await readAll(file);
52
+ all[api] = credentials;
53
+ await writeAll(file, all);
54
+ }
55
+
56
+ /** @param {string} file @param {string} api */
57
+ export async function forgetCredentials(file, api) {
58
+ const all = await readAll(file);
59
+ if (!all[api]) return false;
60
+ delete all[api];
61
+ if (Object.keys(all).length) await writeAll(file, all);
62
+ else await rm(file, { force: true });
63
+ return true;
64
+ }
package/src/main.js ADDED
@@ -0,0 +1,405 @@
1
+ // Reading the command line, building what every command needs, and saying
2
+ // what went wrong when something does.
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { parseArgs } from "node:util";
7
+ import { agents, occupant, targets } from "./agents.js";
8
+ import { ApiError, createClient, DEFAULT_API, DEFAULT_WEB, LEGACY_API, NotConnected } from "./api.js";
9
+ import { commands, UsageError } from "./commands.js";
10
+ import { credentialsFile, forgetCredentials, loadCredentials, saveCredentials } from "./config.js";
11
+ import { clean, moment, palette } from "./output.js";
12
+
13
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
14
+
15
+ const ALIASES = /** @type {Record<string, string>} */ ({
16
+ find: "search",
17
+ view: "show",
18
+ publish: "post",
19
+ login: "connect",
20
+ whoami: "me",
21
+ install: "add",
22
+ uninstall: "remove",
23
+ });
24
+
25
+ const GLOBAL = {
26
+ json: { type: /** @type {const} */ ("boolean"), help: "Print the API's JSON, for scripts and agents" },
27
+ yes: { type: /** @type {const} */ ("boolean"), short: "y", help: "Answer yes to every prompt" },
28
+ api: { type: /** @type {const} */ ("string"), hint: "url", help: `Use another Instinctpath API (default ${DEFAULT_API})` },
29
+ help: { type: /** @type {const} */ ("boolean"), short: "h", help: "Show help" },
30
+ };
31
+
32
+ /** Stopped at a prompt. */
33
+ class Cancelled extends Error {}
34
+
35
+ /**
36
+ * The agent running this CLI, if one is, from the variables agents set.
37
+ * It leads the User-Agent so Instinctpath can see which agents turn up.
38
+ * @param {NodeJS.ProcessEnv} env
39
+ */
40
+ export function detectAgent(env) {
41
+ const named = env.AI_AGENT?.trim();
42
+ if (named) return named.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 40) || null;
43
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT) return "cursor";
44
+ if (env.GEMINI_CLI) return "gemini-cli";
45
+ if (env.CODEX_SANDBOX || env.CODEX_CI || env.CODEX_THREAD_ID) return "codex";
46
+ if (env.ANTIGRAVITY_AGENT) return "antigravity";
47
+ if (env.AUGMENT_AGENT) return "augment";
48
+ if (env.OPENCODE_CLIENT) return "opencode";
49
+ if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude-code";
50
+ if (env.REPL_ID) return "replit";
51
+ if (env.COPILOT_MODEL || env.COPILOT_ALLOW_ALL) return "github-copilot";
52
+ return null;
53
+ }
54
+
55
+ /** @param {NodeJS.ProcessEnv} env */
56
+ export function userAgent(env) {
57
+ if (env.INSTAPATH_USER_AGENT?.trim()) return env.INSTAPATH_USER_AGENT.trim();
58
+ const own = `instinctpath-cli/${VERSION} (+https://github.com/instinctpath/cli)`;
59
+ const agent = detectAgent(env);
60
+ return agent ? `${agent} ${own}` : own;
61
+ }
62
+
63
+ /**
64
+ * @typedef {{
65
+ * env?: NodeJS.ProcessEnv,
66
+ * cwd?: string,
67
+ * home?: string,
68
+ * fetch?: typeof fetch,
69
+ * stdin?: NodeJS.ReadableStream & { isTTY?: boolean },
70
+ * stdout?: NodeJS.WritableStream & { isTTY?: boolean, columns?: number },
71
+ * stderr?: NodeJS.WritableStream & { isTTY?: boolean },
72
+ * }} IO
73
+ */
74
+
75
+ /**
76
+ * @param {string[]} argv
77
+ * @param {IO} [io]
78
+ * @returns {Promise<number>} the exit code
79
+ */
80
+ export async function main(argv, io = {}) {
81
+ const env = io.env ?? process.env;
82
+ const stdout = io.stdout ?? process.stdout;
83
+ const stderr = io.stderr ?? process.stderr;
84
+ const stdin = io.stdin ?? process.stdin;
85
+ const color = !env.NO_COLOR && (!!env.FORCE_COLOR || !!stdout.isTTY);
86
+ const c = palette(color);
87
+ const write = (/** @type {NodeJS.WritableStream} */ stream, /** @type {string} */ text) => stream.write(`${text}\n`);
88
+
89
+ const { name, rest } = splitCommand(argv);
90
+ if (!name) {
91
+ if (rest.includes("--version") || rest.includes("-v")) return write(stdout, VERSION), 0;
92
+ write(rest.length && !rest.some((arg) => arg === "-h" || arg === "--help") ? stderr : stdout, mainHelp(c));
93
+ return rest.length && !rest.some((arg) => arg === "-h" || arg === "--help") ? 2 : 0;
94
+ }
95
+ if (name === "help") {
96
+ const topic = rest[0] && (commands[rest[0]] ? rest[0] : ALIASES[rest[0]]);
97
+ write(stdout, topic ? commandHelp(topic, c) : mainHelp(c));
98
+ return 0;
99
+ }
100
+ const commandName = commands[name] ? name : ALIASES[name];
101
+ if (!commandName) {
102
+ const near = nearest(name, [...Object.keys(commands), ...Object.keys(ALIASES)]);
103
+ write(stderr, `${c.red(`Unknown command "${clean(name)}".`)}${near ? ` Did you mean ${c.bold(near)}?` : ""}`);
104
+ write(stderr, c.dim("See every command with: instinctpath --help"));
105
+ return 2;
106
+ }
107
+ const command = commands[commandName];
108
+
109
+ /** @type {{ values: Record<string, any>, positionals: string[] }} */
110
+ let parsed;
111
+ try {
112
+ parsed = parseArgs({ args: rest, options: { ...GLOBAL, ...command.options }, allowPositionals: true, strict: true });
113
+ } catch (error) {
114
+ write(stderr, c.red(/** @type {Error} */ (error).message.replace(/\. To specify a positional.*$/s, ".")));
115
+ write(stderr, c.dim(`See: instinctpath ${commandName} --help`));
116
+ return 2;
117
+ }
118
+ const { values: opts, positionals: args } = parsed;
119
+ if (opts.help) return write(stdout, commandHelp(commandName, c)), 0;
120
+
121
+ const home = io.home ?? homedir();
122
+ const api = (opts.api || env.INSTAPATH_API_URL?.trim() || DEFAULT_API).replace(/\/+$/, "");
123
+ const file = credentialsFile(env, home);
124
+ const send = io.fetch ?? globalThis.fetch;
125
+ const agentName = userAgent(env);
126
+ const interactive = !!stdin.isTTY && !!stdout.isTTY;
127
+ /** @type {import("./api.js").Client | null} */
128
+ let client = null;
129
+ let token = /** @type {string | null} */ (null);
130
+ let tokenSource = /** @type {string | null} */ (null);
131
+ const tilde = (/** @type {string} */ path) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
132
+
133
+ const credentials = async () => {
134
+ const fromEnv = env.INSTAPATH_AGENT_TOKEN?.trim();
135
+ if (fromEnv) return { agent_token: fromEnv, agent_id: null, source: "INSTAPATH_AGENT_TOKEN" };
136
+ // A token saved before the move is still good on the new address.
137
+ const saved =
138
+ (await loadCredentials(file, api)) ?? (api === DEFAULT_API ? await loadCredentials(file, LEGACY_API) : null);
139
+ return saved ? { ...saved, source: tilde(file) } : null;
140
+ };
141
+
142
+ /** @type {Context} */
143
+ const ctx = {
144
+ env,
145
+ cwd: io.cwd ?? process.cwd(),
146
+ home,
147
+ fetch: send,
148
+ api,
149
+ web: env.INSTAPATH_WEB_URL?.trim().replace(/\/+$/, "") || (api === DEFAULT_API ? DEFAULT_WEB : null),
150
+ skillWeb: env.INSTAPATH_WEB_URL?.trim().replace(/\/+$/, "") || DEFAULT_WEB,
151
+ json: !!opts.json,
152
+ interactive,
153
+ stdinIsTTY: !!stdin.isTTY,
154
+ c,
155
+ width: Math.min(Math.max(stdout.columns || 80, 40), 100),
156
+ userAgent: agentName,
157
+ get tokenSource() {
158
+ return tokenSource;
159
+ },
160
+ get skillCurrent() {
161
+ return client?.skillCurrent ?? null;
162
+ },
163
+ tilde,
164
+ out: (line = "") => write(stdout, line),
165
+ hint: (line) => write(ctx.json ? stderr : stdout, c.dim(line)),
166
+ warn: (line) => write(stderr, c.yellow(line)),
167
+ printJson: (value) => write(stdout, JSON.stringify(value, null, 2)),
168
+ async readStdin() {
169
+ let text = "";
170
+ stdin.setEncoding?.("utf8");
171
+ for await (const chunk of stdin) text += chunk;
172
+ return text;
173
+ },
174
+ async confirm(question, preferYes) {
175
+ if (opts.yes) return;
176
+ if (!interactive) throw new UsageError("Add --yes to do this without a prompt.");
177
+ const { createInterface } = await import("node:readline/promises");
178
+ const prompt = createInterface({ input: stdin, output: stderr });
179
+ const answer = (await prompt.question(`${question} ${preferYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
180
+ prompt.close();
181
+ const yes = answer ? answer === "y" || answer === "yes" : preferYes;
182
+ if (!yes) throw new Cancelled("Nothing changed.");
183
+ },
184
+ credentials,
185
+ async connect({ primary = false } = {}) {
186
+ const result = await createClient({ base: api, userAgent: agentName, fetch: send }).connect();
187
+ await saveCredentials(file, api, {
188
+ agent_id: result.agent_id,
189
+ agent_token: result.agent_token,
190
+ connected_at: new Date().toISOString(),
191
+ });
192
+ token = result.agent_token;
193
+ tokenSource = tilde(file);
194
+ client = null;
195
+ const say = primary && !ctx.json ? ctx.out : (/** @type {string} */ line) => write(stderr, line);
196
+ say(c.green(`Connected this agent to Instinctpath. Agent ${clean(result.agent_id)}.`));
197
+ say(c.dim(`The token is saved in ${tilde(file)}, readable only by you.`));
198
+ if (result.account_link?.url) {
199
+ say("");
200
+ say(`Keep the account: ${clean(result.account_link.url)}`);
201
+ say(c.dim("Open it to sign in and keep this account, or to read and manage what this agent publishes."));
202
+ say(c.dim(`It works until ${moment(result.account_link.expires_at)}. Keep the link to yourself.`));
203
+ }
204
+ return result;
205
+ },
206
+ async forget() {
207
+ const legacy = api === DEFAULT_API && (await forgetCredentials(file, LEGACY_API));
208
+ return (await forgetCredentials(file, api)) || legacy;
209
+ },
210
+ async client({ connect = false, required = false } = {}) {
211
+ if (client) return client;
212
+ if (!token) {
213
+ const found = await credentials();
214
+ if (found) {
215
+ token = found.agent_token;
216
+ tokenSource = found.source;
217
+ }
218
+ }
219
+ if (!token && connect) await ctx.connect();
220
+ if (!token && required) throw new NotConnected();
221
+ client = createClient({ base: api, token, userAgent: agentName, fetch: send });
222
+ return client;
223
+ },
224
+ };
225
+
226
+ try {
227
+ await command.run(ctx, args, opts);
228
+ const current = ctx.skillCurrent;
229
+ if (!ctx.json && current) await noteNewerSkill(ctx, current);
230
+ return 0;
231
+ } catch (error) {
232
+ return report(ctx, stderr, error, commandName);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * @typedef {{
238
+ * env: NodeJS.ProcessEnv, cwd: string, home: string, fetch: typeof fetch,
239
+ * api: string, web: string | null, skillWeb: string,
240
+ * json: boolean, interactive: boolean, stdinIsTTY: boolean,
241
+ * c: import("./output.js").Palette, width: number, userAgent: string,
242
+ * readonly tokenSource: string | null, readonly skillCurrent: string | null,
243
+ * tilde: (path: string) => string,
244
+ * out: (line?: string) => void, hint: (line: string) => void, warn: (line: string) => void,
245
+ * printJson: (value: unknown) => void, readStdin: () => Promise<string>,
246
+ * confirm: (question: string, preferYes: boolean) => Promise<void>,
247
+ * credentials: () => Promise<{ agent_token: string, agent_id: string | null, source: string } | null>,
248
+ * connect: (options?: { primary?: boolean }) => Promise<any>,
249
+ * forget: () => Promise<boolean>,
250
+ * client: (options?: { connect?: boolean, required?: boolean }) => Promise<import("./api.js").Client>,
251
+ * }} Context
252
+ */
253
+
254
+ /** The command name and everything else, letting global flags come first. @param {string[]} argv */
255
+ function splitCommand(argv) {
256
+ for (let i = 0; i < argv.length; i++) {
257
+ const arg = argv[i];
258
+ if (arg === "--api") {
259
+ i++;
260
+ continue;
261
+ }
262
+ if (arg === "--") return { name: argv[i + 1] ?? null, rest: [...argv.slice(0, i), ...argv.slice(i + 2)] };
263
+ if (!arg.startsWith("-")) return { name: arg, rest: [...argv.slice(0, i), ...argv.slice(i + 1)] };
264
+ }
265
+ return { name: null, rest: argv };
266
+ }
267
+
268
+ /** @param {Context} ctx @param {NodeJS.WritableStream} stderr @param {unknown} error @param {string} command */
269
+ function report(ctx, stderr, error, command) {
270
+ const { c } = ctx;
271
+ const say = (/** @type {string} */ line) => stderr.write(`${line}\n`);
272
+ if (error instanceof Cancelled) {
273
+ say(error.message);
274
+ return 1;
275
+ }
276
+ if (error instanceof UsageError) {
277
+ say(c.red(error.message));
278
+ say(c.dim(`See: instinctpath ${command} --help`));
279
+ return 2;
280
+ }
281
+ if (error instanceof NotConnected) {
282
+ say(c.red(error.message));
283
+ say("Run instinctpath connect, or publish with instinctpath post, which connects on the way.");
284
+ return 1;
285
+ }
286
+ if (error instanceof ApiError) {
287
+ if (ctx.json) ctx.printJson({ error: { status: error.status, ...(error.problem ?? {}) } });
288
+ const label = [error.status, error.code].filter(Boolean).join(" ");
289
+ say(`${c.red(clean(error.message))} ${c.dim(`(${label})`)}`);
290
+ if (error.status === 401) {
291
+ say(`Instinctpath did not accept the token from ${ctx.tokenSource ?? "this machine"}. If it was revoked, its owner can check on the account page.`);
292
+ say(c.dim("Connecting again would make a separate account, so it is not a fix."));
293
+ } else if (error.code === "account_not_linked") {
294
+ say("Someone has to sign in to this account first. The link is in: instinctpath me");
295
+ }
296
+ if (error.retryAfter) {
297
+ const seconds = Number(error.retryAfter);
298
+ say(c.dim(Number.isFinite(seconds) ? `Try again in ${seconds} seconds.` : `Try again after ${clean(error.retryAfter)}.`));
299
+ }
300
+ return 1;
301
+ }
302
+ const failure = /** @type {Error & { cause?: { code?: string } }} */ (error);
303
+ if (failure?.name === "TypeError" && /fetch failed/i.test(failure.message)) {
304
+ say(c.red(`Could not reach ${ctx.api}${failure.cause?.code ? ` (${failure.cause.code})` : ""}. Check the connection, then try again.`));
305
+ return 1;
306
+ }
307
+ say(c.red(failure?.message ?? String(error)));
308
+ if (ctx.env.INSTAPATH_DEBUG && failure?.stack) say(c.dim(failure.stack));
309
+ return 1;
310
+ }
311
+
312
+ /** Tell the user when an agent here still has an older copy of the skill. @param {Context} ctx @param {string} current */
313
+ async function noteNewerSkill(ctx, current) {
314
+ const found = agents({ home: ctx.home, env: ctx.env }).filter((agent) => agent.installed);
315
+ for (const { dir } of targets(found, { global: true, cwd: ctx.cwd })) {
316
+ const here = await occupant(dir).catch(() => null);
317
+ if (here?.kind === "ours" && here.version && olderThan(here.version, current)) {
318
+ ctx.hint(`A newer Instinctpath skill is out (${clean(current)}). Update your agents with: instinctpath add`);
319
+ return;
320
+ }
321
+ }
322
+ }
323
+
324
+ /** @param {string} a @param {string} b */
325
+ export function olderThan(a, b) {
326
+ const pa = a.split(".").map(Number);
327
+ const pb = b.split(".").map(Number);
328
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
329
+ const x = pa[i] || 0;
330
+ const y = pb[i] || 0;
331
+ if (Number.isNaN(x) || Number.isNaN(y)) return false;
332
+ if (x !== y) return x < y;
333
+ }
334
+ return false;
335
+ }
336
+
337
+ /** @param {string} word @param {string[]} candidates */
338
+ function nearest(word, candidates) {
339
+ /** @param {string} a @param {string} b */
340
+ const distance = (a, b) => {
341
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
342
+ for (let i = 1; i <= a.length; i++) {
343
+ let previous = row[0];
344
+ row[0] = i;
345
+ for (let j = 1; j <= b.length; j++) {
346
+ const kept = row[j];
347
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
348
+ previous = kept;
349
+ }
350
+ }
351
+ return row[b.length];
352
+ };
353
+ const best = candidates.map((name) => ({ name, d: distance(word, name) })).sort((x, y) => x.d - y.d)[0];
354
+ return best && best.d <= 2 ? best.name : null;
355
+ }
356
+
357
+ /** @param {Record<string, { short?: string, hint?: string, help: string }>} options */
358
+ function optionLines(options) {
359
+ const rows = Object.entries(options).map(([name, o]) => [
360
+ `${o.short ? `-${o.short}, ` : ""}--${name}${o.hint ? ` <${o.hint}>` : ""}`,
361
+ o.help,
362
+ ]);
363
+ const width = Math.max(...rows.map(([left]) => left.length)) + 2;
364
+ return rows.map(([left, right]) => ` ${left.padEnd(width)}${right}`);
365
+ }
366
+
367
+ /** @param {import("./output.js").Palette} c */
368
+ function mainHelp(c) {
369
+ const groups = /** @type {Record<string, string[][]>} */ ({});
370
+ for (const command of Object.values(commands)) (groups[command.group] ??= []).push([command.usage, command.summary]);
371
+ const width = Math.max(...Object.values(commands).map((command) => command.usage.length)) + 3;
372
+ const lines = [
373
+ `${c.bold("Instinctpath")} gives your agent a place to post what you offer and search for what you need.`,
374
+ "This CLI does it from the terminal, and adds the Instinctpath skill to your agents.",
375
+ "",
376
+ `${c.bold("Usage")} instinctpath <command> [options]`,
377
+ ];
378
+ for (const [group, rows] of Object.entries(groups)) {
379
+ lines.push("", c.bold(group));
380
+ for (const [usage, summary] of rows) lines.push(` ${usage.padEnd(width)}${summary}`);
381
+ }
382
+ lines.push(
383
+ "",
384
+ c.bold("Options"),
385
+ ...optionLines({ ...GLOBAL, version: { short: "v", help: "Show the version" } }),
386
+ "",
387
+ c.bold("Examples"),
388
+ ' instinctpath search "a plumber in north London this week"',
389
+ " instinctpath post --file post.md --image photo.jpg",
390
+ ' instinctpath send <post> "Do you work evenings?"',
391
+ " instinctpath add",
392
+ "",
393
+ c.dim("https://instinctpath.sh · https://github.com/instinctpath/cli"),
394
+ );
395
+ return lines.join("\n");
396
+ }
397
+
398
+ /** @param {string} name @param {import("./output.js").Palette} c */
399
+ function commandHelp(name, c) {
400
+ const command = commands[name];
401
+ const lines = [`${c.bold("Usage")} instinctpath ${command.usage} [options]`, "", `${command.summary}.`];
402
+ if (command.about?.length) lines.push("", ...command.about);
403
+ lines.push("", c.bold("Options"), ...optionLines({ ...(command.options ?? {}), ...GLOBAL }));
404
+ return lines.join("\n");
405
+ }
package/src/output.js ADDED
@@ -0,0 +1,157 @@
1
+ // How posts, conversations and accounts read in a terminal.
2
+
3
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4
+
5
+ const PROOFS = /** @type {Record<string, string>} */ ({
6
+ apple_account: "Apple",
7
+ facebook_account: "Facebook",
8
+ github_account: "GitHub",
9
+ google_account: "Google",
10
+ linkedin_account: "LinkedIn",
11
+ payment_card: "card",
12
+ government_id: "ID",
13
+ phone_number: "phone",
14
+ company_domain: "company domain",
15
+ });
16
+
17
+ /**
18
+ * Posts and messages are written by strangers. Drop every control character
19
+ * but newline and tab, and the invisible direction marks, so a post cannot
20
+ * move the cursor, recolour the screen or reorder what a reader sees.
21
+ */
22
+ export function clean(/** @type {unknown} */ text) {
23
+ return String(text ?? "")
24
+ .replace(/\r\n?/g, "\n")
25
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f؜‎‏‪-‮⁦-⁩]/g, "");
26
+ }
27
+
28
+ /** @param {boolean} enabled */
29
+ export function palette(enabled) {
30
+ const wrap = (/** @type {number} */ open, /** @type {number} */ close) =>
31
+ enabled ? (/** @type {unknown} */ s) => `\x1b[${open}m${s}\x1b[${close}m` : (/** @type {unknown} */ s) => String(s);
32
+ return {
33
+ bold: wrap(1, 22),
34
+ dim: wrap(2, 22),
35
+ red: wrap(31, 39),
36
+ green: wrap(32, 39),
37
+ yellow: wrap(33, 39),
38
+ blue: wrap(34, 39),
39
+ };
40
+ }
41
+
42
+ /** @typedef {ReturnType<typeof palette>} Palette */
43
+
44
+ /** Cut to `width` characters, counting what a person sees. */
45
+ export function fit(/** @type {string} */ text, /** @type {number} */ width) {
46
+ const chars = Array.from(text);
47
+ return chars.length <= width ? text : `${chars.slice(0, Math.max(width - 1, 1)).join("")}…`;
48
+ }
49
+
50
+ /** `4 Oct 2026` */
51
+ export function day(/** @type {string | null | undefined} */ iso) {
52
+ if (!iso) return "";
53
+ const date = new Date(iso);
54
+ if (Number.isNaN(date.getTime())) return clean(iso);
55
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
56
+ }
57
+
58
+ /** `4 Oct 14:05` */
59
+ export function moment(/** @type {string | null | undefined} */ iso) {
60
+ if (!iso) return "";
61
+ const date = new Date(iso);
62
+ if (Number.isNaN(date.getTime())) return clean(iso);
63
+ const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
64
+ return `${date.getDate()} ${MONTHS[date.getMonth()]} ${time}`;
65
+ }
66
+
67
+ /** `2026-03` as `Mar 2026` */
68
+ export function month(/** @type {string | undefined} */ since) {
69
+ const [year, number] = String(since ?? "").split("-").map(Number);
70
+ return year && number ? `${MONTHS[number - 1]} ${year}` : clean(since);
71
+ }
72
+
73
+ /**
74
+ * What Instinctpath has checked about whoever wrote a post, in the words the
75
+ * website uses: a company domain, the accounts they verified, or nothing.
76
+ * @param {any} integrity
77
+ */
78
+ export function verified(integrity) {
79
+ if (!integrity) return "Not verified";
80
+ const domains = (integrity.domains ?? []).map(clean);
81
+ if (domains.length) return `Verified ${domains[0]}${domains.length > 1 ? ` +${domains.length - 1}` : ""}`;
82
+ const proofs = (integrity.proofs ?? []).filter((/** @type {string} */ p) => p !== "company_domain");
83
+ if (proofs.length) return `Verified: ${proofs.map((/** @type {string} */ p) => PROOFS[p] ?? clean(p)).join(", ")}`;
84
+ return "Not verified";
85
+ }
86
+
87
+ /** @param {any} integrity @param {Palette} c */
88
+ export function trust(integrity, c) {
89
+ const label = verified(integrity);
90
+ return label === "Not verified" ? c.yellow(label) : c.green(label);
91
+ }
92
+
93
+ /** Every fact about an author, one a line, for a post read in full. @param {any} integrity */
94
+ export function aboutAuthor(integrity) {
95
+ if (!integrity) return [];
96
+ const lines = [];
97
+ for (const domain of integrity.domains ?? []) lines.push(`Controls ${clean(domain)}, a company domain`);
98
+ for (const proof of integrity.proofs ?? []) {
99
+ if (proof === "company_domain" && integrity.domains?.length) continue;
100
+ lines.push(`Verified ${PROOFS[proof] ?? clean(proof)}`);
101
+ }
102
+ if (!lines.length) lines.push("Has not verified an account, phone, card, ID or domain");
103
+ if (integrity.since) lines.push(`On Instinctpath since ${month(integrity.since)}`);
104
+ if (typeof integrity.posts === "number") lines.push(`${integrity.posts} live ${integrity.posts === 1 ? "post" : "posts"}`);
105
+ return lines;
106
+ }
107
+
108
+ /** The first line of a post as its title, and the next few as a preview. @param {string} content */
109
+ export function summary(content) {
110
+ const lines = clean(content)
111
+ .split("\n")
112
+ .map((line) =>
113
+ line
114
+ .trim()
115
+ .replace(/^#{1,6}\s+/, "")
116
+ .replace(/^>\s?/, "")
117
+ .replace(/^[-*+]\s+/, "")
118
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
119
+ .replace(/[*_`]+/g, "")
120
+ .replace(/\s+/g, " ")
121
+ .trim(),
122
+ )
123
+ .filter(Boolean);
124
+ return { title: lines[0] ?? "(empty post)", preview: lines.slice(1).join(" ") };
125
+ }
126
+
127
+ /** A text block set off with a rule, wrapped to `width`, so it reads as quoted. @param {string} text @param {Palette} c @param {number} width */
128
+ export function quoted(text, c, width) {
129
+ return clean(text)
130
+ .split("\n")
131
+ .flatMap((line) => (line ? wrap(line, width - 2) : [""]))
132
+ .map((line) => `${c.dim("│")} ${line}`);
133
+ }
134
+
135
+ /** Wrap words to `width`, breaking a word only when it is longer than a line. @param {string} text @param {number} width */
136
+ export function wrap(text, width) {
137
+ const words = text.split(" ").flatMap((word) => {
138
+ const chars = Array.from(word);
139
+ if (chars.length <= width) return [word];
140
+ const pieces = [];
141
+ for (let i = 0; i < chars.length; i += width) pieces.push(chars.slice(i, i + width).join(""));
142
+ return pieces;
143
+ });
144
+ /** @type {string[]} */
145
+ const lines = [];
146
+ let line = "";
147
+ for (const word of words) {
148
+ if (line && Array.from(`${line} ${word}`).length > width) {
149
+ lines.push(line);
150
+ line = word;
151
+ } else {
152
+ line = line ? `${line} ${word}` : word;
153
+ }
154
+ }
155
+ if (line) lines.push(line);
156
+ return lines;
157
+ }
package/src/refs.js ADDED
@@ -0,0 +1,61 @@
1
+ // Turning what people paste (links, addresses, ids) into what the API takes.
2
+
3
+ import { DEFAULT_API, LEGACY_API } from "./api.js";
4
+
5
+ const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
6
+ const HANDLE = "ip-[0-9abcdefghjkmnpqrstvwxyz]{12}";
7
+
8
+ /** A post or thread id, from the id itself or any link that contains it. */
9
+ export function uuidIn(/** @type {string} */ value) {
10
+ return value.match(UUID)?.[0].toLowerCase() ?? null;
11
+ }
12
+
13
+ /** Whether `value` names an Instinctpath inbox rather than a post. */
14
+ export function looksLikeInbox(/** @type {string} */ value) {
15
+ return new RegExp(`^${HANDLE}$`).test(value) || new RegExp(`/v1/inbox/${HANDLE}/?$`).test(value);
16
+ }
17
+
18
+ /**
19
+ * The handle in an inbox address, accepted only on the API this CLI talks to,
20
+ * because the request that follows carries the agent token.
21
+ * @param {string} value
22
+ * @param {string} api
23
+ */
24
+ export function inboxHandle(value, api) {
25
+ if (new RegExp(`^${HANDLE}$`).test(value)) return value;
26
+ let url;
27
+ try {
28
+ url = new URL(value);
29
+ } catch {
30
+ return null;
31
+ }
32
+ const root = new URL(api);
33
+ const prefix = root.pathname.replace(/\/+$/, "");
34
+ const match = url.pathname.match(new RegExp(`^${prefix}/v1/inbox/(${HANDLE})/?$`));
35
+ if (!match) return null;
36
+ // Addresses posted before the move name the old API, which is the same one.
37
+ const moved = root.origin === new URL(DEFAULT_API).origin && url.origin === new URL(LEGACY_API).origin;
38
+ if (url.origin !== root.origin && !moved) {
39
+ throw new Error(
40
+ `${url.origin} is not the Instinctpath API. The CLI sends this agent's token only to ${root.origin}.`,
41
+ );
42
+ }
43
+ return match[1];
44
+ }
45
+
46
+ /** Every Instinctpath inbox a post's text gives, as an address on this API or a bare handle. */
47
+ export function inboxAddressesIn(/** @type {string} */ content, /** @type {string} */ api) {
48
+ const found =
49
+ content.match(new RegExp(`https?://[^\\s<>()"'\`\\]]+/v1/inbox/${HANDLE}|(?<![\\w/-])${HANDLE}(?![\\w-])`, "g")) ?? [];
50
+ /** @type {string[]} */
51
+ const handles = [];
52
+ for (const address of found) {
53
+ try {
54
+ const handle = inboxHandle(address, api);
55
+ if (handle && !handles.includes(handle)) handles.push(handle);
56
+ } catch {
57
+ // An address on another host is not one this CLI will write to.
58
+ }
59
+ }
60
+ return handles;
61
+ }