ai-native-lab 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +89 -2
  2. package/SKILL.md +54 -0
  3. package/dist/cli.js +685 -0
  4. package/package.json +48 -3
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # ai-native-lab · `ailab`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [AI Native Lab](https://ai-native-lab.mixdao.world) 的命令行工具,给人和 Agent 用:读、搜、发布内容,管理标签、图片、会员。
4
+
5
+ - 非交互:所有参数都能用 flag 或环境变量传
6
+ - `--json`:stdout 只输出一个 JSON 对象,提示信息走 stderr
7
+ - 稳定退出码:`0` 成功 · `1` 错误 · `2` 用法 · `3` 认证 · `4` 无权限 / 内容锁定 · `5` 冲突(版本不匹配、已存在)
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ npm i -g ai-native-lab # 命令名是 ailab,需要 Node.js 20+
13
+ ailab login # 打开浏览器确认授权;自建服务:ailab login https://your-host
14
+ ailab whoami
15
+ ```
16
+
17
+ Agent / CI 里直接用 Token(在网站「我的账户 → API Token」生成):
18
+
19
+ ```bash
20
+ AILAB_TOKEN=aln_xxx AILAB_API_URL=https://ai-native-lab.mixdao.world ailab ls --json
21
+ ```
22
+
23
+ ## 阅读
24
+
25
+ ```bash
26
+ ailab tags # 标签组和标签
27
+ ailab ls --tag agentic-coding # 组内「或」、组间「且」
28
+ ailab search 公司大脑
29
+ ailab read <slug> # Markdown;HTML 内容输出抽出的文字,--html 输出源码
30
+ ailab bookmark <slug>
31
+ ailab redeem ALN-XXXX-XXXX-XXXX # 兑换会员
32
+ ```
33
+
34
+ 会员内容权限不够时只输出摘要,退出码 `4`。
35
+
36
+ ## 发布(编辑团队)
37
+
38
+ Markdown,元数据写在 frontmatter:
39
+
40
+ ```markdown
41
+ ---
42
+ slug: my-post
43
+ title: 标题
44
+ summary: 看不了全文的读者只能看到这段
45
+ access: public # public | registered | member
46
+ tags: [agentic-coding]
47
+ cover: ./img/cover.jpg # 本地图片会自动上传
48
+ ---
49
+
50
+ 正文,![配图](./img/chart.png) 也会自动上传并压缩。
51
+ ```
52
+
53
+ ```bash
54
+ ailab push post.md --dry-run
55
+ ailab push post.md --publish
56
+ ailab pull my-post -o post.md # 改完再 push,--if-match <version> 防止覆盖别人的修改
57
+ ```
58
+
59
+ HTML 页面(单个 `.html`、`.zip`,或一个目录:根目录 `index.html` + 相对路径的资源,元数据放 `ailab.json`):
60
+
61
+ ```bash
62
+ ailab push ./site/ --slug my-report --title 报告 --access member --publish
63
+ ailab pull my-report -o ./site/ # 解压并写 ailab.json,改完:ailab push ./site/ --if-match <version>
64
+ ```
65
+
66
+ 图片和标签:
67
+
68
+ ```bash
69
+ ailab image upload a.png b.jpg # 输出可直接粘贴的 Markdown
70
+ ailab tag create company-brain --name 公司大脑 --group topic
71
+ ```
72
+
73
+ ## 管理(管理员)
74
+
75
+ ```bash
76
+ ailab users --expiring 7
77
+ ailab member grant someone@example.com --days 365
78
+ ailab role someone@example.com editor
79
+ ailab code create --days 365 --count 10
80
+ ailab image purge --dry-run
81
+ ailab audit --action membership.
82
+ ```
83
+
84
+ ## 给 Claude Code 用
85
+
86
+ ```bash
87
+ ailab skill --install # 写入 ~/.claude/skills/ai-native-lab/SKILL.md
88
+ ```
89
+
90
+ 完整命令:`ailab --help`、`ailab <命令> --help`。
package/SKILL.md ADDED
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: ai-native-lab
3
+ description: Read, search and publish content on the AI Native Lab platform with the `ailab` CLI. Use when the user asks to look up, quote, summarize, write, update or publish AI Native Lab content, manage its tags, or manage members / redeem codes.
4
+ ---
5
+
6
+ # AI Native Lab CLI (`ailab`)
7
+
8
+ Always pass `--json` and parse stdout (one JSON object). Errors are `{"error":{"code","message"}}` on stdout with a non-zero exit code.
9
+
10
+ Exit codes: 0 ok · 1 error · 2 usage · 3 auth (run `ailab login` or set `AILAB_TOKEN`) · 4 forbidden / locked · 5 conflict (re-fetch, then retry).
11
+
12
+ ## Auth
13
+
14
+ - `ailab whoami --json` → `role` is one of guest / user / member / editor / admin, plus `permissions` and `membership.expiresAt`.
15
+ - Not logged in: ask the user to run `ailab login <url>` (it opens a browser), or use `AILAB_TOKEN=aln_… AILAB_API_URL=https://…`.
16
+
17
+ ## Read
18
+
19
+ 1. `ailab tags --json` to learn the tag slugs.
20
+ 2. `ailab ls --tag <a,b> --json` (OR within a tag group, AND across groups), or `ailab search <text> --json` (full text, best matches first; the body only counts for content the reader can open, so locked items match on title / summary only).
21
+ 3. `ailab read <slug>` prints Markdown. `locked: true` (exit 4) means the reader's role can't open the full text; only the summary is available. Don't try to work around it.
22
+
23
+ ## Follow tags
24
+
25
+ - `ailab follow <tag>` subscribes the user to a weekly email digest (Mondays) of new content in that tag; `ailab follow <tag> --remove` unsubscribes; `ailab follow --json` lists current follows. Only do this when the user asks.
26
+
27
+ ## Write (editor or admin)
28
+
29
+ - Edit existing: `ailab pull <slug> -o post.md` → edit the file → `ailab push post.md --if-match <version from frontmatter>`.
30
+ - New content: write Markdown with frontmatter (`slug`, `title`, `summary`, `access`, `tags`), then
31
+ `ailab push post.md --dry-run --json` → `ailab push post.md --json` (saves a draft) → `ailab publish <slug>` when the user approves.
32
+ - Unknown tags fail with `unknown_tag`; create them with `ailab tag create <slug> --name <name> --group <key>`, or pass `--auto-tags <group>`.
33
+ - `access`: `public` (everyone), `registered` (signed-in users), `member` (members only). Always write a useful `summary`: it's all that locked readers see.
34
+ - Publishing is visible to everyone and logged; confirm with the user before `--publish` / `ailab publish`.
35
+
36
+ ## Admin
37
+
38
+ - `ailab users --expiring 7 --json`, `ailab member grant <email> --days 365`, `ailab member revoke <email>`, `ailab role <email> editor`.
39
+ - `ailab code create --days 365 --count 10` prints codes once; store them safely.
40
+ - `ailab audit --action membership. --json`.
41
+
42
+ ## Images
43
+
44
+ - Reference local images in Markdown with relative paths (`![说明](./img/chart.png)`, also `cover: ./img/cover.jpg`); `ailab push` uploads them, compresses them to WebP (long edge ≤ 2000px) and rewrites the links. `--dry-run` does not upload.
45
+ - Or upload first: `ailab image upload a.png b.jpg --json` → paste the returned `url`.
46
+ - `ailab image ls --unused`; admins: `ailab image purge --dry-run` then `ailab image purge`, `ailab image rm <id> [--force]`.
47
+
48
+ ## HTML pages
49
+
50
+ - Content is either Markdown or an HTML page (`format` in `--json` output). HTML pages open full-page at `pageUrl` (`/p/<slug>/`) in a sandbox: scripts run, but `localStorage`, cookies and the platform API are unavailable to the page.
51
+ - Publish a page: `ailab push page.html`, `ailab push site.zip`, or `ailab push ./site/` (a folder with `index.html` at the top plus relative css / js / images). Metadata comes from flags (`--slug --title --summary --access --tags`) or `ailab.json` in the folder (`{"slug","title","summary","access","status","tags","cover"}`). Title falls back to `<title>`.
52
+ - Edit: `ailab pull <slug> -o ./site/` (writes the files and `ailab.json` with `version`) → edit → `ailab push ./site/ --if-match <version>`.
53
+ - `ailab read <slug>` prints the page's extracted text; `ailab read <slug> --html` prints the entry HTML.
54
+ - Pushing Markdown to an HTML slug fails with `html_content` (exit 5) — that's intentional; don't convert formats unless the user asks.
package/dist/cli.js ADDED
@@ -0,0 +1,685 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn } from "child_process";
5
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
6
+ import { homedir as homedir2, hostname } from "os";
7
+ import path4 from "path";
8
+ 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.1",
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
+ ailab: "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": `ailab-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": `ailab-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": `ailab-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
+ }
175
+
176
+ // src/config.ts
177
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
178
+ import { homedir } from "os";
179
+ import path from "path";
180
+ var DIR = path.join(process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "ai-native-lab");
181
+ var FILE = path.join(DIR, "config.json");
182
+ var configPath = FILE;
183
+ var DEFAULT_API_URL = "https://ai-native-lab.mixdao.world";
184
+ var normalizeUrl = (url) => new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).origin;
185
+ async function loadConfig() {
186
+ try {
187
+ return JSON.parse(await readFile(FILE, "utf8"));
188
+ } catch {
189
+ return {};
190
+ }
191
+ }
192
+ async function saveConfig(c) {
193
+ await mkdir(DIR, { recursive: true, mode: 448 });
194
+ await writeFile(FILE, JSON.stringify(c, null, 2), { mode: 384 });
195
+ }
196
+ var clearConfig = () => rm(FILE, { force: true });
197
+ async function resolveAuth(flags) {
198
+ const c = await loadConfig();
199
+ const explicit = flags.apiUrl ?? process.env.AILAB_API_URL;
200
+ const apiUrl = normalizeUrl(explicit ?? c.url ?? DEFAULT_API_URL);
201
+ const same = !explicit || c.url && normalizeUrl(c.url) === apiUrl;
202
+ return { apiUrl, token: flags.token ?? process.env.AILAB_TOKEN ?? (same ? c.token : void 0) };
203
+ }
204
+
205
+ // src/images.ts
206
+ import { readFile as readFile2 } from "fs/promises";
207
+ import path2 from "path";
208
+ var MIME = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif" };
209
+ async function uploadFile(c, file) {
210
+ const buf = await readFile2(file);
211
+ const form = new FormData();
212
+ form.append("file", new Blob([buf], { type: MIME[path2.extname(file).toLowerCase()] ?? "application/octet-stream" }), path2.basename(file));
213
+ return c.upload("/images", form);
214
+ }
215
+ var isLocalRef = (ref) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(ref);
216
+ var MD_IMG = /!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(\s+"[^"]*")?\s*\)/g;
217
+ var HTML_IMG = /(<img\b[^>]*?\bsrc=["'])([^"']+)(["'])/gi;
218
+ async function uploadLocalImages(body, baseDir, upload) {
219
+ const refs = /* @__PURE__ */ new Set();
220
+ for (const m of body.matchAll(MD_IMG)) if (isLocalRef(m[2])) refs.add(m[2]);
221
+ for (const m of body.matchAll(HTML_IMG)) if (isLocalRef(m[2])) refs.add(m[2]);
222
+ const urls = /* @__PURE__ */ new Map();
223
+ const uploaded = [];
224
+ for (const ref of refs) {
225
+ const abs = path2.resolve(baseDir, decodeURIComponent(ref));
226
+ const img = await upload(abs);
227
+ urls.set(ref, img.url);
228
+ uploaded.push({ ...img, file: ref });
229
+ }
230
+ const out2 = body.replace(MD_IMG, (all, alt, ref, title = "") => urls.has(ref) ? `![${alt}](${urls.get(ref)}${title})` : all).replace(HTML_IMG, (all, a, ref, b) => urls.has(ref) ? `${a}${urls.get(ref)}${b}` : all);
231
+ return { body: out2, uploaded };
232
+ }
233
+
234
+ // src/markdown.ts
235
+ import YAML from "yaml";
236
+ var FM = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
237
+ function parseDoc(src) {
238
+ const m = FM.exec(src);
239
+ const meta = m ? YAML.parse(m[1]) : {};
240
+ let body = m ? src.slice(m[0].length).replace(/^\r?\n/, "") : src;
241
+ const fm = meta ?? {};
242
+ const str = (k) => typeof fm[k] === "string" ? fm[k] : fm[k] == null ? void 0 : String(fm[k]);
243
+ let title = str("title");
244
+ if (!title) {
245
+ const h = /^\s*#\s+(.+?)\s*#*\s*$/m.exec(body);
246
+ if (h && body.slice(0, h.index).trim() === "") {
247
+ title = h[1];
248
+ body = body.slice(h.index + h[0].length).replace(/^\r?\n/, "");
249
+ }
250
+ }
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 };
254
+ }
255
+ 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 };
257
+ if (d.cover) meta.cover = d.cover;
258
+ if (d.version != null) meta.version = d.version;
259
+ for (const k of Object.keys(meta)) if (meta[k] === void 0) delete meta[k];
260
+ return `---
261
+ ${YAML.stringify(meta).trimEnd()}
262
+ ---
263
+
264
+ ${d.body.replace(/^\n+/, "")}`;
265
+ }
266
+
267
+ // src/pages.ts
268
+ import { mkdir as mkdir2, readdir, readFile as readFile3, stat, writeFile as writeFile2 } from "fs/promises";
269
+ import path3 from "path";
270
+ import { unzipSync, zipSync } from "fflate";
271
+ var META_FILE = "ailab.json";
272
+ var IGNORE = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", "Thumbs.db", META_FILE]);
273
+ async function zipDir(dir) {
274
+ const files = {};
275
+ let count = 0;
276
+ const walk = async (rel) => {
277
+ for (const e of await readdir(path3.join(dir, rel), { withFileTypes: true })) {
278
+ if (IGNORE.has(e.name) || e.name.startsWith(".")) continue;
279
+ const r = rel ? `${rel}/${e.name}` : e.name;
280
+ if (e.isDirectory()) await walk(r);
281
+ else if (e.isFile()) {
282
+ files[r] = await readFile3(path3.join(dir, r));
283
+ if (++count > 2e3) throw new Error("A page can have at most 2000 files");
284
+ }
285
+ }
286
+ };
287
+ await walk("");
288
+ if (!files["index.html"] && !Object.keys(files).some((f) => !f.includes("/") && /\.html?$/i.test(f))) {
289
+ throw new Error(`No index.html at the top of ${dir}`);
290
+ }
291
+ return zipSync(files, { level: 6 });
292
+ }
293
+ async function readMeta(dir) {
294
+ try {
295
+ return JSON.parse(await readFile3(path3.join(dir, META_FILE), "utf8"));
296
+ } catch (e) {
297
+ if (e.code === "ENOENT") return {};
298
+ throw new Error(`Can't read ${META_FILE}: ${e.message}`);
299
+ }
300
+ }
301
+ var htmlTitle = (html) => /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() || void 0;
302
+ var isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
303
+ async function extractTo(dir, zip, meta) {
304
+ const files = unzipSync(zip);
305
+ for (const [name, buf] of Object.entries(files)) {
306
+ if (name.endsWith("/")) continue;
307
+ const target = path3.resolve(dir, name);
308
+ if (!target.startsWith(path3.resolve(dir) + path3.sep)) throw new Error(`Unsafe path in zip: ${name}`);
309
+ await mkdir2(path3.dirname(target), { recursive: true });
310
+ await writeFile2(target, buf);
311
+ }
312
+ await writeFile2(path3.join(dir, META_FILE), `${JSON.stringify(meta, null, 2)}
313
+ `);
314
+ return Object.keys(files).length;
315
+ }
316
+
317
+ // src/cli.ts
318
+ 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)").showHelpAfterError().addHelpText(
319
+ "after",
320
+ `
321
+ Recommended workflow:
322
+ First time login <url> \u2192 whoami \u2192 tags \u2192 ls
323
+ Read ls --tag <slug> \u2192 read <slug> (read prints Markdown; locked content shows the summary)
324
+ Search search "<words>" \u2192 read <slug>
325
+ Subscribe tags \u2192 follow <tag> (weekly email digest; follow --remove <tag> to stop)
326
+ Write (editor) pull <slug> -o post.md \u2192 edit \u2192 push post.md --publish
327
+ New content push post.md --slug <slug> --tags a,b [--auto-tags topic] --dry-run, then without --dry-run
328
+ 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 ailab.json)
330
+ pull <slug> -o ./site/ \u2192 edit \u2192 push ./site/ --if-match <version>
331
+ Members (admin) users --expiring 7 \u2192 member grant <email> --days 365
332
+
333
+ Markdown files carry metadata in YAML frontmatter:
334
+ ---
335
+ slug: my-post # lowercase letters, digits, hyphens
336
+ title: \u6807\u9898 # or the first "# heading"
337
+ summary: \u4E00\u53E5\u8BDD\u6458\u8981 # shown to readers who can't open the full text
338
+ access: public | registered | member
339
+ status: draft | published
340
+ tags: [agentic-coding, case-study]
341
+ ---
342
+
343
+ Exit codes: 0 ok \xB7 1 error \xB7 2 usage \xB7 3 auth \xB7 4 forbidden \xB7 5 conflict (version mismatch / exists)
344
+
345
+ Examples:
346
+ $ ailab login # default server; or: ailab login https://your-host
347
+ $ ailab ls --tag agentic-coding --json
348
+ $ ailab read company-brain > company-brain.md
349
+ $ cat note.md | ailab push - --slug quick-note --publish
350
+ $ AILAB_TOKEN=aln_xxx AILAB_API_URL=https://lab.example.com ailab ls --json`
351
+ );
352
+ var g = () => program.opts();
353
+ function out(human, data) {
354
+ if (g().json) process.stdout.write(`${JSON.stringify(data)}
355
+ `);
356
+ else console.log(human);
357
+ }
358
+ var note = (msg) => {
359
+ if (!g().json) console.error(msg);
360
+ };
361
+ async function client() {
362
+ const { apiUrl, token } = await resolveAuth(g());
363
+ return createClient(apiUrl, token);
364
+ }
365
+ async function needToken() {
366
+ const { apiUrl, token } = await resolveAuth(g());
367
+ if (!token) throw new CliError("unauthorized", "Not logged in. Run `ailab login <url>` or set AILAB_TOKEN", EXIT.auth);
368
+ return createClient(apiUrl, token);
369
+ }
370
+ var readInput = async (file) => {
371
+ if (file !== "-") return readFile4(file, "utf8");
372
+ const chunks = [];
373
+ for await (const c of process.stdin) chunks.push(c);
374
+ return Buffer.concat(chunks).toString("utf8");
375
+ };
376
+ var date = (iso) => iso ? iso.slice(0, 10) : "\u2014";
377
+ var pad = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
378
+ 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(", ")}]` : ""}`;
380
+ function openBrowser(url) {
381
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
382
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
383
+ try {
384
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
385
+ } catch {
386
+ }
387
+ }
388
+ 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
+ const saved = await loadConfig();
390
+ const apiUrl = normalizeUrl(url ?? g().apiUrl ?? process.env.AILAB_API_URL ?? saved.url ?? DEFAULT_API_URL);
391
+ const c = createClient(apiUrl);
392
+ const start = await c.deviceStart(`ailab on ${hostname()}`.slice(0, 80));
393
+ note(`Open ${start.verificationUrlComplete}
394
+ and confirm the code ${start.userCode}. Waiting\u2026`);
395
+ if (opts.browser) openBrowser(start.verificationUrlComplete);
396
+ const deadline = Date.now() + start.expiresIn * 1e3;
397
+ while (Date.now() < deadline) {
398
+ await new Promise((r) => setTimeout(r, start.interval * 1e3));
399
+ try {
400
+ const res = await c.deviceToken(start.deviceCode);
401
+ await saveConfig({ url: apiUrl, token: res.token, email: res.user.email });
402
+ return out(`Logged in to ${apiUrl} as ${res.user.email}`, { ok: true, url: apiUrl, email: res.user.email });
403
+ } catch (e) {
404
+ if (e instanceof CliError && e.code === "authorization_pending") continue;
405
+ throw e;
406
+ }
407
+ }
408
+ throw new CliError("expired_token", "Login request expired. Run login again", EXIT.auth);
409
+ });
410
+ program.command("logout").description("forget the saved token").action(async () => {
411
+ await clearConfig();
412
+ out(`Removed ${configPath}`, { ok: true });
413
+ });
414
+ program.command("whoami").description("who you are, your role and membership expiry").action(async () => {
415
+ 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}
418
+ permissions: ${me.permissions.join(", ")}` : "guest (not logged in)", me);
419
+ });
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 `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
+ 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
+ const more = res.nextOffset != null ? `
423
+ \u2026 more: --offset ${res.nextOffset}` : "";
424
+ out(res.items.length ? res.items.map(listLine).join("\n") + more : "No content", res);
425
+ });
426
+ program.command("search <text>").description("full-text search, best matches first; every space-separated word must match; the body is searched only for content you can read").option("--limit <n>", "max results", "20").action(async (text, o) => {
427
+ const res = await (await client()).list({ q: text, limit: o.limit });
428
+ out(res.items.length ? res.items.map(listLine).join("\n") : "No matches", res);
429
+ });
430
+ program.command("read <slug>").description("print a piece of content as Markdown; HTML pages print their extracted text (locked content prints the summary and exits 4)").option("--meta", "include YAML frontmatter").option("--html", "HTML pages: print the entry page's HTML source instead of text").action(async (slug, o) => {
431
+ const c = await client();
432
+ const item = await c.get(slug);
433
+ if (g().json) return out("", item);
434
+ if (item.locked) {
435
+ console.log(`# ${item.title}
436
+
437
+ ${item.summary}
438
+ `);
439
+ throw new CliError("locked", `Full text is for ${item.access === "member" ? "members" : "signed-in users"} only`, EXIT.forbidden);
440
+ }
441
+ if (o.html) {
442
+ if (item.format !== "html") throw new CliError("not_html", `${slug} is Markdown; drop --html`, EXIT.usage);
443
+ return void process.stdout.write(await c.call("GET", `/contents/${encodeURIComponent(slug)}?format=html`));
444
+ }
445
+ const doc = { ...item, tags: item.tags.map((t) => t.slug), body: item.body ?? "" };
446
+ console.log(o.meta ? formatDoc(doc) : `# ${item.title}
447
+
448
+ ${doc.body}`);
449
+ });
450
+ program.command("tags").description("all tag groups and tags with published counts").action(async () => {
451
+ const res = await (await client()).tags();
452
+ 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);
454
+ });
455
+ program.command("bookmark <slug>").description("bookmark (or --remove) a piece of content").option("--remove", "remove the bookmark").action(async (slug, o) => {
456
+ const res = await (await needToken()).call(o.remove ? "DELETE" : "PUT", `/contents/${encodeURIComponent(slug)}/bookmark`);
457
+ out(o.remove ? `Removed bookmark ${slug}` : `Bookmarked ${slug}`, res);
458
+ });
459
+ program.command("follow [tag]").description("follow a tag (weekly email digest of new content in it), --remove to unfollow; no tag lists what you follow").option("--remove", "unfollow").action(async (slug, o) => {
460
+ const c = await needToken();
461
+ if (!slug) {
462
+ if (o.remove) throw new CliError("bad_request", "Pass the tag to unfollow", EXIT.usage);
463
+ 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 `ailab tags`, then `ailab follow <tag>`", res2);
465
+ }
466
+ const res = await c.call(o.remove ? "DELETE" : "PUT", `/tags/${encodeURIComponent(slug)}/follow`);
467
+ out(o.remove ? `Unfollowed ${slug}` : `Following ${slug} \xB7 weekly digest every Monday`, res);
468
+ });
469
+ program.command("redeem <code>").description("redeem a membership code").action(async (code2) => {
470
+ const res = await (await needToken()).call("POST", "/redeem", { code: code2 });
471
+ out(`+${res.days} days, member until ${date(res.membership?.expiresAt ?? null)}`, res);
472
+ });
473
+ async function pushPage(file, o) {
474
+ const dir = await isDir(file);
475
+ const meta = dir ? await readMeta(file) : {};
476
+ const base = path4.basename(path4.resolve(file)).replace(/\.(zip|html?)$/i, "");
477
+ const slug = o.slug ?? meta.slug ?? slugify(base);
478
+ if (!slug || !isSlug(slug)) throw new CliError("invalid_slug", `Pass --slug (lowercase letters, digits, hyphens)${dir ? ` or set "slug" in ${META_FILE}` : ""}`, EXIT.usage);
479
+ const data = dir ? await zipDir(file) : await readFile4(file);
480
+ let title = o.title ?? meta.title;
481
+ if (!title && /\.html?$/i.test(file)) title = htmlTitle(Buffer.from(data).toString("utf8"));
482
+ if (!title && dir) title = htmlTitle(await readFile4(path4.join(file, "index.html"), "utf8").catch(() => ""));
483
+ if (/\.html?$/i.test(file) && /<img\b[^>]*\bsrc=["'](?![a-z][a-z0-9+.-]*:|\/|#)/i.test(Buffer.from(data).toString("utf8"))) {
484
+ note("warning: this HTML references local files; push the folder (or a .zip) so they are uploaded too");
485
+ }
486
+ const c = await needToken();
487
+ let cover = meta.cover ?? void 0;
488
+ if (cover && isLocalRef(cover)) {
489
+ if (o.dryRun) {
490
+ note(`dry run: would upload cover ${cover}`);
491
+ cover = void 0;
492
+ } else {
493
+ const img = await uploadFile(c, path4.resolve(dir ? file : path4.dirname(file), cover));
494
+ note(`uploaded cover ${cover} \u2192 ${img.url}`);
495
+ cover = img.url;
496
+ }
497
+ }
498
+ const status = o.publish ? "published" : o.draft ? "draft" : meta.status;
499
+ const tags = o.tags ? o.tags.split(",").map((s) => s.trim()).filter(Boolean) : meta.tags;
500
+ const form = new FormData();
501
+ 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 }));
503
+ const res = await c.upload(
504
+ `/contents/${encodeURIComponent(slug)}/page${c.q({ dryRun: o.dryRun })}`,
505
+ form,
506
+ "PUT",
507
+ o.ifMatch ? { "if-match": o.ifMatch } : {}
508
+ );
509
+ if (res.dryRun) return out(`dry run: would ${res.action} ${slug} (HTML, entry ${res.entry}, ${res.files} files)`, res);
510
+ const p = res.content.page;
511
+ 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
+ }
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) => {
514
+ if (file !== "-" && (await isDir(file) || /\.(zip|html?)$/i.test(file))) return pushPage(file, o);
515
+ const doc = parseDoc(await readInput(file));
516
+ const slug = o.slug ?? doc.slug ?? (file === "-" ? "" : slugify(path4.basename(file, path4.extname(file))));
517
+ if (!slug || !isSlug(slug)) throw new CliError("invalid_slug", "Pass --slug (lowercase letters, digits, hyphens) or set slug in frontmatter", EXIT.usage);
518
+ const title = o.title ?? doc.title;
519
+ if (!title) throw new CliError("bad_request", "No title: add `title:` to frontmatter, a leading `# heading`, or pass --title", EXIT.usage);
520
+ const status = o.publish ? "published" : o.draft ? "draft" : doc.status;
521
+ const tags = o.tags ? o.tags.split(",").map((s) => s.trim()).filter(Boolean) : doc.tags;
522
+ const c = await needToken();
523
+ let text = doc.body;
524
+ let cover = doc.cover;
525
+ const images = [];
526
+ if (o.uploadImages && !o.dryRun) {
527
+ const baseDir = file === "-" ? process.cwd() : path4.dirname(path4.resolve(file));
528
+ const r = await uploadLocalImages(text, baseDir, (abs) => uploadFile(c, abs));
529
+ text = r.body;
530
+ images.push(...r.uploaded);
531
+ if (cover && isLocalRef(cover)) {
532
+ const img = await uploadFile(c, path4.resolve(baseDir, cover));
533
+ images.push({ ...img, file: cover });
534
+ cover = img.url;
535
+ }
536
+ for (const i of images) note(`uploaded ${i.file} \u2192 ${i.url} (${Math.round(i.originalBytes / 1024)}KB \u2192 ${Math.round(i.bytes / 1024)}KB)`);
537
+ } else if (o.dryRun && cover && isLocalRef(cover)) {
538
+ note(`dry run: would upload cover ${cover}`);
539
+ cover = void 0;
540
+ }
541
+ const body = { title, summary: o.summary ?? doc.summary, body: text, cover, access: o.access ?? doc.access, status, tags, autoCreateTagsIn: o.autoTags };
542
+ 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);
544
+ out(`${res.created ? "Created" : "Updated"} ${slug} \xB7 ${res.content.status} \xB7 v${res.content.version}`, { ...res, images });
545
+ });
546
+ 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
+ const c = await needToken();
548
+ const item = await c.get(slug);
549
+ if (item.locked || item.body == null) throw new CliError("locked", "You can't read the full text of this content", EXIT.forbidden);
550
+ if (item.format === "html") {
551
+ if (!o.out) throw new CliError("bad_request", `${slug} is an HTML page: pass -o <folder> or -o <file.zip>`, EXIT.usage);
552
+ const zip = await c.download(`/contents/${encodeURIComponent(slug)}/page.zip`);
553
+ if (/\.zip$/i.test(o.out)) {
554
+ await writeFile3(o.out, zip);
555
+ return out(`Wrote ${o.out} (v${item.version})`, { ok: true, file: o.out, version: item.version });
556
+ }
557
+ const n = await extractTo(o.out, zip, {
558
+ slug: item.slug,
559
+ title: item.title,
560
+ summary: item.summary,
561
+ access: item.access,
562
+ status: item.status,
563
+ tags: item.tags.map((t) => t.slug),
564
+ cover: item.cover,
565
+ version: item.version
566
+ });
567
+ 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
+ }
569
+ const md = formatDoc({ ...item, tags: item.tags.map((t) => t.slug), body: item.body });
570
+ if (!o.out) return void process.stdout.write(md.endsWith("\n") ? md : `${md}
571
+ `);
572
+ await writeFile3(o.out, md);
573
+ out(`Wrote ${o.out} (v${item.version})`, { ok: true, file: o.out, version: item.version });
574
+ });
575
+ for (const action of ["publish", "unpublish", "archive"]) {
576
+ 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
+ const res = await (await needToken()).action(slug, action);
578
+ out(`${slug} \u2192 ${res.content.status}`, res);
579
+ });
580
+ }
581
+ var tagCmd = program.command("tag").description("manage tags (editors)");
582
+ tagCmd.command("create <slug>").requiredOption("--name <name>", "display name").requiredOption("--group <key>", "tag group key").option("--color <color>", "green | lavender | divider").option("--description <text>").action(async (slug, o) => {
583
+ const res = await (await needToken()).call("POST", "/tags", { slug, ...o });
584
+ out(`Created tag ${slug}`, res);
585
+ });
586
+ tagCmd.command("archive <slug>").action(async (slug) => {
587
+ const res = await (await needToken()).call("DELETE", `/tags/${encodeURIComponent(slug)}`);
588
+ out(`Archived tag ${slug}`, res);
589
+ });
590
+ 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 });
592
+ out(`Created tag group ${key}`, res);
593
+ });
594
+ var imageCmd = program.command("image").description("upload and manage images (editors upload, admins delete)");
595
+ imageCmd.command("upload <files...>").description("upload images (compressed to WebP, long edge \u2264 2000px); prints Markdown you can paste").action(async (files) => {
596
+ const c = await needToken();
597
+ const res = [];
598
+ for (const f of files) res.push({ file: f, ...await uploadFile(c, f) });
599
+ out(res.map((r) => `![${path4.basename(r.file, path4.extname(r.file))}](${r.url})`).join("\n"), { images: res });
600
+ });
601
+ 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
+ const c = await needToken();
603
+ 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)"}`);
605
+ out([`${res.stats.total} images, ${res.stats.unused} unused`, ...lines].join("\n"), res);
606
+ });
607
+ 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) => {
608
+ const c = await needToken();
609
+ const res = await c.call("DELETE", `/images/${encodeURIComponent(id)}${o.force ? "?force=1" : ""}`);
610
+ out(`Deleted ${id}`, res);
611
+ });
612
+ 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) });
614
+ out(`${o.dryRun ? "Would delete" : "Deleted"} ${res.count} images (${Math.round(res.bytes / 1024)}KB)`, res);
615
+ });
616
+ async function findUserByEmail(email) {
617
+ 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);
621
+ return { c, u };
622
+ }
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) => {
624
+ const c = await needToken();
625
+ if (o.expiring) {
626
+ 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);
628
+ }
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);
631
+ });
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) => {
634
+ if (!o.days === !o.until) throw new CliError("bad_request", "Pass exactly one of --days or --until", EXIT.usage);
635
+ 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);
638
+ });
639
+ member.command("revoke <email>").action(async (email) => {
640
+ const { c, u } = await findUserByEmail(email);
641
+ const res = await c.call("DELETE", `/admin/users/${u.id}/membership`);
642
+ out(`${email} is now ${res.role}`, res);
643
+ });
644
+ program.command("role <email> <role>").description("set staff role: admin | editor | user (admin)").action(async (email, role) => {
645
+ const { c, u } = await findUserByEmail(email);
646
+ const res = await c.call("POST", `/admin/users/${u.id}/role`, { role });
647
+ out(`${email} \u2192 ${res.staffRole}`, res);
648
+ });
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) => {
651
+ 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),
655
+ expiresAt: o.expires,
656
+ note: o.note
657
+ });
658
+ out(res.codes.join("\n"), res);
659
+ });
660
+ code.command("ls").action(async () => {
661
+ 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);
663
+ });
664
+ program.command("audit").description("recent audit log (admin)").option("--action <prefix>", "e.g. content. or membership.").option("--limit <n>", "", "50").action(async (o) => {
665
+ const c = await needToken();
666
+ 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);
668
+ });
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) => {
670
+ 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");
673
+ await mkdir3(dir, { recursive: true });
674
+ await writeFile3(path4.join(dir, "SKILL.md"), src);
675
+ out(`Installed ${path4.join(dir, "SKILL.md")}`, { ok: true, path: path4.join(dir, "SKILL.md") });
676
+ });
677
+ try {
678
+ await program.parseAsync();
679
+ } catch (e) {
680
+ const err = e instanceof CliError ? e : new CliError("error", e.message, EXIT.error);
681
+ if (g().json) process.stdout.write(`${JSON.stringify({ error: { code: err.code, message: err.message } })}
682
+ `);
683
+ else console.error(`error: ${err.message}`);
684
+ process.exit(err.exitCode);
685
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "ai-native-lab",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "AI Native Lab CLI for people and agents: read, search and publish content. Non-interactive, --json output, stable exit codes.",
5
+ "keywords": [
6
+ "ai-native-lab",
7
+ "cli",
8
+ "agent",
9
+ "claude-code",
10
+ "content",
11
+ "markdown",
12
+ "mixlab"
13
+ ],
14
+ "homepage": "https://github.com/shadowcz007/ai-native-lab/tree/main/packages/cli#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/shadowcz007/ai-native-lab.git",
18
+ "directory": "packages/cli"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/shadowcz007/ai-native-lab/issues"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "ailab": "dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "SKILL.md",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "devDependencies": {
36
+ "@ai-native-lab/shared": "0.0.0"
37
+ },
38
+ "dependencies": {
39
+ "commander": "^15.0.0",
40
+ "fflate": "^0.8.3",
41
+ "yaml": "^2.8.1"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "dev": "tsx src/cli.ts",
48
+ "build": "tsup",
49
+ "typecheck": "tsc -p ."
50
+ }
6
51
  }