ai-native-lab 0.1.0 → 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/dist/cli.js CHANGED
@@ -1,4 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ CliError,
4
+ EXIT,
5
+ createClient,
6
+ isOlder,
7
+ isSlug,
8
+ muteVersionNotice,
9
+ package_default,
10
+ serverMinCli,
11
+ slugify
12
+ } from "./chunk-2AEAK4KI.js";
2
13
 
3
14
  // src/cli.ts
4
15
  import { spawn } from "child_process";
@@ -6,172 +17,7 @@ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from
6
17
  import { homedir as homedir2, hostname } from "os";
7
18
  import path4 from "path";
8
19
  import { fileURLToPath } from "url";
9
- import { Command, Option } from "commander";
10
-
11
- // ../shared/src/cli.ts
12
- var EXIT = {
13
- ok: 0,
14
- error: 1,
15
- usage: 2,
16
- auth: 3,
17
- forbidden: 4,
18
- conflict: 5
19
- };
20
-
21
- // ../shared/src/content.ts
22
- var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
23
- var isSlug = (s) => SLUG_RE.test(s);
24
- function slugify(raw) {
25
- return raw.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
26
- }
27
- var IMAGE_LIMITS = { maxUploadBytes: 20 * 1024 * 1024, maxEdge: 2e3, targetBytes: 600 * 1024 };
28
- var PAGE_LIMITS = { maxUploadBytes: 50 * 1024 * 1024, maxTotalBytes: 200 * 1024 * 1024, maxFiles: 2e3 };
29
-
30
- // package.json
31
- var package_default = {
32
- name: "ai-native-lab",
33
- version: "0.1.0",
34
- description: "AI Native Lab CLI for people and agents: read, search and publish content. Non-interactive, --json output, stable exit codes.",
35
- keywords: [
36
- "ai-native-lab",
37
- "cli",
38
- "agent",
39
- "claude-code",
40
- "content",
41
- "markdown",
42
- "mixlab"
43
- ],
44
- homepage: "https://github.com/shadowcz007/ai-native-lab/tree/main/packages/cli#readme",
45
- repository: {
46
- type: "git",
47
- url: "git+https://github.com/shadowcz007/ai-native-lab.git",
48
- directory: "packages/cli"
49
- },
50
- bugs: {
51
- url: "https://github.com/shadowcz007/ai-native-lab/issues"
52
- },
53
- type: "module",
54
- bin: {
55
- aln: "dist/cli.js"
56
- },
57
- files: [
58
- "dist",
59
- "SKILL.md",
60
- "README.md"
61
- ],
62
- engines: {
63
- node: ">=20"
64
- },
65
- scripts: {
66
- dev: "tsx src/cli.ts",
67
- build: "tsup",
68
- typecheck: "tsc -p .",
69
- prepack: "tsup"
70
- },
71
- devDependencies: {
72
- "@ai-native-lab/shared": "workspace:*"
73
- },
74
- dependencies: {
75
- commander: "^15.0.0",
76
- fflate: "^0.8.3",
77
- yaml: "^2.8.1"
78
- },
79
- publishConfig: {
80
- access: "public"
81
- }
82
- };
83
-
84
- // src/api.ts
85
- var CliError = class extends Error {
86
- constructor(code2, message, exitCode, status) {
87
- super(message);
88
- this.code = code2;
89
- this.exitCode = exitCode;
90
- this.status = status;
91
- }
92
- code;
93
- exitCode;
94
- status;
95
- };
96
- var exitFor = (status, code2) => status === 401 || code2 === "pending_approval" || code2 === "banned" || code2 === "expired_token" || code2 === "access_denied" ? EXIT.auth : status === 403 ? EXIT.forbidden : status === 409 || status === 412 ? EXIT.conflict : status === 400 ? EXIT.usage : EXIT.error;
97
- function createClient(apiUrl, token) {
98
- async function call(method, p, body, headers = {}) {
99
- const h = { "user-agent": `aln-cli/${package_default.version}`, ...headers };
100
- if (token) h.authorization = `Bearer ${token}`;
101
- if (body !== void 0) h["content-type"] = "application/json";
102
- let res;
103
- try {
104
- res = await fetch(`${apiUrl}/api${p}`, { method, headers: h, body: body === void 0 ? void 0 : JSON.stringify(body) });
105
- } catch (e) {
106
- throw new CliError("network", `Can't reach ${apiUrl}: ${e.message}`, EXIT.error);
107
- }
108
- const text = await res.text();
109
- let json = {};
110
- try {
111
- json = text ? JSON.parse(text) : {};
112
- } catch {
113
- if (res.ok) return text;
114
- }
115
- if (!res.ok) {
116
- const code2 = json.error?.code ?? "http_error";
117
- throw new CliError(code2, json.error?.message ?? `HTTP ${res.status}`, exitFor(res.status, code2), res.status);
118
- }
119
- return json;
120
- }
121
- async function download(p) {
122
- const h = { "user-agent": `aln-cli/${package_default.version}` };
123
- if (token) h.authorization = `Bearer ${token}`;
124
- let res;
125
- try {
126
- res = await fetch(`${apiUrl}/api${p}`, { headers: h });
127
- } catch (e) {
128
- throw new CliError("network", `Can't reach ${apiUrl}: ${e.message}`, EXIT.error);
129
- }
130
- if (!res.ok) {
131
- const json = await res.json().catch(() => ({}));
132
- const code2 = json.error?.code ?? "http_error";
133
- throw new CliError(code2, json.error?.message ?? `HTTP ${res.status}`, exitFor(res.status, code2), res.status);
134
- }
135
- return new Uint8Array(await res.arrayBuffer());
136
- }
137
- async function upload(p, form, method = "POST", extraHeaders = {}) {
138
- const h = { "user-agent": `aln-cli/${package_default.version}`, ...extraHeaders };
139
- if (token) h.authorization = `Bearer ${token}`;
140
- let res;
141
- try {
142
- res = await fetch(`${apiUrl}/api${p}`, { method, headers: h, body: form });
143
- } catch (e) {
144
- throw new CliError("network", `Can't reach ${apiUrl}: ${e.message}`, EXIT.error);
145
- }
146
- const json = await res.json().catch(() => ({}));
147
- if (!res.ok) {
148
- const code2 = json.error?.code ?? "http_error";
149
- throw new CliError(code2, json.error?.message ?? `HTTP ${res.status}`, exitFor(res.status, code2), res.status);
150
- }
151
- return json;
152
- }
153
- const q = (params) => {
154
- const s = new URLSearchParams();
155
- for (const [k, v] of Object.entries(params)) if (v !== void 0 && v !== false && v !== "") s.set(k, String(v === true ? 1 : v));
156
- const str = s.toString();
157
- return str ? `?${str}` : "";
158
- };
159
- const enc = encodeURIComponent;
160
- return {
161
- call,
162
- upload,
163
- download,
164
- q,
165
- me: () => call("GET", "/me"),
166
- deviceStart: (clientName) => call("POST", "/cli/device", { clientName }),
167
- deviceToken: (deviceCode) => call("POST", "/cli/device/token", { deviceCode }),
168
- list: (params) => call("GET", `/contents${q(params)}`),
169
- get: (slug) => call("GET", `/contents/${enc(slug)}`),
170
- put: (slug, body, opts) => call("PUT", `/contents/${enc(slug)}${q({ dryRun: opts.dryRun })}`, body, opts.ifMatch ? { "if-match": opts.ifMatch } : {}),
171
- action: (slug, action) => call("POST", `/contents/${enc(slug)}/${action}`, {}),
172
- tags: () => call("GET", "/tags")
173
- };
174
- }
20
+ import { Command, CommanderError, Option } from "commander";
175
21
 
176
22
  // src/config.ts
177
23
  import { mkdir, readFile, rm, writeFile } from "fs/promises";
@@ -180,7 +26,7 @@ import path from "path";
180
26
  var DIR = path.join(process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "ai-native-lab");
181
27
  var FILE = path.join(DIR, "config.json");
182
28
  var configPath = FILE;
183
- var DEFAULT_API_URL = "https://ai-native-lab.mixdao.world";
29
+ var DEFAULT_API_URL = "https://ai-native-lab.mixlab.top";
184
30
  var normalizeUrl = (url) => new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).origin;
