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/README.md +59 -29
- package/SKILL.md +88 -30
- package/dist/chunk-2AEAK4KI.js +226 -0
- package/dist/cli.js +646 -242
- package/dist/mcp-X4ZHRZEN.js +175 -0
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
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
|
|
252
|
-
const
|
|
253
|
-
return {
|
|
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 = {
|
|
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 = "
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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  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
|
|
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
|
-
|
|
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
|
-
$
|
|
347
|
-
$
|
|
348
|
-
$
|
|
349
|
-
$ cat note.md |
|
|
350
|
-
$
|
|
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 `
|
|
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
|
|
377
|
-
var
|
|
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) => `${
|
|
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.
|
|
391
|
-
const c = createClient(apiUrl);
|
|
392
|
-
const start = await c.deviceStart(`
|
|
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,
|
|
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
|
|
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 ${
|
|
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("
|
|
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.
|
|
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) => ` ${
|
|
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) => `${
|
|
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 ${
|
|
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)
|
|
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)
|
|
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
|
|
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:
|
|
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 ?
|
|
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)} ${
|
|
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:
|
|
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
|
|
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) => `${
|
|
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
|
-
|
|
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("
|
|
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.
|
|
637
|
-
out(`${email} is a member until ${
|
|
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
|
|
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.
|
|
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
|
-
|
|
650
|
-
|
|
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:
|
|
653
|
-
count:
|
|
654
|
-
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
`);
|