ai-native-lab 0.0.0-stage → 0.1.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 +89 -2
- package/SKILL.md +54 -0
- package/dist/cli.js +685 -0
- package/package.json +48 -3
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ai-native-lab · `aln`
|
|
2
2
|
|
|
3
|
-
|
|
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 # 命令名是 aln,需要 Node.js 20+
|
|
13
|
+
aln login # 打开浏览器确认授权;自建服务:aln login https://your-host
|
|
14
|
+
aln whoami
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Agent / CI 里直接用 Token(在网站「我的账户 → API Token」生成):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ALN_TOKEN=aln_xxx ALN_API_URL=https://ai-native-lab.mixdao.world aln ls --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 阅读
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
aln tags # 标签组和标签
|
|
27
|
+
aln ls --tag agentic-coding # 组内「或」、组间「且」
|
|
28
|
+
aln search 公司大脑
|
|
29
|
+
aln read <slug> # Markdown;HTML 内容输出抽出的文字,--html 输出源码
|
|
30
|
+
aln bookmark <slug>
|
|
31
|
+
aln 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
|
+
正文, 也会自动上传并压缩。
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
aln push post.md --dry-run
|
|
55
|
+
aln push post.md --publish
|
|
56
|
+
aln pull my-post -o post.md # 改完再 push,--if-match <version> 防止覆盖别人的修改
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
HTML 页面(单个 `.html`、`.zip`,或一个目录:根目录 `index.html` + 相对路径的资源,元数据放 `aln.json`):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
aln push ./site/ --slug my-report --title 报告 --access member --publish
|
|
63
|
+
aln pull my-report -o ./site/ # 解压并写 aln.json,改完:aln push ./site/ --if-match <version>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
图片和标签:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
aln image upload a.png b.jpg # 输出可直接粘贴的 Markdown
|
|
70
|
+
aln tag create company-brain --name 公司大脑 --group topic
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 管理(管理员)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
aln users --expiring 7
|
|
77
|
+
aln member grant someone@example.com --days 365
|
|
78
|
+
aln role someone@example.com editor
|
|
79
|
+
aln code create --days 365 --count 10
|
|
80
|
+
aln image purge --dry-run
|
|
81
|
+
aln audit --action membership.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 给 Claude Code 用
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
aln skill --install # 写入 ~/.claude/skills/ai-native-lab/SKILL.md
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
完整命令:`aln --help`、`aln <命令> --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 `aln` 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 (`aln`)
|
|
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 `aln login` or set `ALN_TOKEN`) · 4 forbidden / locked · 5 conflict (re-fetch, then retry).
|
|
11
|
+
|
|
12
|
+
## Auth
|
|
13
|
+
|
|
14
|
+
- `aln 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 `aln login <url>` (it opens a browser), or use `ALN_TOKEN=aln_… ALN_API_URL=https://…`.
|
|
16
|
+
|
|
17
|
+
## Read
|
|
18
|
+
|
|
19
|
+
1. `aln tags --json` to learn the tag slugs.
|
|
20
|
+
2. `aln ls --tag <a,b> --json` (OR within a tag group, AND across groups), or `aln 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. `aln 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
|
+
- `aln follow <tag>` subscribes the user to a weekly email digest (Mondays) of new content in that tag; `aln follow <tag> --remove` unsubscribes; `aln follow --json` lists current follows. Only do this when the user asks.
|
|
26
|
+
|
|
27
|
+
## Write (editor or admin)
|
|
28
|
+
|
|
29
|
+
- Edit existing: `aln pull <slug> -o post.md` → edit the file → `aln push post.md --if-match <version from frontmatter>`.
|
|
30
|
+
- New content: write Markdown with frontmatter (`slug`, `title`, `summary`, `access`, `tags`), then
|
|
31
|
+
`aln push post.md --dry-run --json` → `aln push post.md --json` (saves a draft) → `aln publish <slug>` when the user approves.
|
|
32
|
+
- Unknown tags fail with `unknown_tag`; create them with `aln 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` / `aln publish`.
|
|
35
|
+
|
|
36
|
+
## Admin
|
|
37
|
+
|
|
38
|
+
- `aln users --expiring 7 --json`, `aln member grant <email> --days 365`, `aln member revoke <email>`, `aln role <email> editor`.
|
|
39
|
+
- `aln code create --days 365 --count 10` prints codes once; store them safely.
|
|
40
|
+
- `aln audit --action membership. --json`.
|
|
41
|
+
|
|
42
|
+
## Images
|
|
43
|
+
|
|
44
|
+
- Reference local images in Markdown with relative paths (``, also `cover: ./img/cover.jpg`); `aln push` uploads them, compresses them to WebP (long edge ≤ 2000px) and rewrites the links. `--dry-run` does not upload.
|
|
45
|
+
- Or upload first: `aln image upload a.png b.jpg --json` → paste the returned `url`.
|
|
46
|
+
- `aln image ls --unused`; admins: `aln image purge --dry-run` then `aln image purge`, `aln 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: `aln push page.html`, `aln push site.zip`, or `aln 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 `aln.json` in the folder (`{"slug","title","summary","access","status","tags","cover"}`). Title falls back to `<title>`.
|
|
52
|
+
- Edit: `aln pull <slug> -o ./site/` (writes the files and `aln.json` with `version`) → edit → `aln push ./site/ --if-match <version>`.
|
|
53
|
+
- `aln read <slug>` prints the page's extracted text; `aln 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.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
|
+
}
|
|
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.ALN_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.ALN_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) ? `}${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 = "aln.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("aln").description("AI Native Lab: read, search and publish content from the terminal or an agent").version(package_default.version).addOption(new Option("--json", "print one JSON object to stdout (also ALN_JSON=1)").default(process.env.ALN_JSON === "1")).option("--api-url <url>", "server URL, overrides the saved one (env ALN_API_URL)").option("--token <token>", "API token, overrides the saved one (env ALN_TOKEN)").showHelpAfterError().addHelpText(
|
|
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  in Markdown is uploaded by push automatically; or: image upload a.png
|
|
329
|
+
HTML pages push page.html | push site.zip | push ./site/ (folder: index.html + assets, metadata in aln.json)
|
|
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
|
+
$ aln login # default server; or: aln login https://your-host
|
|
347
|
+
$ aln ls --tag agentic-coding --json
|
|
348
|
+
$ aln read company-brain > company-brain.md
|
|
349
|
+
$ cat note.md | aln push - --slug quick-note --publish
|
|
350
|
+
$ ALN_TOKEN=aln_xxx ALN_API_URL=https://lab.example.com aln ls --json`
|
|
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 `aln login <url>` or set ALN_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.ALN_API_URL ?? saved.url ?? DEFAULT_API_URL);
|
|
391
|
+
const c = createClient(apiUrl);
|
|
392
|
+
const start = await c.deviceStart(`aln on ${hostname()}`.slice(0, 80));
|
|
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 `aln search`)").option("--bookmarked", "only content you bookmarked").option("--limit <n>", "page size, max 100", "20").option("--offset <n>", "skip n items", "0").action(async (o) => {
|
|
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 `aln tags`, then `aln 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 aln.json) / .zip").option("-o, --out <file>", "write to a file instead of stdout; HTML pages: a folder, or a path ending in .zip").action(async (slug, o) => {
|
|
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: aln 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) => ``).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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"aln": "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
|
}
|