185
31
  async function loadConfig() {
186
32
  try {
@@ -196,10 +42,10 @@ async function saveConfig(c) {
196
42
  var clearConfig = () => rm(FILE, { force: true });
197
43
  async function resolveAuth(flags) {
198
44
  const c = await loadConfig();
199
- const explicit = flags.apiUrl ?? process.env.ALN_API_URL;
45
+ const explicit = flags.apiUrl ?? process.env.AILAB_API_URL;
200
46
  const apiUrl = normalizeUrl(explicit ?? c.url ?? DEFAULT_API_URL);
201
47
  const same = !explicit || c.url && normalizeUrl(c.url) === apiUrl;
202
- return { apiUrl, token: flags.token ?? process.env.ALN_TOKEN ?? (same ? c.token : void 0) };
48
+ return { apiUrl, token: flags.token ?? process.env.AILAB_TOKEN ?? (same ? c.token : void 0), column: flags.column ?? process.env.AILAB_COLUMN };
203
49
  }
204
50
 
205
51
  // src/images.ts
@@ -231,8 +77,124 @@ async function uploadLocalImages(body, baseDir, upload) {
231
77
  return { body: out2, uploaded };
232
78
  }
233
79
 
80
+ // src/completion.ts
81
+ var SHELLS = ["bash", "zsh", "fish"];
82
+ var optsOf = (c) => c.options.flatMap((o) => [o.long, o.short].filter((x) => Boolean(x)));
83
+ function walk(c, path5 = []) {
84
+ const subs = c.commands.filter((x) => !x._hidden);
85
+ const here = { path: path5, description: c.description(), subs: subs.map((s) => s.name()), opts: [...optsOf(c), "--help"] };
86
+ return [here, ...subs.flatMap((s) => walk(s, [...path5, s.name()]))];
87
+ }
88
+ var sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
89
+ function bash(root) {
90
+ const nodes = walk(root);
91
+ const cases = nodes.map((n) => ` ${sh(n.path.join(" "))}) subs=${sh(n.subs.join(" "))}; opts=${sh(n.opts.join(" "))} ;;`).join("\n");
92
+ return `_ailab() {
93
+ local cur="\${COMP_WORDS[COMP_CWORD]}" path="" i w subs="" opts=""
94
+ # \u6CBF\u7740\u5DF2\u8F93\u5165\u7684\u8BCD\u5F80\u4E0B\u8D70\uFF0C\u8BA4\u5F97\u7684\u5B50\u547D\u4EE4\u624D\u7B97\u8DEF\u5F84\uFF1B\u9009\u9879\u548C\u5B83\u4EEC\u7684\u503C\u8DF3\u8FC7
95
+ for ((i = 1; i < COMP_CWORD; i++)); do
96
+ w="\${COMP_WORDS[i]}"
97
+ case "$w" in -*) continue ;; esac
98
+ case "\${path:+$path }$w" in
99
+ ${nodes.filter((n) => n.path.length).map((n) => ` ${sh(n.path.join(" "))}) path="\${path:+$path }$w" ;;`).join("\n")}
100
+ esac
101
+ done
102
+ case "$path" in
103
+ ${cases}
104
+ esac
105
+ if [[ "$cur" == -* ]]; then
106
+ COMPREPLY=($(compgen -W "$opts --json --api-url --token --column --timeout" -- "$cur"))
107
+ else
108
+ COMPREPLY=($(compgen -W "$subs" -- "$cur"))
109
+ fi
110
+ }
111
+ complete -F _ailab ailab
112
+ `;
113
+ }
114
+ function fish(root) {
115
+ const lines = ["complete -c ailab -f"];
116
+ for (const n of walk(root)) {
117
+ const cond = n.path.length === 0 ? "__fish_use_subcommand" : `__fish_seen_subcommand_from ${n.path[n.path.length - 1]}`;
118
+ const cmd = n.path.length ? root.commands.find((c) => c.name() === n.path[0]) : root;
119
+ const children = cmd ? (n.path.length > 1 ? cmd.commands.find((c) => c.name() === n.path[1])?.commands : cmd.commands) ?? [] : [];
120
+ for (const c of children) lines.push(`complete -c ailab -n ${sh(cond)} -a ${sh(c.name())} -d ${sh(c.description().split("\n")[0].slice(0, 80))}`);
121
+ for (const o of n.opts) if (o.startsWith("--")) lines.push(`complete -c ailab -n ${sh(n.path.length ? cond : "true")} -l ${o.slice(2)}`);
122
+ }
123
+ return `${lines.join("\n")}
124
+ `;
125
+ }
126
+ function completionScript(root, shell) {
127
+ const head = `# ailab ${shell} completion. Load it with:
128
+ # ${shell === "fish" ? "ailab completion fish | source" : `eval "$(ailab completion ${shell})"`}
129
+ `;
130
+ if (shell === "fish") return head + fish(root);
131
+ const zsh = shell === "zsh" ? "autoload -U +X compinit && compinit -u 2>/dev/null\nautoload -U +X bashcompinit && bashcompinit\n" : "";
132
+ return head + zsh + bash(root);
133
+ }
134
+
135
+ // src/doctor.ts
136
+ var date = (iso) => iso.slice(0, 10);
137
+ async function diagnose(i) {
138
+ const checks = [];
139
+ const add = (c) => void checks.push(c);
140
+ const major = Number(i.nodeVersion.replace(/^v/, "").split(".")[0]);
141
+ add(major >= 20 ? { name: "cli", status: "ok", detail: `ailab ${i.version} \xB7 node ${i.nodeVersion}` } : { name: "cli", status: "fail", detail: `node ${i.nodeVersion} is too old; ailab needs node 20+`, hint: "upgrade node" });
142
+ const moved = i.urlSource === "saved login" && i.savedUrl && new URL(i.savedUrl).host !== new URL(i.defaultUrl).host && /mixdao\.world$/.test(new URL(i.savedUrl).host);
143
+ add(
144
+ moved ? { name: "server", status: "warn", detail: `${i.apiUrl} (${i.urlSource})`, hint: `the default server is now ${i.defaultUrl}; if you meant that one, run \`ailab login\` again` } : { name: "server", status: "ok", detail: `${i.apiUrl} (${i.urlSource})${i.column ? ` \xB7 column ${i.column}` : ""}` }
145
+ );
146
+ let me = null;
147
+ const t0 = Date.now();
148
+ try {
149
+ me = await i.client.me();
150
+ add({ name: "connection", status: "ok", detail: `reachable, ${Date.now() - t0}ms` });
151
+ } catch (e) {
152
+ if (e instanceof CliError && e.status !== void 0) {
153
+ add({ name: "connection", status: "ok", detail: `reachable, ${Date.now() - t0}ms` });
154
+ add({ name: "auth", status: "fail", detail: e.message, hint: "run `ailab login` again" });
155
+ } else {
156
+ add({ name: "connection", status: "fail", detail: e.message, hint: "check the server address (`--api-url`, `AILAB_API_URL`, `ailab login <url>`) and your network" });
157
+ }
158
+ return checks;
159
+ }
160
+ const min = i.minCli?.() ?? null;
161
+ if (min && isOlder(i.version, min)) {
162
+ add({ name: "version", status: "warn", detail: `ailab ${i.version} is older than the oldest version this server supports (${min})`, hint: "upgrade: npm i -g ai-native-lab" });
163
+ } else if (min) {
164
+ add({ name: "version", status: "ok", detail: `ailab ${i.version} (server needs ${min} or newer)` });
165
+ }
166
+ if (!i.tokenSource) {
167
+ add({ name: "auth", status: "warn", detail: "not logged in: you can only read public content", hint: "run `ailab login`, or set AILAB_TOKEN" });
168
+ } else if (!me.user) {
169
+ add({ name: "auth", status: "fail", detail: `the token (from ${i.tokenSource}) was not accepted: expired or revoked`, hint: "run `ailab login` again" });
170
+ } else {
171
+ const kind = me.actor.kind !== "human" ? ` (${me.actor.kind} credential)` : "";
172
+ add({ name: "auth", status: "ok", detail: `${me.user.email} \xB7 ${me.role}${kind}, token from ${i.tokenSource}` });
173
+ }
174
+ add(
175
+ me.column.status === "active" ? { name: "column", status: "ok", detail: `${me.column.name} (${me.column.slug})` } : { name: "column", status: "warn", detail: `${me.column.name} (${me.column.slug}) is ${me.column.status}` }
176
+ );
177
+ if (me.membership) add({ name: "membership", status: "ok", detail: `member until ${date(me.membership.expiresAt)}` });
178
+ return checks;
179
+ }
180
+ var MARK = { ok: "\u2713", warn: "!", fail: "\u2717" };
181
+ function renderChecks(checks) {
182
+ return checks.map((c) => `${MARK[c.status]} ${c.name.padEnd(11)} ${c.detail}${c.hint ? `
183
+ \u2192 ${c.hint}` : ""}`).join("\n");
184
+ }
185
+
234
186
  // src/markdown.ts
235
187
  import YAML from "yaml";
188
+ var list = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
189
+ function parseQuestions(v, fallback) {
190
+ if (v == null) return void 0;
191
+ if (!Array.isArray(v)) throw new Error("frontmatter `questions` must be a list");
192
+ return v.map((q) => {
193
+ if (typeof q === "string") return { text: q, disciplines: fallback ?? [] };
194
+ const o = q;
195
+ return { ...o.id ? { id: String(o.id) } : {}, text: String(o.text ?? ""), disciplines: list(o.disciplines) ?? fallback ?? [], ...o.membersOnly != null ? { membersOnly: Boolean(o.membersOnly) } : {}, ...o.evidence && typeof o.evidence === "object" ? { evidence: o.evidence } : {} };
196
+ });
197
+ }
236
198
  var FM = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
237
199
  function parseDoc(src) {
238
200
  const m = FM.exec(src);
@@ -248,12 +210,38 @@ function parseDoc(src) {
248
210
  body = body.slice(h.index + h[0].length).replace(/^\r?\n/, "");
249
211
  }
250
212
  }
251
- const rawTags = fm.tags;
252
- const tags = Array.isArray(rawTags) ? rawTags.map(String) : typeof rawTags === "string" ? rawTags.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
253
- return { slug: str("slug"), title, summary: str("summary"), access: str("access"), status: str("status"), cover: str("cover"), tags, body };
213
+ const tags = list(fm.tags);
214
+ const disciplines = list(fm.disciplines);
215
+ return {
216
+ slug: str("slug"),
217
+ title,
218
+ summary: str("summary"),
219
+ access: str("access"),
220
+ authorship: str("authorship"),
221
+ status: str("status"),
222
+ cover: str("cover"),
223
+ tags,
224
+ disciplines,
225
+ research: typeof fm.research === "boolean" ? fm.research : void 0,
226
+ questions: parseQuestions(fm.questions, disciplines),
227
+ answers: list(fm.answers),
228
+ body
229
+ };
254
230
  }
255
231
  function formatDoc(d) {
256
- const meta = { slug: d.slug, title: d.title, summary: d.summary || void 0, access: d.access, status: d.status, tags: d.tags };
232
+ const meta = {
233
+ slug: d.slug,
234
+ title: d.title,
235
+ summary: d.summary || void 0,
236
+ access: d.access,
237
+ authorship: d.authorship,
238
+ status: d.status,
239
+ tags: d.tags,
240
+ disciplines: d.disciplines?.length ? d.disciplines : void 0,
241
+ research: d.research === false ? false : void 0,
242
+ questions: d.questions?.length ? d.questions : void 0,
243
+ answers: d.answers?.length ? d.answers : void 0
244
+ };
257
245
  if (d.cover) meta.cover = d.cover;
258
246
  if (d.version != null) meta.version = d.version;
259
247
  for (const k of Object.keys(meta)) if (meta[k] === void 0) delete meta[k];
@@ -264,27 +252,81 @@ ${YAML.stringify(meta).trimEnd()}
264
252
  ${d.body.replace(/^\n+/, "")}`;
265
253
  }
266
254
 
255
+ // src/questions.ts
256
+ var pad = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
257
+ var num = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1);
258
+ function formatGaps(r) {
259
+ if (!r.gaps.length) return r.concept ? `No pair has posts about "${r.concept.text}" on both sides (${r.concept.posts} matching posts)` : "No disciplines yet";
260
+ const lines = r.gaps.map((g2) => {
261
+ const head = `${pad(`${g2.a.slug} \xD7 ${g2.b.slug}`, 34)} score ${pad(num(g2.score), 4)} ${g2.questions}q \xB7 ${g2.confirmed} confirmed \xB7 ${g2.proposed} proposed bridges \xB7 posts ${g2.posts.a}/${g2.posts.b}`;
262
+ if (!g2.concept) return head;
263
+ const side = (slug, posts) => ` ${slug}: ${posts.map((p) => `${p.title} (${p.ref})`).join("; ")}`;
264
+ return [head, side(g2.a.slug, g2.concept.a), side(g2.b.slug, g2.concept.b)].join("\n");
265
+ });
266
+ const foot = r.concept ? `
267
+ ${r.concept.posts} posts about "${r.concept.text}". A low score means few questions and bridges, not that nobody has asked it elsewhere: check with \`question check\` and search the web.` : "";
268
+ return lines.join("\n") + foot;
269
+ }
270
+ function formatCheck(r) {
271
+ const lines = [];
272
+ if (r.cell) lines.push(`${r.cell.a.slug} \xD7 ${r.cell.b.slug}: ${r.cell.questions} open questions \xB7 ${r.cell.bridges} bridges (${r.cell.confirmedBridges} confirmed)`);
273
+ if (!r.similar.length) lines.push("No similar question on this site. That says nothing about the rest of the world: search the web before calling it new.");
274
+ else {
275
+ lines.push("Similar questions on this site:");
276
+ for (const s of r.similar) lines.push(` ${s.similarity.toFixed(2)} ${pad(s.status, 6)} ${pad(s.disciplines.map((d) => d.slug).join("\xD7"), 24)} ${s.text} (${s.id})`);
277
+ }
278
+ return lines.join("\n");
279
+ }
280
+ async function checkQuestions(c, questions) {
281
+ const out2 = [];
282
+ for (const q of questions ?? []) {
283
+ try {
284
+ const check = await c.call("GET", `/questions/check${c.q({ text: q.text, disciplines: q.disciplines.length === 2 ? q.disciplines.join(",") : void 0 })}`);
285
+ out2.push({ text: q.text, disciplines: q.disciplines, similar: check.similar, cell: check.cell });
286
+ } catch (e) {
287
+ out2.push({ text: q.text, disciplines: q.disciplines, error: e instanceof Error ? e.message : String(e) });
288
+ }
289
+ }
290
+ return out2;
291
+ }
292
+ function formatChecks(checks) {
293
+ return checks.map((c) => {
294
+ if ("error" in c) return `question "${c.text}": couldn't check (${c.error})`;
295
+ if (!c.similar.length) return `question "${c.text}": nothing similar on this site`;
296
+ return `question "${c.text}": similar on this site
297
+ ${c.similar.map((s) => ` ${s.similarity.toFixed(2)} ${s.text} (${s.id})`).join("\n")}`;
298
+ }).join("\n");
299
+ }
300
+ function formatEvidence(e) {
301
+ if (!e) return "";
302
+ const lines = [];
303
+ if (e.why) lines.push(` why: ${e.why}`);
304
+ if (e.site) lines.push(` site: ${e.site.nearest?.length ? `nearest ${e.site.nearest.join(", ")}` : "nothing similar"}${e.site.note ? ` \u2014 ${e.site.note}` : ""}`);
305
+ for (const w of e.web ?? []) lines.push(` web: "${w.query}"${w.closest ? ` \u2192 ${w.closest}` : " \u2192 nothing close"}${w.note ? ` \u2014 ${w.note}` : ""}`);
306
+ return lines.join("\n");
307
+ }
308
+
267
309
  // src/pages.ts
268
310
  import { mkdir as mkdir2, readdir, readFile as readFile3, stat, writeFile as writeFile2 } from "fs/promises";
269
311
  import path3 from "path";
270
312
  import { unzipSync, zipSync } from "fflate";
271
- var META_FILE = "aln.json";
313
+ var META_FILE = "ailab.json";
272
314
  var IGNORE = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", "Thumbs.db", META_FILE]);
273
315
  async function zipDir(dir) {
274
316
  const files = {};
275
317
  let count = 0;
276
- const walk = async (rel) => {
318
+ const walk2 = async (rel) => {
277
319
  for (const e of await readdir(path3.join(dir, rel), { withFileTypes: true })) {
278
320
  if (IGNORE.has(e.name) || e.name.startsWith(".")) continue;
279
321
  const r = rel ? `${rel}/${e.name}` : e.name;
280
- if (e.isDirectory()) await walk(r);
322
+ if (e.isDirectory()) await walk2(r);
281
323
  else if (e.isFile()) {
282
324
  files[r] = await readFile3(path3.join(dir, r));
283
325
  if (++count > 2e3) throw new Error("A page can have at most 2000 files");
284
326
  }
285
327
  }
286
328
  };
287
- await walk("");
329
+ await walk2("");
288
330
  if (!files["index.html"] && !Object.keys(files).some((f) => !f.includes("/") && /\.html?$/i.test(f))) {
289
331
  throw new Error(`No index.html at the top of ${dir}`);
290
332
  }
@@ -315,20 +357,26 @@ async function extractTo(dir, zip, meta) {
315
357
  }
316
358
 
317
359
  // src/cli.ts
318
- var program = new Command().name("aln").description("AI Native Lab: read, search and publish content from the terminal or an agent").version(package_default.version).addOption(new Option("--json", "print one JSON object to stdout (also ALN_JSON=1)").default(process.env.ALN_JSON === "1")).option("--api-url <url>", "server URL, overrides the saved one (env ALN_API_URL)").option("--token <token>", "API token, overrides the saved one (env ALN_TOKEN)").showHelpAfterError().addHelpText(
360
+ var program = new Command().name("ailab").description("AI Native Lab: read, search and publish content from the terminal or an agent").version(package_default.version).addOption(new Option("--json", "print one JSON object to stdout (also AILAB_JSON=1)").default(process.env.AILAB_JSON === "1")).option("--api-url <url>", "server URL, overrides the saved one (env AILAB_API_URL)").option("--token <token>", "API token, overrides the saved one (env AILAB_TOKEN)").option("--column <slug>", "which column to act in (env AILAB_COLUMN); default: the token's column, else the platform default").option("--timeout <seconds>", "per-request timeout, default 30 (env AILAB_TIMEOUT); uploads and downloads get 4\xD7").showHelpAfterError().exitOverride().addHelpText(
319
361
  "after",
320
362
  `
321
363
  Recommended workflow:
322
364
  First time login <url> \u2192 whoami \u2192 tags \u2192 ls
365
+ Trouble doctor (address, connection, login, column) \xB7 completion zsh|bash|fish
323
366
  Read ls --tag <slug> \u2192 read <slug> (read prints Markdown; locked content shows the summary)
324
367
  Search search "<words>" \u2192 read <slug>
325
368
  Subscribe tags \u2192 follow <tag> (weekly email digest; follow --remove <tag> to stop)
326
369
  Write (editor) pull <slug> -o post.md \u2192 edit \u2192 push post.md --publish
327
370
  New content push post.md --slug <slug> --tags a,b [--auto-tags topic] --dry-run, then without --dry-run
328
371
  Images ![\u8BF4\u660E](./img/a.png) in Markdown is uploaded by push automatically; or: image upload a.png
329
- HTML pages push page.html | push site.zip | push ./site/ (folder: index.html + assets, metadata in aln.json)
372
+ HTML pages push page.html | push site.zip | push ./site/ (folder: index.html + assets, metadata in ailab.json)
330
373
  pull <slug> -o ./site/ \u2192 edit \u2192 push ./site/ --if-match <version>
331
- Members (admin) users --expiring 7 \u2192 member grant <email> --days 365
374
+ Columns every command acts in one column: --column <slug> / AILAB_COLUMN (a column-bound token implies its own)
375
+ Members (admin) member ls \xB7 users --expiring 7 \u2192 member grant <email> --days 365 [--plan vip]
376
+ Team (owner) team ls \u2192 team set <email> editor \u2192 team rm <email>
377
+ Map discipline ls \u2192 map \u2192 map --cell computing,anthropology \u2192 question ls
378
+ Compile (agent) graph -o graph.json \u2192 bridge add <from> <col>/<slug> --kind analogy --claim "\u2026" --run r1
379
+ (agent bridges and questions wait for a human: bridge confirm <id> \xB7 question confirm <id>)
332
380
 
333
381
  Markdown files carry metadata in YAML frontmatter:
334
382
  ---
@@ -336,18 +384,24 @@ Markdown files carry metadata in YAML frontmatter:
336
384
  title: \u6807\u9898 # or the first "# heading"
337
385
  summary: \u4E00\u53E5\u8BDD\u6458\u8981 # shown to readers who can't open the full text
338
386
  access: public | registered | member
387
+ authorship: human | assisted | agent_reviewed | agent # required for new content: say how it was made
339
388
  status: draft | published
340
389
  tags: [agentic-coding, case-study]
390
+ disciplines: [computing, anthropology] # 1\u20133, first is the main one; required to publish research content
391
+ questions: # at least one to publish; ends with "?", spans \u2265 2 disciplines
392
+ - text: \u5982\u679C reward shaping \u662F\u4E00\u79CD\u4E92\u60E0\u4E49\u52A1\uFF0CAgent \u4F1A\u4E0D\u4F1A\u300C\u6B20\u503A\u300D\uFF1F
393
+ disciplines: [computing, anthropology]
394
+ # research: false # announcements / events: no disciplines or questions needed
341
395
  ---
342
396
 
343
397
  Exit codes: 0 ok \xB7 1 error \xB7 2 usage \xB7 3 auth \xB7 4 forbidden \xB7 5 conflict (version mismatch / exists)
344
398
 
345
399
  Examples:
346
- $ aln login # default server; or: aln login https://your-host
347
- $ aln ls --tag agentic-coding --json
348
- $ aln read company-brain > company-brain.md
349
- $ cat note.md | aln push - --slug quick-note --publish
350
- $ ALN_TOKEN=aln_xxx ALN_API_URL=https://lab.example.com aln ls --json`
400
+ $ ailab login # default server; or: ailab login https://your-host
401
+ $ ailab ls --tag agentic-coding --json
402
+ $ ailab read company-brain > company-brain.md
403
+ $ cat note.md | ailab push - --slug quick-note --publish
404
+ $ AILAB_TOKEN=aln_xxx AILAB_API_URL=https://lab.example.com ailab ls --json`
351
405
  );
352
406
  var g = () => program.opts();
353
407
  function out(human, data) {
@@ -358,14 +412,35 @@ function out(human, data) {
358
412
  var note = (msg) => {
359
413
  if (!g().json) console.error(msg);
360
414
  };
415
+ function clientOptions() {
416
+ const raw = g().timeout ?? process.env.AILAB_TIMEOUT;
417
+ if (raw === void 0) return {};
418
+ const n = Number(raw);
419
+ if (!(n > 0)) throw new CliError("bad_request", `--timeout must be a positive number of seconds, got "${raw}"`, EXIT.usage);
420
+ return { timeoutMs: n * 1e3 };
421
+ }
361
422
  async function client() {
362
- const { apiUrl, token } = await resolveAuth(g());
363
- return createClient(apiUrl, token);
423
+ const { apiUrl, token, column } = await resolveAuth(g());
424
+ return createClient(apiUrl, token, column, clientOptions());
425
+ }
426
+ async function currentColumn(c) {
427
+ const { column } = await resolveAuth(g());
428
+ return column ?? (await c.me()).column.slug;
364
429
  }
365
430
  async function needToken() {
366
- const { apiUrl, token } = await resolveAuth(g());
367
- if (!token) throw new CliError("unauthorized", "Not logged in. Run `aln login <url>` or set ALN_TOKEN", EXIT.auth);
368
- return createClient(apiUrl, token);
431
+ const { apiUrl, token, column } = await resolveAuth(g());
432
+ if (!token) throw new CliError("unauthorized", "Not logged in. Run `ailab login <url>` or set AILAB_TOKEN", EXIT.auth);
433
+ return createClient(apiUrl, token, column, clientOptions());
434
+ }
435
+ function onoff(flag, v) {
436
+ if (v === void 0) return void 0;
437
+ if (v !== "on" && v !== "off") throw new CliError("bad_request", `${flag} must be on or off, got "${v}"`, EXIT.usage);
438
+ return v === "on";
439
+ }
440
+ function int(flag, v, min = 0) {
441
+ const n = Number(v);
442
+ if (v.trim() === "" || !Number.isInteger(n) || n < min) throw new CliError("bad_request", `${flag} must be an integer \u2265 ${min}, got "${v}"`, EXIT.usage);
443
+ return n;
369
444
  }
370
445
  var readInput = async (file) => {
371
446
  if (file !== "-") return readFile4(file, "utf8");
@@ -373,10 +448,10 @@ var readInput = async (file) => {
373
448
  for await (const c of process.stdin) chunks.push(c);
374
449
  return Buffer.concat(chunks).toString("utf8");
375
450
  };
376
- var date = (iso) => iso ? iso.slice(0, 10) : "\u2014";
377
- var pad = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
451
+ var date2 = (iso) => iso ? iso.slice(0, 10) : "\u2014";
452
+ var pad2 = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
378
453
  var lock = (i) => i.locked ? " \u{1F512}" : "";
379
- var listLine = (i) => `${pad(i.slug, 28)} ${pad(i.access, 10)} ${pad(i.status, 9)} ${date(i.publishedAt)} ${i.title}${lock(i)}${i.tags.length ? ` [${i.tags.map((t) => t.slug).join(", ")}]` : ""}`;
454
+ var listLine = (i) => `${pad2(i.slug, 28)} ${pad2(i.access, 10)} ${pad2(i.status, 9)} ${date2(i.publishedAt)} ${i.title}${lock(i)}${i.tags.length ? ` [${i.tags.map((t) => t.slug).join(", ")}]` : ""}`;
380
455
  function openBrowser(url) {
381
456
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
382
457
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -387,21 +462,29 @@ function openBrowser(url) {
387
462
  }
388
463
  program.command("login [url]").description("log in through the browser (device code); the token is saved to ~/.config/ai-native-lab").option("--no-browser", "don't open the browser, just print the link").action(async (url, opts) => {
389
464
  const saved = await loadConfig();
390
- const apiUrl = normalizeUrl(url ?? g().apiUrl ?? process.env.ALN_API_URL ?? saved.url ?? DEFAULT_API_URL);
391
- const c = createClient(apiUrl);
392
- const start = await c.deviceStart(`aln on ${hostname()}`.slice(0, 80));
465
+ const apiUrl = normalizeUrl(url ?? g().apiUrl ?? process.env.AILAB_API_URL ?? saved.url ?? DEFAULT_API_URL);
466
+ const c = createClient(apiUrl, void 0, void 0, clientOptions());
467
+ const start = await c.deviceStart(`ailab on ${hostname()}`.slice(0, 80));
393
468
  note(`Open ${start.verificationUrlComplete}
394
469
  and confirm the code ${start.userCode}. Waiting\u2026`);
395
470
  if (opts.browser) openBrowser(start.verificationUrlComplete);
396
471
  const deadline = Date.now() + start.expiresIn * 1e3;
472
+ let interval = start.interval;
397
473
  while (Date.now() < deadline) {
398
- await new Promise((r) => setTimeout(r, start.interval * 1e3));
474
+ await new Promise((r) => setTimeout(r, interval * 1e3));
399
475
  try {
400
476
  const res = await c.deviceToken(start.deviceCode);
401
477
  await saveConfig({ url: apiUrl, token: res.token, email: res.user.email });
402
478
  return out(`Logged in to ${apiUrl} as ${res.user.email}`, { ok: true, url: apiUrl, email: res.user.email });
403
479
  } catch (e) {
404
- if (e instanceof CliError && e.code === "authorization_pending") continue;
480
+ if (e instanceof CliError) {
481
+ if (e.code === "authorization_pending") continue;
482
+ if (e.code === "slow_down") {
483
+ interval += 5;
484
+ continue;
485
+ }
486
+ if (e.code === "network" || e.code === "timeout" || (e.status ?? 0) >= 500) continue;
487
+ }
405
488
  throw e;
406
489
  }
407
490
  }
@@ -413,11 +496,28 @@ program.command("logout").description("forget the saved token").action(async ()
413
496
  });
414
497
  program.command("whoami").description("who you are, your role and membership expiry").action(async () => {
415
498
  const me = await (await client()).me();
416
- const m = me.membership ? `, member until ${date(me.membership.expiresAt)}` : "";
417
- out(me.user ? `${me.user.email} \xB7 ${me.role}${m}
499
+ const m = me.membership ? `, member until ${date2(me.membership.expiresAt)}` : "";
500
+ out(me.user ? `${me.user.email} \xB7 ${me.role} in ${me.column.slug}${me.actor.kind !== "human" ? ` (${me.actor.kind} credential)` : ""}${m}
418
501
  permissions: ${me.permissions.join(", ")}` : "guest (not logged in)", me);
419
502
  });
420
- program.command("ls").description("list content (newest first)").option("--tag <slugs>", "comma separated tag slugs; OR within a tag group, AND across groups").option("--access <level>", "public | registered | member").option("--status <status>", "published (default) | draft | archived | all (editors only for non-published)").option("-q, --query <text>", "full-text search (same as `aln search`)").option("--bookmarked", "only content you bookmarked").option("--limit <n>", "page size, max 100", "20").option("--offset <n>", "skip n items", "0").action(async (o) => {
503
+ program.command("doctor").description("check the server address, connection, login and column, and say how to fix what's wrong").action(async () => {
504
+ muteVersionNotice();
505
+ const g0 = g();
506
+ const saved = await loadConfig();
507
+ const { apiUrl, token, column } = await resolveAuth(g0);
508
+ const explicit = g0.apiUrl ? "--api-url" : process.env.AILAB_API_URL ? "AILAB_API_URL" : null;
509
+ const urlSource = explicit ?? (saved.url ? "saved login" : "default");
510
+ const tokenSource = !token ? null : g0.token ? "--token" : process.env.AILAB_TOKEN ? "AILAB_TOKEN" : "saved login";
511
+ const checks = await diagnose({ version: package_default.version, nodeVersion: process.version, apiUrl, urlSource, tokenSource, column, savedUrl: saved.url, defaultUrl: DEFAULT_API_URL, client: createClient(apiUrl, token, column, clientOptions()), minCli: serverMinCli });
512
+ const failed = checks.find((c) => c.status === "fail");
513
+ out(renderChecks(checks), { ok: !failed, checks });
514
+ if (failed) process.exitCode = failed.name === "auth" ? EXIT.auth : EXIT.error;
515
+ });
516
+ program.command("completion <shell>").description(`print a shell completion script: ${SHELLS.join(" | ")}. e.g. eval "$(ailab completion zsh)"`).action((shell) => {
517
+ if (!SHELLS.includes(shell)) throw new CliError("bad_request", `shell must be one of ${SHELLS.join(", ")}`, EXIT.usage);
518
+ process.stdout.write(completionScript(program, shell));
519
+ });
520
+ program.command("ls").description("list content (newest first)").option("--tag <slugs>", "comma separated tag slugs; OR within a tag group, AND across groups").option("--access <level>", "public | registered | member").option("--status <status>", "published (default) | draft | archived | all (editors only for non-published)").option("-q, --query <text>", "full-text search (same as `ailab search`)").option("--bookmarked", "only content you bookmarked").option("--limit <n>", "page size, max 100", "20").option("--offset <n>", "skip n items", "0").action(async (o) => {
421
521
  const res = await (await client()).list({ tag: o.tag, access: o.access, status: o.status, q: o.query, bookmarked: o.bookmarked, limit: o.limit, offset: o.offset });
422
522
  const more = res.nextOffset != null ? `
423
523
  \u2026 more: --offset ${res.nextOffset}` : "";
@@ -432,7 +532,7 @@ program.command("read <slug>").description("print a piece of content as Markdown
432
532
  const item = await c.get(slug);
433
533
  if (g().json) return out("", item);
434
534
  if (item.locked) {
435
- console.log(`# ${item.title}
535
+ console.error(`# ${item.title}
436
536
 
437
537
  ${item.summary}
438
538
  `);
@@ -442,7 +542,7 @@ ${item.summary}
442
542
  if (item.format !== "html") throw new CliError("not_html", `${slug} is Markdown; drop --html`, EXIT.usage);
443
543
  return void process.stdout.write(await c.call("GET", `/contents/${encodeURIComponent(slug)}?format=html`));
444
544
  }
445
- const doc = { ...item, tags: item.tags.map((t) => t.slug), body: item.body ?? "" };
545
+ const doc = { ...item, tags: item.tags.map((t) => t.slug), body: item.body ?? "", ...pulledKnowledge(item) };
446
546
  console.log(o.meta ? formatDoc(doc) : `# ${item.title}
447
547
 
448
548
  ${doc.body}`);
@@ -450,7 +550,7 @@ ${doc.body}`);
450
550
  program.command("tags").description("all tag groups and tags with published counts").action(async () => {
451
551
  const res = await (await client()).tags();
452
552
  out(res.groups.map((gr) => `${gr.name} (${gr.key})
453
- ${gr.tags.map((t) => ` ${pad(t.slug, 28)} ${t.name} ${t.count}`).join("\n") || " \u2014"}`).join("\n"), res);
553
+ ${gr.tags.map((t) => ` ${pad2(t.slug, 28)} ${t.name} ${t.count}`).join("\n") || " \u2014"}`).join("\n"), res);
454
554
  });
455
555
  program.command("bookmark <slug>").description("bookmark (or --remove) a piece of content").option("--remove", "remove the bookmark").action(async (slug, o) => {
456
556
  const res = await (await needToken()).call(o.remove ? "DELETE" : "PUT", `/contents/${encodeURIComponent(slug)}/bookmark`);
@@ -461,15 +561,24 @@ program.command("follow [tag]").description("follow a tag (weekly email digest o
461
561
  if (!slug) {
462
562
  if (o.remove) throw new CliError("bad_request", "Pass the tag to unfollow", EXIT.usage);
463
563
  const res2 = await c.call("GET", "/me/follows");
464
- return out(res2.follows.map((t) => `${pad(t.slug, 28)} ${t.name}${t.archived ? " (archived)" : ""}`).join("\n") || "Not following any tags. Try `aln tags`, then `aln follow <tag>`", res2);
564
+ return out(res2.follows.map((t) => `${pad2(t.slug, 28)} ${t.name}${t.archived ? " (archived)" : ""}`).join("\n") || "Not following any tags. Try `ailab tags`, then `ailab follow <tag>`", res2);
465
565
  }
466
566
  const res = await c.call(o.remove ? "DELETE" : "PUT", `/tags/${encodeURIComponent(slug)}/follow`);
467
567
  out(o.remove ? `Unfollowed ${slug}` : `Following ${slug} \xB7 weekly digest every Monday`, res);
468
568
  });
469
569
  program.command("redeem <code>").description("redeem a membership code").action(async (code2) => {
470
570
  const res = await (await needToken()).call("POST", "/redeem", { code: code2 });
471
- out(`+${res.days} days, member until ${date(res.membership?.expiresAt ?? null)}`, res);
571
+ out(`+${res.days} days, member until ${date2(res.membership?.expiresAt ?? null)}`, res);
472
572
  });
573
+ function knowledgeOf(src, o) {
574
+ const disciplines = o.disciplines ? o.disciplines.split(",").map((s) => s.trim()).filter(Boolean) : src.disciplines;
575
+ return {
576
+ ...disciplines ? { disciplines } : {},
577
+ ...o.notResearch ? { research: false } : src.research !== void 0 ? { research: src.research } : {},
578
+ ...src.questions ? { questions: src.questions.map((q) => ({ ...q, disciplines: q.disciplines.length ? q.disciplines : disciplines ?? [] })) } : {},
579
+ ...src.answers ? { answers: src.answers } : {}
580
+ };
581
+ }
473
582
  async function pushPage(file, o) {
474
583
  const dir = await isDir(file);
475
584
  const meta = dir ? await readMeta(file) : {};
@@ -497,20 +606,24 @@ async function pushPage(file, o) {
497
606
  }
498
607
  const status = o.publish ? "published" : o.draft ? "draft" : meta.status;
499
608
  const tags = o.tags ? o.tags.split(",").map((s) => s.trim()).filter(Boolean) : meta.tags;
609
+ const knowledge = knowledgeOf(meta, o);
500
610
  const form = new FormData();
501
611
  form.append("file", new Blob([data]), dir ? `${slug}.zip` : path4.basename(file));
502
- form.append("meta", JSON.stringify({ title, summary: o.summary ?? meta.summary, cover, access: o.access ?? meta.access, status, tags, autoCreateTagsIn: o.autoTags }));
612
+ form.append("meta", JSON.stringify({ title, summary: o.summary ?? meta.summary, cover, access: o.access ?? meta.access, authorship: o.authorship ?? meta.authorship, scheduledAt: o.schedule, status, tags, autoCreateTagsIn: o.autoTags, ...knowledge }));
503
613
  const res = await c.upload(
504
614
  `/contents/${encodeURIComponent(slug)}/page${c.q({ dryRun: o.dryRun })}`,
505
615
  form,
506
616
  "PUT",
507
617
  o.ifMatch ? { "if-match": o.ifMatch } : {}
508
618
  );
509
- if (res.dryRun) return out(`dry run: would ${res.action} ${slug} (HTML, entry ${res.entry}, ${res.files} files)`, res);
619
+ if (res.dryRun) {
620
+ const questionChecks = await checkQuestions(c, knowledge.questions);
621
+ return out([`dry run: would ${res.action} ${slug} (HTML, entry ${res.entry}, ${res.files} files)`, formatChecks(questionChecks)].filter(Boolean).join("\n"), { ...res, questionChecks });
622
+ }
510
623
  const p = res.content.page;
511
624
  out(`${res.created ? "Created" : "Updated"} ${slug} \xB7 HTML${p ? ` (${p.files} files, ${Math.round(p.bytes / 1024)}KB)` : ""} \xB7 ${res.content.status} \xB7 v${res.content.version}`, res);
512
625
  }
513
- program.command("push <file>").description("create or update content from a Markdown file (or - for stdin), upsert by slug").option("--slug <slug>", "defaults to frontmatter slug, then the file name").option("--title <title>", "override the title").option("--summary <text>", "override the summary (what readers who can't open the full text see)").option("--access <level>", "public | registered | member").option("--tags <slugs>", "comma separated tag slugs (replaces existing tags)").option("--auto-tags <group>", "create unknown tags inside this tag group instead of failing").option("--publish", "publish right away (same as status: published)").option("--draft", "save as draft").option("--if-match <version>", "only write if the server version still matches (optimistic lock)").option("--dry-run", "validate and show what would happen, change nothing (local images are not uploaded)").option("--no-upload-images", "don't upload local images referenced in the Markdown").action(async (file, o) => {
626
+ program.command("push <file>").description("create or update content from a Markdown file (or - for stdin), upsert by slug").option("--slug <slug>", "defaults to frontmatter slug, then the file name").option("--title <title>", "override the title").option("--summary <text>", "override the summary (what readers who can't open the full text see)").option("--access <level>", "public | registered | member").option("--authorship <how>", "how it was made: human | assisted | agent_reviewed | agent (frontmatter `authorship:` works too). Required for new content, except with an agent credential, which is always `agent`").option("--tags <slugs>", "comma separated tag slugs (replaces existing tags)").option("--auto-tags <group>", "create unknown tags inside this tag group instead of failing").option("--disciplines <slugs>", "comma separated discipline slugs, the first is the main one (frontmatter `disciplines:` works too); see `ailab discipline ls`").option("--not-research", "announcements, events\u2026: no disciplines or questions needed, kept off the map").option("--schedule <time>", "publish automatically at this time (ISO 8601, e.g. 2026-11-02T09:00:00+08:00); editors only").option("--publish", "publish right away (same as status: published)").option("--draft", "save as draft").option("--if-match <version>", "only write if the server version still matches (optimistic lock)").option("--dry-run", "validate and show what would happen, change nothing (local images are not uploaded)").option("--no-upload-images", "don't upload local images referenced in the Markdown").action(async (file, o) => {
514
627
  if (file !== "-" && (await isDir(file) || /\.(zip|html?)$/i.test(file))) return pushPage(file, o);
515
628
  const doc = parseDoc(await readInput(file));
516
629
  const slug = o.slug ?? doc.slug ?? (file === "-" ? "" : slugify(path4.basename(file, path4.extname(file))));
@@ -538,12 +651,15 @@ program.command("push <file>").description("create or update content from a Mark
538
651
  note(`dry run: would upload cover ${cover}`);
539
652
  cover = void 0;
540
653
  }
541
- const body = { title, summary: o.summary ?? doc.summary, body: text, cover, access: o.access ?? doc.access, status, tags, autoCreateTagsIn: o.autoTags };
654
+ const body = { title, summary: o.summary ?? doc.summary, body: text, cover, access: o.access ?? doc.access, authorship: o.authorship ?? doc.authorship, scheduledAt: o.schedule, status, tags, autoCreateTagsIn: o.autoTags, ...knowledgeOf(doc, o) };
542
655
  const res = await c.put(slug, body, { ifMatch: o.ifMatch, dryRun: o.dryRun });
543
- if (res.dryRun) return out(`dry run: would ${res.action} ${slug}`, res);
656
+ if (res.dryRun) {
657
+ const questionChecks = await checkQuestions(c, body.questions);
658
+ return out([`dry run: would ${res.action} ${slug}`, formatChecks(questionChecks)].filter(Boolean).join("\n"), { ...res, questionChecks });
659
+ }
544
660
  out(`${res.created ? "Created" : "Updated"} ${slug} \xB7 ${res.content.status} \xB7 v${res.content.version}`, { ...res, images });
545
661
  });
546
- program.command("pull <slug>").description("download content to edit and push back: Markdown with frontmatter, or for HTML pages a folder (with aln.json) / .zip").option("-o, --out <file>", "write to a file instead of stdout; HTML pages: a folder, or a path ending in .zip").action(async (slug, o) => {
662
+ program.command("pull <slug>").description("download content to edit and push back: Markdown with frontmatter, or for HTML pages a folder (with ailab.json) / .zip").option("-o, --out <file>", "write to a file instead of stdout; HTML pages: a folder, or a path ending in .zip").action(async (slug, o) => {
547
663
  const c = await needToken();
548
664
  const item = await c.get(slug);
549
665
  if (item.locked || item.body == null) throw new CliError("locked", "You can't read the full text of this content", EXIT.forbidden);
@@ -559,19 +675,29 @@ program.command("pull <slug>").description("download content to edit and push ba
559
675
  title: item.title,
560
676
  summary: item.summary,
561
677
  access: item.access,
678
+ authorship: item.authorship,
562
679
  status: item.status,
563
680
  tags: item.tags.map((t) => t.slug),
564
681
  cover: item.cover,
565
- version: item.version
682
+ version: item.version,
683
+ ...pulledKnowledge(item)
566
684
  });
567
- return out(`Wrote ${n} files to ${o.out}/ (v${item.version}); edit, then: aln push ${o.out} --if-match ${item.version}`, { ok: true, dir: o.out, files: n, version: item.version });
685
+ return out(`Wrote ${n} files to ${o.out}/ (v${item.version}); edit, then: ailab push ${o.out} --if-match ${item.version}`, { ok: true, dir: o.out, files: n, version: item.version });
568
686
  }
569
- const md = formatDoc({ ...item, tags: item.tags.map((t) => t.slug), body: item.body });
687
+ const md = formatDoc({ ...item, tags: item.tags.map((t) => t.slug), body: item.body, ...pulledKnowledge(item) });
570
688
  if (!o.out) return void process.stdout.write(md.endsWith("\n") ? md : `${md}
571
689
  `);
572
690
  await writeFile3(o.out, md);
573
691
  out(`Wrote ${o.out} (v${item.version})`, { ok: true, file: o.out, version: item.version });
574
692
  });
693
+ function pulledKnowledge(item) {
694
+ return {
695
+ disciplines: item.disciplines?.map((d) => d.slug),
696
+ research: item.research,
697
+ questions: item.questions?.filter((q) => q.status === "open").map((q) => ({ id: q.id, text: q.text, disciplines: q.disciplines.map((d) => d.slug), ...q.membersOnly ? { membersOnly: true } : {}, ...q.evidence ? { evidence: q.evidence } : {} })),
698
+ answers: item.answers?.map((a) => a.id)
699
+ };
700
+ }
575
701
  for (const action of ["publish", "unpublish", "archive"]) {
576
702
  program.command(`${action} <slug>`).description(action === "publish" ? "publish content" : action === "unpublish" ? "move published content back to draft" : "archive content (hide it everywhere)").action(async (slug) => {
577
703
  const res = await (await needToken()).action(slug, action);
@@ -588,7 +714,7 @@ tagCmd.command("archive <slug>").action(async (slug) => {
588
714
  out(`Archived tag ${slug}`, res);
589
715
  });
590
716
  tagCmd.command("group <key>").description("create a tag group").requiredOption("--name <name>").option("--position <n>").action(async (key, o) => {
591
- const res = await (await needToken()).call("POST", "/tag-groups", { key, name: o.name, position: o.position ? Number(o.position) : void 0 });
717
+ const res = await (await needToken()).call("POST", "/tag-groups", { key, name: o.name, position: o.position ? int("--position", o.position) : void 0 });
592
718
  out(`Created tag group ${key}`, res);
593
719
  });
594
720
  var imageCmd = program.command("image").description("upload and manage images (editors upload, admins delete)");
@@ -601,7 +727,7 @@ imageCmd.command("upload <files...>").description("upload images (compressed to
601
727
  imageCmd.command("ls").description("list uploaded images and where they are used").option("--unused", "only images no content uses").option("--used", "only images in use").option("--limit <n>", "", "60").action(async (o) => {
602
728
  const c = await needToken();
603
729
  const res = await c.call("GET", `/images${c.q({ filter: o.unused ? "unused" : o.used ? "used" : void 0, limit: o.limit })}`);
604
- const lines = res.images.map((i) => `${i.id.slice(0, 8)} ${pad(`${i.width}x${i.height}`, 10)} ${pad(`${Math.round(i.bytes / 1024)}KB`, 7)} ${pad(i.originalName ?? "", 24)} ${i.usages.length ? i.usages.map((u) => `${u.slug}:${u.where}`).join(", ") : "(unused)"}`);
730
+ const lines = res.images.map((i) => `${i.id.slice(0, 8)} ${pad2(`${i.width}x${i.height}`, 10)} ${pad2(`${Math.round(i.bytes / 1024)}KB`, 7)} ${pad2(i.originalName ?? "", 24)} ${i.usages.length ? i.usages.map((u) => `${u.slug}:${u.where}`).join(", ") : "(unused)"}`);
605
731
  out([`${res.stats.total} images, ${res.stats.unused} unused`, ...lines].join("\n"), res);
606
732
  });
607
733
  imageCmd.command("rm <id>").description("delete an image (admin); refuses if still used unless --force").option("--force", "delete even if content still uses it").action(async (id, o) => {
@@ -610,48 +736,74 @@ imageCmd.command("rm <id>").description("delete an image (admin); refuses if sti
610
736
  out(`Deleted ${id}`, res);
611
737
  });
612
738
  imageCmd.command("purge").description("delete images no content body / cover uses (admin); skips uploads from the last hour").option("--dry-run", "only count").option("--include-revisions", "also delete images only referenced by old revisions").option("--min-age <minutes>", "skip images newer than this", "60").action(async (o) => {
613
- const res = await (await needToken()).call("POST", "/images/purge", { dryRun: o.dryRun, includeRevisions: o.includeRevisions, minAgeMinutes: Number(o.minAge) });
739
+ const res = await (await needToken()).call("POST", "/images/purge", { dryRun: o.dryRun, includeRevisions: o.includeRevisions, minAgeMinutes: int("--min-age", o.minAge) });
614
740
  out(`${o.dryRun ? "Would delete" : "Deleted"} ${res.count} images (${Math.round(res.bytes / 1024)}KB)`, res);
615
741
  });
616
742
  async function findUserByEmail(email) {
617
743
  const c = await needToken();
618
- const res = await c.call("GET", `/admin/users${c.q({ q: email })}`);
619
- const u = res.users.find((x) => x.email.toLowerCase() === email.toLowerCase());
620
- if (!u) throw new CliError("not_found", `No user with email ${email}`, EXIT.error);
744
+ const u = await c.call("GET", `/admin/members/lookup${c.q({ email })}`);
621
745
  return { c, u };
622
746
  }
623
- program.command("users").description("list users (admin)").option("-q, --query <text>", "filter by email or name").option("--expiring <days>", "only members whose membership ends within N days").action(async (o) => {
747
+ program.command("users").description("list all platform users with their identities in each column (platform admin); column staff: use `member ls`").option("-q, --query <text>", "filter by email or name").option("--status <s>", "all | pending | active | banned", "all").option("--limit <n>", "page size (max 200)", "50").option("--offset <n>", "skip the first N users", "0").option("--expiring <days>", "only members of the current column whose membership ends within N days").action(async (o) => {
624
748
  const c = await needToken();
625
749
  if (o.expiring) {
626
750
  const res2 = await c.call("GET", `/admin/memberships/expiring${c.q({ days: o.expiring })}`);
627
- return out(res2.memberships.map((m) => `${pad(m.email, 32)} ${date(m.expiresAt)} ${m.name}`).join("\n") || "Nobody", res2);
751
+ return out(res2.memberships.map((m) => `${pad2(m.email, 32)} ${date2(m.expiresAt)} ${m.name}`).join("\n") || "Nobody", res2);
628
752
  }
629
- const res = await c.call("GET", `/admin/users${c.q({ q: o.query })}`);
630
- out(res.users.map((u) => `${pad(u.email, 32)} ${pad(u.role, 8)} ${pad(u.status, 8)} ${u.membership ? `member\u2192${date(u.membership.expiresAt)}` : ""}`).join("\n") || "No users", res);
753
+ const res = await c.call("GET", `/admin/users${c.q({ q: o.query, status: o.status, limit: o.limit, offset: o.offset })}`);
754
+ const state = (u) => u.banned ? "banned" : u.status;
755
+ const where = (u) => [...u.staffOf.map((s) => `${s.slug}:${s.role}`), ...u.memberOf.map((m) => `${m.slug}:member\u2192${date2(m.until)}`)].join(" ");
756
+ const lines = res.users.map((u) => `${pad2(u.email, 32)} ${pad2(u.platformRole, 6)} ${pad2(state(u), 8)} ${where(u)}`);
757
+ if (res.nextOffset !== null) lines.push(`\u2026 ${res.total} users in total; next page: --offset ${res.nextOffset}`);
758
+ out(lines.join("\n") || "No users", res);
631
759
  });
632
- var member = program.command("member").description("grant or revoke membership (admin)");
633
- member.command("grant <email>").option("--days <n>", "add N days (after the current expiry if already a member)").option("--until <date>", "set the end date, e.g. 2027-10-20").option("--note <text>").action(async (email, o) => {
760
+ var member = program.command("member").description("grant or revoke membership in the current column (column admin)");
761
+ member.command("ls").description("members of the current column (manager+)").option("-q, --query <text>", "filter by email or name").option("--status <s>", "active | expired | revoked | all", "active").option("--plan <key>", "only this plan").option("--limit <n>", "page size (max 200)", "50").option("--offset <n>", "skip the first N members", "0").action(async (o) => {
762
+ const c = await needToken();
763
+ const res = await c.call("GET", `/admin/members${c.q({ q: o.query, status: o.status, plan: o.plan, limit: o.limit, offset: o.offset })}`);
764
+ const lines = res.members.map((m) => `${pad2(m.email, 32)} ${pad2(m.plan, 10)} ${pad2(m.status, 8)} ${m.expiresAt ? date2(m.expiresAt) : ""} ${m.name}`);
765
+ if (res.nextOffset !== null) lines.push(`\u2026 ${res.total} members in total; next page: --offset ${res.nextOffset}`);
766
+ out(lines.join("\n") || "No members", res);
767
+ });
768
+ member.command("grant <email>").option("--days <n>", "add N days (after the current expiry if already a member)").option("--until <date>", "set the end date, e.g. 2027-10-20").option("--plan <key>", "plan key; default: the lowest plan of the column").option("--note <text>").action(async (email, o) => {
634
769
  if (!o.days === !o.until) throw new CliError("bad_request", "Pass exactly one of --days or --until", EXIT.usage);
770
+ const days = o.days ? int("--days", o.days, 1) : void 0;
635
771
  const { c, u } = await findUserByEmail(email);
636
- const res = await c.call("POST", `/admin/users/${u.id}/membership`, { days: o.days ? Number(o.days) : void 0, until: o.until, note: o.note });
637
- out(`${email} is a member until ${date(res.membership?.expiresAt ?? null)}`, res);
772
+ const res = await c.call("POST", `/admin/users/${u.id}/membership`, { days, until: o.until, plan: o.plan, note: o.note });
773
+ out(`${email} is a member until ${date2(res.membership?.expiresAt ?? null)}`, res);
638
774
  });
639
775
  member.command("revoke <email>").action(async (email) => {
640
776
  const { c, u } = await findUserByEmail(email);
641
777
  const res = await c.call("DELETE", `/admin/users/${u.id}/membership`);
642
778
  out(`${email} is now ${res.role}`, res);
643
779
  });
644
- program.command("role <email> <role>").description("set staff role: admin | editor | user (admin)").action(async (email, role) => {
780
+ program.command("role <email> <role>").description("set the PLATFORM role: admin | user (platform admin). Column roles: `team set`").action(async (email, role) => {
645
781
  const { c, u } = await findUserByEmail(email);
646
782
  const res = await c.call("POST", `/admin/users/${u.id}/role`, { role });
647
- out(`${email} \u2192 ${res.staffRole}`, res);
783
+ out(`${email} \u2192 platform ${res.platformRole}`, res);
784
+ });
785
+ var team = program.command("team").description("the current column's editorial team (column admin; agent credentials can't)");
786
+ team.command("ls").action(async () => {
787
+ const res = await (await needToken()).call("GET", "/admin/team");
788
+ out(res.team.map((m) => `${pad2(m.email, 32)} ${pad2(m.role, 8)} ${m.name}`).join("\n") || "Nobody", res);
648
789
  });
649
- var code = program.command("code").description("membership redeem codes (admin)");
650
- code.command("create").requiredOption("--days <n>", "membership days per redemption").option("--count <n>", "how many codes", "1").option("--max-uses <n>", "redemptions per code", "1").option("--expires <date>", "code stops working after this date").option("--note <text>").action(async (o) => {
790
+ team.command("set <email> <role>").description("add someone to the team or change their role: owner | admin | editor | author").action(async (email, role) => {
791
+ const { c, u } = await findUserByEmail(email);
792
+ const res = await c.call("PUT", `/admin/team/${u.id}`, { role });
793
+ out(`${email} \u2192 ${res.role}`, res);
794
+ });
795
+ team.command("rm <email>").description("remove someone from the team").action(async (email) => {
796
+ const { c, u } = await findUserByEmail(email);
797
+ const res = await c.call("DELETE", `/admin/team/${u.id}`);
798
+ out(`${email} removed from the team`, res);
799
+ });
800
+ var code = program.command("code").description("membership redeem codes of the current column (column admin)");
801
+ code.command("create").requiredOption("--days <n>", "membership days per redemption").option("--count <n>", "how many codes", "1").option("--max-uses <n>", "redemptions per code", "1").option("--expires <date>", "code stops working after this date").option("--plan <key>", "plan the code grants; default: the lowest plan of the column").option("--note <text>").action(async (o) => {
651
802
  const res = await (await needToken()).call("POST", "/admin/redeem-codes", {
652
- days: Number(o.days),
653
- count: Number(o.count),
654
- maxUses: Number(o.maxUses),
803
+ days: int("--days", o.days, 1),
804
+ count: int("--count", o.count, 1),
805
+ maxUses: int("--max-uses", o.maxUses, 1),
806
+ plan: o.plan,
655
807
  expiresAt: o.expires,
656
808
  note: o.note
657
809
  });
@@ -659,17 +811,264 @@ code.command("create").requiredOption("--days <n>", "membership days per redempt
659
811
  });
660
812
  code.command("ls").action(async () => {
661
813
  const res = await (await needToken()).call("GET", "/admin/redeem-codes");
662
- out(res.codes.map((x) => `${x.prefix}\u2026 ${pad(`${x.days}d`, 6)} ${x.usedCount}/${x.maxUses} ${x.active ? "active" : "inactive"} ${x.note ?? ""}`).join("\n") || "No codes", res);
814
+ out(res.codes.map((x) => `${x.prefix}\u2026 ${pad2(`${x.days}d`, 6)} ${x.usedCount}/${x.maxUses} ${x.active ? "active" : "inactive"} ${x.note ?? ""}`).join("\n") || "No codes", res);
663
815
  });
664
- program.command("audit").description("recent audit log (admin)").option("--action <prefix>", "e.g. content. or membership.").option("--limit <n>", "", "50").action(async (o) => {
816
+ program.command("audit").description("recent audit log of the current column (column admin)").option("--action <prefix>", "e.g. content. or membership.").option("--limit <n>", "", "50").action(async (o) => {
665
817
  const c = await needToken();
666
818
  const res = await c.call("GET", `/admin/audit${c.q({ action: o.action, limit: o.limit })}`);
667
- out(res.entries.map((e) => `${e.createdAt.slice(0, 19).replace("T", " ")} ${pad(e.actor ?? "system", 28)} ${pad(e.action, 24)} ${e.targetType}:${e.targetId}`).join("\n"), res);
819
+ const who = (e) => `${e.actor ?? "system"}${e.actorKind === "agent" ? " [agent]" : e.actorKind === "reader_agent" ? " [reader-agent]" : ""}${e.platformOperator ? " [platform]" : ""}`;
820
+ out(res.entries.map((e) => `${e.createdAt.slice(0, 19).replace("T", " ")} ${pad2(who(e), 36)} ${pad2(e.action, 24)} ${e.targetType}:${e.targetId}`).join("\n"), res);
821
+ });
822
+ var columnCmd = program.command("column").description("columns: discover, create (platform admin), settings (owner)");
823
+ columnCmd.command("ls").description("open columns, optionally by category").option("--category <slug>").option("-q, --query <text>").action(async (o) => {
824
+ const c = await client();
825
+ const res = await c.call("GET", `/columns${c.q({ category: o.category, q: o.query })}`);
826
+ out(res.columns.map((x) => `${pad2(x.slug, 24)} ${pad2(x.category?.slug ?? "\u2014", 12)} ${pad2(`${x.publishedCount} posts`, 10)} ${x.name}${x.tagline ? ` \u2014 ${x.tagline}` : ""}`).join("\n") || "No columns", res);
827
+ });
828
+ columnCmd.command("create <slug>").description("create a column and name its owner (platform admin)").requiredOption("--name <name>").requiredOption("--owner <email>", "the owner's email; they must have registered").option("--tagline <text>").option("--category <slug>").action(async (slug, o) => {
829
+ const res = await (await needToken()).call("POST", "/columns", { slug, name: o.name, ownerEmail: o.owner, tagline: o.tagline, category: o.category });
830
+ out(`Created column ${res.slug}`, res);
668
831
  });
669
- program.command("skill").description("print the agent usage guide (SKILL.md), or --install it for Claude Code").option("--install", "write it to ~/.claude/skills/ai-native-lab/SKILL.md").action(async (o) => {
832
+ columnCmd.command("info").description("the current column's public profile and plans").action(async () => {
833
+ const c = await client();
834
+ const slug = await currentColumn(c);
835
+ const res = await c.call("GET", `/columns/${encodeURIComponent(slug)}`);
836
+ out(`${res.name} (${res.slug})
837
+ plans: ${res.plans.map((p) => `${p.key}=rank ${p.rank}`).join(", ")}
838
+ team: ${res.team.map((t) => `${t.name} (${t.role})`).join(", ")}`, res);
839
+ });
840
+ columnCmd.command("set").description("change the current column's profile / settings (owner)").option("--name <name>").option("--tagline <text>").option("--slug <slug>", "rename the address; the old one keeps redirecting").option("--category <slug>").option("--comments <who>", "all | member | off").option("--ai-policy <policy>", "allow | cite-only | deny: what outside crawlers / AI may do with public content").option("--review <on|off>", "turn the author \u2192 editor review flow on or off").option("--notify-on-publish <on|off>", "email followers when something is published").action(async (o) => {
841
+ const c = await needToken();
842
+ const settings = { comments: o.comments, aiPolicy: o.aiPolicy, review: onoff("--review", o.review), notifyOnPublish: onoff("--notify-on-publish", o.notifyOnPublish) };
843
+ for (const k of Object.keys(settings)) if (settings[k] === void 0) delete settings[k];
844
+ const slug = await currentColumn(c);
845
+ const res = await c.call("PATCH", `/columns/${encodeURIComponent(slug)}`, { name: o.name, tagline: o.tagline, slug: o.slug, category: o.category, settings: Object.keys(settings).length ? settings : void 0 });
846
+ out(`Updated ${res.slug}`, res);
847
+ });
848
+ columnCmd.command("status <status>").description("active | suspended | archived (platform admin; pass --reason when acting on someone else's column)").option("--reason <text>", "why \u2014 recorded in the column's audit log").action(async (status, o) => {
849
+ const c = await needToken();
850
+ const slug = await currentColumn(c);
851
+ const res = await c.call("POST", `/columns/${encodeURIComponent(slug)}/status`, { status, reason: o.reason }, o.reason ? { "x-platform-reason": o.reason } : {});
852
+ out(`${slug} \u2192 ${status}`, res);
853
+ });
854
+ var planCmd = program.command("plan").description("membership plans of the current column (owner)");
855
+ planCmd.command("ls").action(async () => {
856
+ const res = await (await client()).call("GET", "/plans");
857
+ out(res.plans.map((p) => `${pad2(p.key, 14)} rank ${pad2(String(p.rank), 4)} ${p.name} ${p.description}`).join("\n") || "No plans", res);
858
+ });
859
+ planCmd.command("create <key>").requiredOption("--name <name>").requiredOption("--rank <n>", "higher rank includes everything below it").option("--description <text>", "", "").action(async (key, o) => {
860
+ const res = await (await needToken()).call("POST", "/admin/plans", { key, name: o.name, rank: int("--rank", o.rank), description: o.description });
861
+ out(`Created plan ${res.key} (rank ${res.rank})`, res);
862
+ });
863
+ planCmd.command("archive <key>").description("stop selling a plan; existing members keep it until they expire").action(async (key) => {
864
+ const res = await (await needToken()).call("PATCH", `/admin/plans/${encodeURIComponent(key)}`, { archived: true });
865
+ out(`Archived ${key}`, res);
866
+ });
867
+ team.command("invite <email> <role>").description("invite someone to the team by email (owner | admin | editor | author); they accept with `invite accept <token>` or in the browser").action(async (email, role) => {
868
+ const res = await (await needToken()).call("POST", "/admin/team/invites", { email, role });
869
+ out(res.mailed ? `Invitation emailed to ${email}` : `Mail isn't set up; send this link to ${email}:
870
+ ${res.link}`, res);
871
+ });
872
+ team.command("invites").description("pending invitations").action(async () => {
873
+ const res = await (await needToken()).call("GET", "/admin/team/invites");
874
+ out(res.invites.map((i) => `${pad2(i.id.slice(0, 8), 9)} ${pad2(i.email, 32)} ${pad2(i.role, 8)} until ${date2(i.expiresAt)}`).join("\n") || "No pending invitations", res);
875
+ });
876
+ team.command("uninvite <id>").action(async (id) => out(`Revoked ${id}`, await (await needToken()).call("DELETE", `/admin/team/invites/${encodeURIComponent(id)}`)));
877
+ program.command("invite").description("accept a team invitation (sign in as the invited email first)").command("accept <token>").action(async (token) => {
878
+ const res = await (await needToken()).call("POST", "/invites/accept", { token });
879
+ out(`You're now ${res.role} of ${res.column.slug}`, res);
880
+ });
881
+ var agentCmd = program.command("agent").description("agent seats of the current column (owner; agent credentials can't manage these)");
882
+ agentCmd.command("ls").action(async () => {
883
+ const res = await (await needToken()).call("GET", "/admin/agents");
884
+ out(res.agents.map((a) => `${pad2(a.id.slice(0, 8), 9)} ${pad2(a.name, 24)} ${a.canPublish ? "can publish" : "drafts only"} ${a.disabled ? "disabled" : "active"} ${a.tokens} tokens`).join("\n") || "No agent seats", res);
885
+ });
886
+ agentCmd.command("create <name>").option("--can-publish", "let this agent publish directly (still declared as `agent`, still audited)").action(async (name, o) => {
887
+ const res = await (await needToken()).call("POST", "/admin/agents", { name, canPublish: Boolean(o.canPublish) });
888
+ out(`Created agent seat ${res.id}`, res);
889
+ });
890
+ agentCmd.command("set <id>").option("--can-publish <on|off>").option("--disabled <on|off>").option("--name <name>").action(async (id, o) => {
891
+ const res = await (await needToken()).call("PATCH", `/admin/agents/${encodeURIComponent(id)}`, { name: o.name, canPublish: onoff("--can-publish", o.canPublish), disabled: onoff("--disabled", o.disabled) });
892
+ out(`Updated ${id}`, res);
893
+ });
894
+ agentCmd.command("rm <id>").action(async (id) => out(`Deleted ${id}`, await (await needToken()).call("DELETE", `/admin/agents/${encodeURIComponent(id)}`)));
895
+ agentCmd.command("revoke-all").description("emergency stop: revoke every agent credential of this column").action(async () => {
896
+ const res = await (await needToken()).call("POST", "/admin/agents/revoke-all", {});
897
+ out(`Revoked ${res.revoked} agent credentials`, res);
898
+ });
899
+ program.command("export").description("take your column with you: content, members (email + plan + expiry only), tags, code metadata (column admin)").option("--part <part>", "all | members | content | tags | codes | knowledge", "all").option("-o, --out <dir>", "write files into this folder instead of printing JSON").action(async (o) => {
900
+ const c = await needToken();
901
+ const res = await c.call("GET", `/admin/export${c.q({ part: o.part })}`);
902
+ if (!o.out) return out(JSON.stringify(res, null, 2), res);
903
+ await mkdir3(o.out, { recursive: true });
904
+ let files = 0;
905
+ const questionsOf = (slug) => (res.questions ?? []).filter((q) => q.status === "open" && q.source?.slug === slug && q.source.columnSlug === res.column.slug).map((q) => ({ id: q.id, text: q.text, disciplines: q.disciplines.map((d) => d.slug), ...q.membersOnly ? { membersOnly: true } : {} }));
906
+ for (const post of res.content ?? []) {
907
+ const { body, slug, ...meta } = post;
908
+ await mkdir3(path4.join(o.out, "content"), { recursive: true });
909
+ await writeFile3(path4.join(o.out, "content", `${slug}.md`), formatDoc({ slug, title: String(meta.title), summary: String(meta.summary ?? ""), access: String(meta.access), authorship: String(meta.authorship), status: String(meta.status), tags: meta.tags ?? [], cover: meta.cover ?? null, disciplines: meta.disciplines ?? [], research: meta.research, questions: questionsOf(slug), body: String(body ?? "") }));
910
+ files++;
911
+ }
912
+ if (res.members) {
913
+ const esc = (v) => /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
914
+ await writeFile3(path4.join(o.out, "members.csv"), ["email,name,plans,active,expires_at", ...res.members.map((m) => [m.email, m.name, m.plans.join("|"), m.active ? "yes" : "no", m.expiresAt ?? ""].map(esc).join(","))].join("\n"));
915
+ files++;
916
+ }
917
+ for (const part of ["tags", "codes", "questions", "bridges"]) if (res[part]) await writeFile3(path4.join(o.out, `${part}.json`), JSON.stringify(res[part], null, 2)), files++;
918
+ out(`Exported ${res.column.slug}: ${files} files in ${o.out}/`, { ok: true, dir: o.out, files });
919
+ });
920
+ program.command("mcp").description("run as an MCP server over stdio so an agent client can read and write the column (tools follow your credential's permissions)").action(async () => {
921
+ const { apiUrl, token, column } = await resolveAuth(g());
922
+ const { serveStdio } = await import("./mcp-X4ZHRZEN.js");
923
+ console.error(`ailab mcp: serving ${apiUrl}${column ? ` (column ${column})` : ""}${token ? "" : " as a guest"}`);
924
+ await serveStdio({ client: (col) => createClient(apiUrl, token, col ?? column, clientOptions()) });
925
+ });
926
+ columnCmd.command("follow").description("follow (or --remove) the current column: new-post emails when the column has them on, and its posts in your weekly digest").option("--remove", "stop following").action(async (o) => {
927
+ const res = await (await needToken()).call(o.remove ? "DELETE" : "PUT", "/column/follow");
928
+ out(o.remove ? "Unfollowed" : "Following", res);
929
+ });
930
+ columnCmd.command("apply <slug>").description("apply to open your own column; a platform admin reviews it").requiredOption("--name <name>").requiredOption("--pitch <text>", "what you'll write and for whom (at least 10 characters)").action(async (slug, o) => {
931
+ const res = await (await needToken()).call("POST", "/column-applications", { slug, name: o.name, pitch: o.pitch });
932
+ out(`Application ${res.id.slice(0, 8)} submitted; you'll get an email when it's decided`, res);
933
+ });
934
+ var apps = program.command("applications").description("column applications (platform admin)");
935
+ apps.command("ls").option("--status <s>", "pending | approved | rejected", "pending").action(async (o) => {
936
+ const c = await needToken();
937
+ const res = await c.call("GET", `/admin/column-applications${c.q({ status: o.status })}`);
938
+ out(res.applications.map((a) => `${a.id.slice(0, 8)} ${pad2(a.slug, 20)} ${pad2(a.applicant.email, 28)} ${a.name} \u2014 ${a.pitch.replace(/\s+/g, " ").slice(0, 80)}`).join("\n") || "Nothing waiting", res);
939
+ });
940
+ apps.command("decide <id> <verdict>").description("approve | reject").option("--note <text>", "sent to the applicant").action(async (id, verdict, o) => {
941
+ if (!["approve", "reject"].includes(verdict)) throw new CliError("bad_request", "verdict must be approve or reject", EXIT.usage);
942
+ const res = await (await needToken()).call("POST", `/admin/column-applications/${encodeURIComponent(id)}/decide`, { approve: verdict === "approve", note: o.note });
943
+ out(`${verdict}d ${id}`, res);
944
+ });
945
+ var hook = program.command("webhook").description("webhooks of the current column: events pushed to your service, HMAC-signed (column admin)");
946
+ hook.command("ls").action(async () => {
947
+ const res = await (await needToken()).call("GET", "/admin/webhooks");
948
+ out(res.webhooks.map((h) => `${h.id.slice(0, 8)} ${h.disabled ? "off" : "on "} ${pad2(h.url, 44)} ${h.events.join(",")} ok ${h.deliveries.ok} / failed ${h.deliveries.failed} / pending ${h.deliveries.pending}`).join("\n") || "No webhooks", res);
949
+ });
950
+ hook.command("create <url>").requiredOption("--events <list>", "comma separated: content.published, content.updated, content.unpublished, member.added, member.revoked, member.expired, comment.created, or *").description("the signing secret is shown once").action(async (url, o) => {
951
+ const res = await (await needToken()).call("POST", "/admin/webhooks", { url, events: o.events.split(",").map((s) => s.trim()).filter(Boolean) });
952
+ out(`Created ${res.id}
953
+ secret (shown once): ${res.secret}
954
+ Verify: X-Aln-Signature = t=<unix>,v1=HMAC_SHA256(secret, "<t>.<raw body>")`, res);
955
+ });
956
+ hook.command("test <id>").description("send a ping").action(async (id) => out(`Ping queued for ${id}`, await (await needToken()).call("POST", `/admin/webhooks/${encodeURIComponent(id)}/test`)));
957
+ hook.command("rotate <id>").description("issue a new signing secret").action(async (id) => {
958
+ const res = await (await needToken()).call("PATCH", `/admin/webhooks/${encodeURIComponent(id)}`, { rotateSecret: true });
959
+ out(`new secret (shown once): ${res.secret}`, res);
960
+ });
961
+ hook.command("rm <id>").action(async (id) => out(`Deleted ${id}`, await (await needToken()).call("DELETE", `/admin/webhooks/${encodeURIComponent(id)}`)));
962
+ hook.command("deliveries <id>").description("recent deliveries").action(async (id) => {
963
+ const res = await (await needToken()).call("GET", `/admin/webhooks/${encodeURIComponent(id)}/deliveries`);
964
+ out(res.deliveries.map((d) => `${d.id.slice(0, 8)} ${pad2(d.event, 20)} ${pad2(d.status, 8)} attempts ${d.attempts} ${d.responseCode ?? "\u2014"} ${d.createdAt.slice(0, 19)}`).join("\n") || "No deliveries", res);
965
+ });
966
+ program.command("report <type> <target>").description("report content (slug), a comment (id) or the whole column (any text) to the column team / platform").requiredOption("--reason <text>", "what's wrong (at least 4 characters)").action(async (type, target, o) => {
967
+ if (!["content", "comment", "column"].includes(type)) throw new CliError("bad_request", "type must be content, comment or column", EXIT.usage);
968
+ out("Reported. Thank you.", await (await needToken()).call("POST", "/reports", { targetType: type, target, reason: o.reason }));
969
+ });
970
+ program.command("stats").description("the current column's numbers: members, expiring, content, comments, followers, views (column admin)").action(async () => {
971
+ const s = await (await needToken()).call("GET", "/admin/stats");
972
+ out(
973
+ [
974
+ `members ${s.members.active} (+${s.members.new30d} in 30d; ${s.members.expiring7d} expire within 7d, ${s.members.expiring30d} within 30d) ${s.members.byPlan.map((p) => `${p.plan}:${p.n}`).join(" ")}`,
975
+ `content ${s.content.published} published \xB7 ${s.content.draft} drafts \xB7 ${s.content.review} in review \xB7 ${s.content.scheduled} scheduled`,
976
+ `comments pending ${s.comments.pending} \xB7 followers ${s.followers} \xB7 views(30d) ${s.views.last30d}`,
977
+ ...s.views.top.map((t) => ` ${pad2(t.slug, 28)} ${t.views}`)
978
+ ].join("\n"),
979
+ s
980
+ );
981
+ });
982
+ var qLine = (q) => `${q.id} ${pad2(q.status === "open" ? q.confirmed ? "open" : "pending" : q.status, 8)} ${pad2(q.disciplines.map((d) => d.slug).join("\xD7"), 28)} ${q.follows}f/${q.claims}c ${q.text}${q.source ? ` (${q.source.columnSlug}/${q.source.slug})` : ""}`;
983
+ var bLine = (b) => `${b.id} ${pad2(b.status, 9)} ${pad2(b.kind, 13)} ${b.from.columnSlug}/${b.from.slug} [${b.from.discipline.slug}] \u2194 ${b.to.columnSlug}/${b.to.slug} [${b.to.discipline.slug}]${b.actorKind === "agent" ? " (agent)" : ""} ${b.claim}`;
984
+ var disciplineCmd = program.command("discipline").description("the platform-wide list of disciplines (platform admins maintain it)");
985
+ disciplineCmd.command("ls").action(async () => {
986
+ const r = await (await client()).call("GET", "/disciplines");
987
+ out(r.disciplines.map((d) => `${pad2(d.slug, 20)} ${pad2(d.name, 10)} ${d.contents} published`).join("\n") || "No disciplines yet", r);
988
+ });
989
+ disciplineCmd.command("request <slug> <name>").description("ask the platform to add a discipline (column editors)").requiredOption("--reason <text>", "why the existing disciplines don't fit").action(async (slug, name, o) => out(`Requested ${slug}`, await (await needToken()).call("POST", "/discipline-requests", { slug, name, reason: o.reason })));
990
+ program.command("map").description("the discipline \xD7 discipline map: confirmed / proposed bridges, contradictions, open questions per pair").option("--cell <a,b>", "show one pair: its bridges and questions").option("--gaps", "list the emptiest pairs first (score = open questions + confirmed bridges + half the proposed ones) to find where nobody has asked yet").option("--concept <text>", "with --gaps: only pairs where both sides have published posts mentioning this concept").option("--limit <n>", "with --gaps: how many pairs (default 10)").action(async (o) => {
991
+ if (o.gaps && o.cell) throw new CliError("bad_request", "--gaps and --cell can't be combined", EXIT.usage);
992
+ if (!o.gaps && (o.concept !== void 0 || o.limit !== void 0)) throw new CliError("bad_request", "--concept and --limit only work with --gaps", EXIT.usage);
993
+ const c = await client();
994
+ if (o.gaps) {
995
+ const r2 = await c.call("GET", `/map/gaps${c.q({ limit: o.limit === void 0 ? void 0 : int("--limit", o.limit, 1), concept: o.concept })}`);
996
+ return out(formatGaps(r2), r2);
997
+ }
998
+ if (o.cell) {
999
+ const [a, b] = o.cell.split(",").map((s) => s.trim());
1000
+ const r2 = await c.call("GET", `/map/cell${c.q({ a, b })}`);
1001
+ return out([`${a} \xD7 ${b}: ${r2.bridges.length} bridges, ${r2.questions.length} questions`, ...r2.bridges.map(bLine), ...r2.questions.map(qLine)].join("\n"), r2);
1002
+ }
1003
+ const r = await c.call("GET", "/map");
1004
+ const pairs = [];
1005
+ for (let i = 0; i < r.disciplines.length; i++)
1006
+ for (let j = i + 1; j < r.disciplines.length; j++) {
1007
+ const a = r.disciplines[i].slug, b = r.disciplines[j].slug;
1008
+ const v = r.cells.find((x) => x.a === a && x.b === b || x.a === b && x.b === a);
1009
+ pairs.push(`${pad2(`${a} \xD7 ${b}`, 34)} ${v ? `${v.confirmed} confirmed \xB7 ${v.proposed} proposed \xB7 ${v.contradictions} contradictions \xB7 ${v.questions} questions${v.proposed > 2 * v.confirmed ? " \u2190 mostly unverified" : ""}` : "\u2014 blank \u2014"}`);
1010
+ }
1011
+ out([...pairs, "", `totals: ${JSON.stringify(r.totals)}`].join("\n"), r);
1012
+ });
1013
+ var questionCmd = program.command("question").description("questions: the question wall, follow / claim, and confirming agent-proposed questions (editors). Questions of a post are written with `push` (frontmatter `questions:`)");
1014
+ questionCmd.command("ls").description("the question wall").option("--discipline <slug>").option("--status <s>", "open | closed | all", "open").option("--limit <n>", "", "30").action(async (o) => {
1015
+ const c = await client();
1016
+ const r = await c.call("GET", `/questions${c.q({ discipline: o.discipline, status: o.status, limit: o.limit })}`);
1017
+ out(r.questions.map(qLine).join("\n") || "No questions", r);
1018
+ });
1019
+ questionCmd.command("search <text>").description("find questions on the wall by words (every word must appear in the question or its note); closed ones included by default").option("--discipline <slug>").option("--status <s>", "open | closed | all", "all").option("--limit <n>", "", "20").action(async (text, o) => {
1020
+ const c = await client();
1021
+ const r = await c.call("GET", `/questions${c.q({ q: text, discipline: o.discipline, status: o.status, limit: int("--limit", o.limit, 1) })}`);
1022
+ out(r.questions.map(qLine).join("\n") || "No matches", r);
1023
+ });
1024
+ questionCmd.command("check <text>").description("before asking: the most similar questions already on this site, and how crowded the pair of disciplines is. Says nothing about the web: search it yourself").option("--disciplines <a,b>", "exactly two discipline slugs, to also get that pair's open questions and bridges").action(async (text, o) => {
1025
+ const slugs = o.disciplines?.split(",").map((s) => s.trim()).filter(Boolean);
1026
+ if (slugs && (slugs.length !== 2 || slugs[0] === slugs[1])) throw new CliError("bad_request", "--disciplines takes exactly two different slugs, e.g. computing,anthropology", EXIT.usage);
1027
+ const c = await client();
1028
+ const r = await c.call("GET", `/questions/check${c.q({ text, disciplines: slugs?.join(",") })}`);
1029
+ out(formatCheck(r), r);
1030
+ });
1031
+ questionCmd.command("show <id>").action(async (id) => {
1032
+ const r = await (await client()).call("GET", `/questions/${encodeURIComponent(id)}`);
1033
+ out([qLine(r), ...r.claimsList.map((x) => ` claimed by ${x.name}${x.note ? `: ${x.note}` : ""}`)].join("\n"), r);
1034
+ });
1035
+ questionCmd.command("pending").description("this column's agent-proposed questions waiting for a human").action(async () => {
1036
+ const r = await (await needToken()).call("GET", "/column/questions?state=pending");
1037
+ out(r.questions.map((q) => [qLine(q), formatEvidence(q.evidence)].filter(Boolean).join("\n")).join("\n") || "Nothing waiting", r);
1038
+ });
1039
+ for (const action of ["confirm", "close", "reopen"]) {
1040
+ questionCmd.command(`${action} <id>`).description(action === "confirm" ? "confirm an agent-proposed question (humans only)" : `${action} a question of this column`).action(async (id) => out(`${id} \u2192 ${action}`, await (await needToken()).call("POST", `/column/questions/${encodeURIComponent(id)}/${action}`)));
1041
+ }
1042
+ questionCmd.command("follow <id>").option("--remove", "unfollow").action(async (id, o) => out(o.remove ? `Unfollowed ${id}` : `Following ${id}`, await (await needToken()).call(o.remove ? "DELETE" : "PUT", `/questions/${encodeURIComponent(id)}/follow`)));
1043
+ questionCmd.command("claim <id>").description("say you're working on it").option("--note <text>", "one line on what you're doing").option("--remove", "withdraw the claim").action(async (id, o) => out(o.remove ? `Withdrew claim on ${id}` : `Claimed ${id}`, await (await needToken()).call(o.remove ? "DELETE" : "PUT", `/questions/${encodeURIComponent(id)}/claim`, o.remove ? void 0 : { note: o.note })));
1044
+ questionCmd.command("ask <text>").description("post a platform question on a blank pair of the map (platform admins)").requiredOption("--disciplines <slugs>", "at least two, comma separated").action(async (text, o) => out("Posted", await (await needToken()).call("POST", "/admin/questions", { text, disciplines: o.disciplines.split(",").map((s) => s.trim()) })));
1045
+ var bridgeCmd = program.command("bridge").description("bridges between two disciplines: analogy, transfer, contradiction, translation. Agent bridges wait for a human editor");
1046
+ bridgeCmd.command("ls").description("this column's bridges").option("--status <s>", "proposed | confirmed | rejected").option("--run <id>", "only bridges from one compile run").option("--content <slug>", "bridges from / to one post (any visitor)").action(async (o) => {
1047
+ const c = await (o.content ? client() : needToken());
1048
+ const r = await c.call("GET", o.content ? `/contents/${encodeURIComponent(o.content)}/bridges` : `/bridges${c.q({ status: o.status, run: o.run })}`);
1049
+ out(r.bridges.map(bLine).join("\n") || "No bridges", r);
1050
+ });
1051
+ bridgeCmd.command("add <from> <to>").description("from: a slug in this column; to: a slug here or <column>/<slug> (published)").requiredOption("--kind <kind>", "analogy | transfer | contradiction | translation").requiredOption("--claim <text>", "one line readers see on the map").option("--rationale <text>").option("--from-discipline <slug>", "defaults to the from post's main discipline").option("--to-discipline <slug>", "defaults to the to post's main discipline").option("--run <id>", "compile run id, to review / withdraw a batch together").action(async (from, to, o) => {
1052
+ const r = await (await needToken()).call("POST", "/bridges", { from, to, kind: o.kind, claim: o.claim, rationale: o.rationale, fromDiscipline: o.fromDiscipline, toDiscipline: o.toDiscipline, compileRun: o.run });
1053
+ out(bLine(r), r);
1054
+ });
1055
+ for (const action of ["confirm", "reject"]) {
1056
+ bridgeCmd.command(`${action} <id>`).description(`${action} a bridge of this column (human editors)`).action(async (id) => out(bLine(await (await needToken()).call("POST", `/bridges/${encodeURIComponent(id)}/${action}`)), { ok: true }));
1057
+ }
1058
+ bridgeCmd.command("rm <id>").action(async (id) => out(`Deleted ${id}`, await (await needToken()).call("DELETE", `/bridges/${encodeURIComponent(id)}`)));
1059
+ program.command("graph").description("export the whole map for an agent to compile: disciplines, published research posts (metadata only), visible bridges and questions").option("-o, --out <file>", "write JSON to a file instead of stdout").action(async (o) => {
1060
+ const r = await (await client()).call("GET", "/graph");
1061
+ if (!o.out) return void process.stdout.write(`${JSON.stringify(r, null, 2)}
1062
+ `);
1063
+ await writeFile3(o.out, `${JSON.stringify(r, null, 2)}
1064
+ `);
1065
+ out(`Wrote ${o.out}: ${r.contents.length} posts, ${r.bridges.length} bridges, ${r.questions.length} questions`, { ok: true, file: o.out });
1066
+ });
1067
+ program.command("skill").description("print the agent usage guide (SKILL.md), or --install it as a skill").option("--install", "write it to ~/.claude/skills/ai-native-lab/SKILL.md (Claude Code)").option("--dir <skills-dir>", "install into another agent's skills folder as <skills-dir>/ai-native-lab/SKILL.md (implies --install)").action(async (o) => {
670
1068
  const src = await readFile4(fileURLToPath(new URL("../SKILL.md", import.meta.url)), "utf8");
671
- if (!o.install) return void process.stdout.write(src);
672
- const dir = path4.join(homedir2(), ".claude", "skills", "ai-native-lab");
1069
+ if (!o.install && !o.dir) return void process.stdout.write(src);
1070
+ const root = o.dir ? path4.resolve(o.dir.replace(/^~(?=$|\/)/, homedir2())) : path4.join(homedir2(), ".claude", "skills");
1071
+ const dir = path4.join(root, "ai-native-lab");
673
1072
  await mkdir3(dir, { recursive: true });
674
1073
  await writeFile3(path4.join(dir, "SKILL.md"), src);
675
1074
  out(`Installed ${path4.join(dir, "SKILL.md")}`, { ok: true, path: path4.join(dir, "SKILL.md") });
@@ -677,6 +1076,11 @@ program.command("skill").description("print the agent usage guide (SKILL.md), or
677
1076
  try {
678
1077
  await program.parseAsync();
679
1078
  } catch (e) {
1079
+ if (e instanceof CommanderError) {
1080
+ if (e.exitCode !== 0 && program.opts().json) process.stdout.write(`${JSON.stringify({ error: { code: "usage", message: e.message.replace(/^error: /, "") } })}
1081
+ `);
1082
+ process.exit(e.exitCode === 0 ? 0 : EXIT.usage);
1083
+ }
680
1084
  const err = e instanceof CliError ? e : new CliError("error", e.message, EXIT.error);
681
1085
  if (g().json) process.stdout.write(`${JSON.stringify({ error: { code: err.code, message: err.message } })}
682
1086
  `);