@yijiancaotang/cli 0.0.0-stage → 1.0.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 +279 -2
- package/bin/caotang.cjs +15 -0
- package/dist/agent-identity.js +14 -0
- package/dist/auth.js +337 -0
- package/dist/capability-result.js +80 -0
- package/dist/cli.js +248 -0
- package/dist/command-help.js +100 -0
- package/dist/commands.js +350 -0
- package/dist/config.js +14 -0
- package/dist/connection-copy.js +40 -0
- package/dist/context.js +30 -0
- package/dist/contracts.js +1 -0
- package/dist/create-bundle.js +103 -0
- package/dist/create-file-failure.js +18 -0
- package/dist/credential-store.js +594 -0
- package/dist/doctor.js +214 -0
- package/dist/errors.js +214 -0
- package/dist/generated/knowledge-growth.js +37 -0
- package/dist/habit-day.js +58 -0
- package/dist/habit-read.js +30 -0
- package/dist/habit-wire.js +56 -0
- package/dist/http.js +201 -0
- package/dist/input-validation.js +76 -0
- package/dist/installation.js +491 -0
- package/dist/knowledge-growth.js +85 -0
- package/dist/local-commands.js +136 -0
- package/dist/local-lock.js +245 -0
- package/dist/local-types.js +3 -0
- package/dist/missing-sections.js +71 -0
- package/dist/onboarding.js +48 -0
- package/dist/output.js +52 -0
- package/dist/pending-requests.js +45 -0
- package/dist/platforms.js +200 -0
- package/dist/query-daily.js +70 -0
- package/dist/query-search.js +42 -0
- package/dist/query-validation.js +45 -0
- package/dist/skill-assets.js +136 -0
- package/dist/upload.js +130 -0
- package/dist/web-citations.js +68 -0
- package/dist/write-permissions.js +41 -0
- package/package.json +27 -4
- package/skills/caotang-context/SKILL.md +47 -0
- package/skills/caotang-first-connect/SKILL.md +62 -0
- package/skills/caotang-project-progress/SKILL.md +87 -0
- package/skills/caotang-save-knowledge/SKILL.md +40 -0
- package/skills/caotang-search/SKILL.md +68 -0
- package/skills/caotang-workday/SKILL.md +34 -0
- package/skills/manifest.json +11 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
|
|
2
|
+
import { access, lstat, stat, realpath, open, readdir } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { CliError, asRecord } from "./errors.js";
|
|
7
|
+
import { PLATFORM_IDS, isPlatformId, platformSupported } from "./local-types.js";
|
|
8
|
+
import { bindSkillAgent, completeSkillInventory } from "./skill-assets.js";
|
|
9
|
+
import { acquireLocalLock } from "./local-lock.js";
|
|
10
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
11
|
+
const missing = (error) => asRecord(error)?.code === "ENOENT";
|
|
12
|
+
const unsafe = () => new CliError("installation_unsafe", "本地安装位置未通过安全检查。", "请人工检查属主、权限、符号链接与目标目录;不要自动删除、覆盖或修改凭据。");
|
|
13
|
+
const permissionDenied = (path) => new CliError("installation_permission_denied", "没有权限访问本地安装目标目录。", "请由本人核对详情中目标目录的访问权限;不要自动提权或放宽权限。", 4, { target_directory: path });
|
|
14
|
+
const conflict = () => new CliError("installation_conflict", "已有命令行文件与当前候选不一致。", "保留已有文件,先人工核对安装来源;不要强制覆盖或删除。");
|
|
15
|
+
const skillConflict = () => new CliError("skill_conflict", "已有同名技能与当前候选不一致。", "保留原技能,人工核对冲突;不要覆盖个人修改。");
|
|
16
|
+
const sourceInvalid = () => new CliError("installation_source_invalid", "当前命令行构建或技能资源不完整。", "使用维护者提供的完整且已审核的构建,不要运行不明安装脚本。", 7);
|
|
17
|
+
const packageRoot = () => fileURLToPath(new URL("../", import.meta.url));
|
|
18
|
+
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
19
|
+
const unchanged = (a, b) => same(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
20
|
+
// No await or user callback while cwd is pinned: relative operations use the checked directory inode.
|
|
21
|
+
function pinned(directory, action, failure = unsafe) {
|
|
22
|
+
const original = process.cwd();
|
|
23
|
+
try {
|
|
24
|
+
process.chdir(directory.path);
|
|
25
|
+
const current = lstatSync(".");
|
|
26
|
+
if (!current.isDirectory() || !same(current, directory.identity) || current.uid !== directory.identity.uid || (current.mode & 0o7777) !== (directory.identity.mode & 0o7777))
|
|
27
|
+
throw failure();
|
|
28
|
+
return action();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
process.chdir(original);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readDescriptor(fd, max) {
|
|
35
|
+
const bytes = Buffer.alloc(max + 1);
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (length < bytes.length) {
|
|
38
|
+
const read = readSync(fd, bytes, length, bytes.length - length, length);
|
|
39
|
+
if (!read)
|
|
40
|
+
break;
|
|
41
|
+
length += read;
|
|
42
|
+
}
|
|
43
|
+
return bytes.subarray(0, length);
|
|
44
|
+
}
|
|
45
|
+
export class OwnedTree {
|
|
46
|
+
home;
|
|
47
|
+
uid;
|
|
48
|
+
constructor(home) { if (!isAbsolute(home) || typeof process.getuid !== "function")
|
|
49
|
+
throw unsafe(); this.home = resolve(home); this.uid = process.getuid(); }
|
|
50
|
+
within(path) {
|
|
51
|
+
const absolute = resolve(path), rel = relative(this.home, absolute);
|
|
52
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
53
|
+
throw unsafe();
|
|
54
|
+
return absolute;
|
|
55
|
+
}
|
|
56
|
+
checkDirectory(info, path) {
|
|
57
|
+
if (!info.isDirectory() || info.uid !== this.uid || (info.mode & 0o022) !== 0)
|
|
58
|
+
throw unsafe();
|
|
59
|
+
const rel = relative(this.home, path);
|
|
60
|
+
if ((rel === ".caotang" || rel.startsWith(`.caotang${sep}`)) && (info.mode & 0o7777) !== 0o700)
|
|
61
|
+
throw unsafe();
|
|
62
|
+
}
|
|
63
|
+
async verify(directories) {
|
|
64
|
+
for (const directory of directories) {
|
|
65
|
+
const info = await lstat(directory.path);
|
|
66
|
+
this.checkDirectory(info, directory.path);
|
|
67
|
+
if (!same(info, directory.identity) || !same(await directory.handle.stat(), directory.identity))
|
|
68
|
+
throw unsafe();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async directory(path, create, action) {
|
|
72
|
+
const absolute = this.within(path), rel = relative(this.home, absolute), directories = [];
|
|
73
|
+
try {
|
|
74
|
+
let current = this.home;
|
|
75
|
+
for (const part of [null, ...(rel ? rel.split(sep) : [])]) {
|
|
76
|
+
if (part !== null)
|
|
77
|
+
current = join(current, part);
|
|
78
|
+
let info;
|
|
79
|
+
try {
|
|
80
|
+
info = await lstat(current);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!missing(error))
|
|
84
|
+
throw error;
|
|
85
|
+
if (current === this.home)
|
|
86
|
+
throw unsafe();
|
|
87
|
+
if (!create)
|
|
88
|
+
return null;
|
|
89
|
+
await this.verify(directories);
|
|
90
|
+
const parent = directories.at(-1);
|
|
91
|
+
if (!parent)
|
|
92
|
+
throw unsafe();
|
|
93
|
+
pinned(parent, () => {
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(part, { mode: 0o700 });
|
|
96
|
+
}
|
|
97
|
+
catch (created) {
|
|
98
|
+
if (asRecord(created)?.code !== "EEXIST")
|
|
99
|
+
throw created;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
info = await lstat(current);
|
|
103
|
+
}
|
|
104
|
+
this.checkDirectory(info, current);
|
|
105
|
+
if (await realpath(current) !== current)
|
|
106
|
+
throw unsafe();
|
|
107
|
+
const handle = await open(current, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
108
|
+
directories.push({ path: current, handle, identity: info });
|
|
109
|
+
if (!same(info, await handle.stat()))
|
|
110
|
+
throw unsafe();
|
|
111
|
+
}
|
|
112
|
+
await this.verify(directories);
|
|
113
|
+
const value = await action(directories);
|
|
114
|
+
await this.verify(directories);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (!(error instanceof CliError) && ["EACCES", "EPERM"].includes(String(asRecord(error)?.code)))
|
|
119
|
+
throw permissionDenied(absolute);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
for (const directory of directories.reverse())
|
|
124
|
+
await directory.handle.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
checkFile(info, max) {
|
|
128
|
+
if (!info.isFile() || info.uid !== this.uid || info.nlink !== 1 || (info.mode & 0o022) !== 0 || info.size > max)
|
|
129
|
+
throw unsafe();
|
|
130
|
+
}
|
|
131
|
+
readFile(name, max, executable = false) {
|
|
132
|
+
const check = (info) => { this.checkFile(info, max); if (executable && (info.mode & 0o100) === 0)
|
|
133
|
+
throw unsafe(); };
|
|
134
|
+
let before;
|
|
135
|
+
try {
|
|
136
|
+
before = lstatSync(name);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (missing(error))
|
|
140
|
+
return null;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
check(before);
|
|
144
|
+
const fd = openSync(name, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
145
|
+
try {
|
|
146
|
+
const opened = fstatSync(fd);
|
|
147
|
+
check(opened);
|
|
148
|
+
if (!unchanged(before, opened))
|
|
149
|
+
throw unsafe();
|
|
150
|
+
const bytes = readDescriptor(fd, max), after = fstatSync(fd), current = lstatSync(name);
|
|
151
|
+
check(after);
|
|
152
|
+
check(current);
|
|
153
|
+
if (bytes.length !== opened.size || !unchanged(opened, after) || !unchanged(after, current))
|
|
154
|
+
throw unsafe();
|
|
155
|
+
return bytes;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async read(path, max = 1048576) {
|
|
162
|
+
return this.directory(dirname(path), false, async (directories) => {
|
|
163
|
+
return pinned(directories.at(-1), () => this.readFile(basename(path), max));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async list(path) {
|
|
167
|
+
return this.directory(path, false, async (directories) => pinned(directories.at(-1), () => readdirSync(".").sort()));
|
|
168
|
+
}
|
|
169
|
+
async createDirectory(path) {
|
|
170
|
+
return this.directory(dirname(path), true, async (directories) => {
|
|
171
|
+
await this.verify(directories);
|
|
172
|
+
return pinned(directories.at(-1), () => {
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(basename(path), { mode: 0o700 });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (asRecord(error)?.code === "EEXIST")
|
|
178
|
+
return null;
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
const result = lstatSync(basename(path));
|
|
182
|
+
this.checkDirectory(result, path);
|
|
183
|
+
return result;
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async writeNew(path, bytes, mode) {
|
|
188
|
+
const result = await this.directory(dirname(path), true, async (directories) => {
|
|
189
|
+
return pinned(directories.at(-1), () => {
|
|
190
|
+
const name = basename(path);
|
|
191
|
+
let fd;
|
|
192
|
+
try {
|
|
193
|
+
fd = openSync(name, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (asRecord(error)?.code !== "EEXIST")
|
|
197
|
+
throw error;
|
|
198
|
+
if (!this.readFile(name, 1048576, (mode & 0o100) !== 0)?.equals(bytes))
|
|
199
|
+
throw conflict();
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const initial = fstatSync(fd);
|
|
204
|
+
this.checkFile(initial, bytes.length);
|
|
205
|
+
if (initial.size !== 0 || (initial.mode & 0o7777) !== 0)
|
|
206
|
+
throw unsafe();
|
|
207
|
+
writeFileSync(fd, bytes);
|
|
208
|
+
fsyncSync(fd);
|
|
209
|
+
const written = fstatSync(fd), current = lstatSync(name);
|
|
210
|
+
this.checkFile(written, bytes.length);
|
|
211
|
+
this.checkFile(current, bytes.length);
|
|
212
|
+
if (!same(initial, written) || !unchanged(written, current) || written.size !== bytes.length || !readDescriptor(fd, bytes.length).equals(bytes))
|
|
213
|
+
throw unsafe();
|
|
214
|
+
fchmodSync(fd, mode);
|
|
215
|
+
fsyncSync(fd);
|
|
216
|
+
const final = fstatSync(fd), published = lstatSync(name);
|
|
217
|
+
this.checkFile(final, bytes.length);
|
|
218
|
+
this.checkFile(published, bytes.length);
|
|
219
|
+
if (!same(initial, final) || !unchanged(final, published) || (final.mode & 0o7777) !== mode || final.size !== bytes.length || final.mtimeMs !== written.mtimeMs
|
|
220
|
+
|| !readDescriptor(fd, bytes.length).equals(bytes) || !unchanged(final, fstatSync(fd)) || !unchanged(final, lstatSync(name)))
|
|
221
|
+
throw unsafe();
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// Only this newly-created inode may be demoted; the pathname may now name somebody else's file.
|
|
226
|
+
try {
|
|
227
|
+
fchmodSync(fd, 0);
|
|
228
|
+
fsyncSync(fd);
|
|
229
|
+
}
|
|
230
|
+
catch { /* Filesystem failure can prevent rollback; keep the installation failed. */ }
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
closeSync(fd);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
return result === true;
|
|
239
|
+
}
|
|
240
|
+
async removeDirectory(path, expected) {
|
|
241
|
+
await this.directory(dirname(path), false, async (directories) => {
|
|
242
|
+
pinned(directories.at(-1), () => {
|
|
243
|
+
const info = lstatSync(basename(path));
|
|
244
|
+
this.checkDirectory(info, path);
|
|
245
|
+
if (!same(info, expected))
|
|
246
|
+
throw unsafe();
|
|
247
|
+
rmdirSync(basename(path));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function readSource(path, max = 1048576) {
|
|
253
|
+
try {
|
|
254
|
+
const parent = dirname(path), identity = await lstat(parent);
|
|
255
|
+
if (!identity.isDirectory() || await realpath(parent) !== parent)
|
|
256
|
+
throw sourceInvalid();
|
|
257
|
+
return pinned({ path: parent, identity }, () => {
|
|
258
|
+
const valid = (info) => { if (!info.isFile() || info.nlink !== 1 || info.size < 1 || info.size > max)
|
|
259
|
+
throw sourceInvalid(); };
|
|
260
|
+
const name = basename(path), before = lstatSync(name);
|
|
261
|
+
valid(before);
|
|
262
|
+
const fd = openSync(name, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
263
|
+
try {
|
|
264
|
+
const opened = fstatSync(fd);
|
|
265
|
+
valid(opened);
|
|
266
|
+
if (!unchanged(before, opened))
|
|
267
|
+
throw sourceInvalid();
|
|
268
|
+
const bytes = readDescriptor(fd, max), after = fstatSync(fd), current = lstatSync(name);
|
|
269
|
+
valid(after);
|
|
270
|
+
valid(current);
|
|
271
|
+
if (bytes.length !== opened.size || !unchanged(opened, after) || !unchanged(after, current))
|
|
272
|
+
throw sourceInvalid();
|
|
273
|
+
return bytes;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
closeSync(fd);
|
|
277
|
+
}
|
|
278
|
+
}, sourceInvalid);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
throw sourceInvalid();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function bundle(ctx, tree) {
|
|
285
|
+
if (!completeSkillInventory(ctx.assets.map(asset => asset.name)) || ctx.assets.some(asset => hash(asset.content) !== asset.sha256 || !asset.content || Buffer.byteLength(asset.content) > 65536))
|
|
286
|
+
throw sourceInvalid();
|
|
287
|
+
const source = resolve(ctx.packageRoot ?? packageRoot());
|
|
288
|
+
if (await realpath(source) !== source)
|
|
289
|
+
throw sourceInvalid();
|
|
290
|
+
const info = asRecord(JSON.parse((await readSource(join(source, "package.json"), 32768)).toString("utf8")));
|
|
291
|
+
if (!info || info.name !== "@yijiancaotang/cli" || (info.private !== true && asRecord(info.publishConfig)?.access !== "public") || typeof info.version !== "string" || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(info.version))
|
|
292
|
+
throw sourceInvalid();
|
|
293
|
+
const runtimeManifest = { name: info.name, version: info.version, private: true, type: "module", engines: { node: ">=20" }, bin: { caotang: "bin/caotang.cjs" } };
|
|
294
|
+
const files = [{ path: "package.json", bytes: Buffer.from(JSON.stringify(runtimeManifest)), mode: 0o600 }, { path: "bin/caotang.cjs", bytes: await readSource(join(source, "bin", "caotang.cjs")), mode: 0o700 }];
|
|
295
|
+
const dist = join(source, "dist");
|
|
296
|
+
if (!(await lstat(dist)).isDirectory() || await realpath(dist) !== dist)
|
|
297
|
+
throw sourceInvalid();
|
|
298
|
+
const modules = (await readdir(dist)).filter(name => name.endsWith(".js")).sort();
|
|
299
|
+
// The shipped runtime has one nested generated dependency. Keep the 64-file
|
|
300
|
+
// JS limit across both categories; do not recursively trust dist subtrees.
|
|
301
|
+
if (!modules.includes("cli.js") || modules.length + 1 > 64)
|
|
302
|
+
throw sourceInvalid();
|
|
303
|
+
for (const name of modules) {
|
|
304
|
+
if (!/^[a-z][a-z0-9-]*\.js$/.test(name))
|
|
305
|
+
throw sourceInvalid();
|
|
306
|
+
files.push({ path: `dist/${name}`, bytes: await readSource(join(dist, name)), mode: 0o600 });
|
|
307
|
+
}
|
|
308
|
+
files.push({ path: "dist/generated/knowledge-growth.js", bytes: await readSource(join(dist, "generated", "knowledge-growth.js")), mode: 0o600 });
|
|
309
|
+
files.push({ path: "skills/manifest.json", bytes: Buffer.from(JSON.stringify({ formatVersion: 1, skills: ctx.assets.map(({ name, description }) => ({ name, description })) })), mode: 0o600 });
|
|
310
|
+
for (const asset of ctx.assets)
|
|
311
|
+
files.push({ path: `skills/${asset.name}/SKILL.md`, bytes: Buffer.from(asset.content), mode: 0o600 });
|
|
312
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
313
|
+
if (files.reduce((total, file) => total + file.bytes.length, 0) > 5242880)
|
|
314
|
+
throw sourceInvalid();
|
|
315
|
+
const digest = createHash("sha256");
|
|
316
|
+
for (const file of files)
|
|
317
|
+
digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
|
|
318
|
+
const sha = digest.digest("hex"), directory = join(tree.home, ".caotang", "cli", "releases", sha), command = join(tree.home, ".local", "bin", "caotang");
|
|
319
|
+
const shim = Buffer.from(`#!/usr/bin/env node\n// caotang managed command v1\nimport(${JSON.stringify(pathToFileURL(join(directory, "bin", "caotang.cjs")).href)}).catch(()=>{process.stderr.write(JSON.stringify({code:"cli_unavailable",message:"本地命令行暂不可用。",next_action:"请人工核对本地安装,不要重复业务写入。"})+"\\n");process.exitCode=7;});\n`);
|
|
320
|
+
return { files, sha, directory, command, shim, version: info.version };
|
|
321
|
+
}
|
|
322
|
+
function validateTargets(ctx, tree) {
|
|
323
|
+
if (ctx.targets.length > PLATFORM_IDS.length || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
|
|
324
|
+
throw unsafe();
|
|
325
|
+
for (const target of ctx.targets) {
|
|
326
|
+
if (!isPlatformId(target.id) || !platformSupported(target.id, ctx.operatingSystem) || !isAbsolute(target.skillDirectory) || tree.within(target.skillDirectory) === tree.home)
|
|
327
|
+
throw unsafe();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async function skillState(tree, target, asset) {
|
|
331
|
+
const dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
|
|
332
|
+
if (entries === null || entries.length === 0)
|
|
333
|
+
return "missing";
|
|
334
|
+
if (entries.length !== 1 || entries[0] !== "SKILL.md")
|
|
335
|
+
return "modified";
|
|
336
|
+
const bytes = await tree.read(join(dir, "SKILL.md"), 65536);
|
|
337
|
+
return bytes && hash(bytes) === asset.sha256 ? "installed" : "modified";
|
|
338
|
+
}
|
|
339
|
+
async function bundleState(tree, value) {
|
|
340
|
+
if (await tree.list(value.directory) === null)
|
|
341
|
+
return "missing";
|
|
342
|
+
const expected = new Set(value.files.map(file => file.path));
|
|
343
|
+
async function walk(dir, prefix = "") {
|
|
344
|
+
const entries = await tree.list(dir);
|
|
345
|
+
if (!entries)
|
|
346
|
+
return false;
|
|
347
|
+
for (const name of entries) {
|
|
348
|
+
const path = prefix + name;
|
|
349
|
+
if (value.files.some(file => file.path.startsWith(path + "/"))) {
|
|
350
|
+
if (!await walk(join(dir, name), path + "/"))
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
else if (!expected.has(path))
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
if (!await walk(value.directory))
|
|
359
|
+
return "modified";
|
|
360
|
+
for (const file of value.files) {
|
|
361
|
+
const bytes = await tree.read(join(value.directory, file.path));
|
|
362
|
+
if (!bytes?.equals(file.bytes))
|
|
363
|
+
return "modified";
|
|
364
|
+
}
|
|
365
|
+
return "installed";
|
|
366
|
+
}
|
|
367
|
+
async function onPath(command, env) {
|
|
368
|
+
for (const entry of (env.PATH ?? "").split(delimiter)) {
|
|
369
|
+
if (!isAbsolute(entry))
|
|
370
|
+
continue;
|
|
371
|
+
try {
|
|
372
|
+
if (await realpath(join(entry, "caotang")) === command && ((await stat(command)).mode & 0o100) !== 0) {
|
|
373
|
+
await access(command, constants.X_OK);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch { }
|
|
378
|
+
}
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
export async function installLocal(ctx) {
|
|
382
|
+
const tree = new OwnedTree(ctx.homeDir), changed = [];
|
|
383
|
+
let changesPossible = false;
|
|
384
|
+
const newlyInstalledPlatforms = [];
|
|
385
|
+
try {
|
|
386
|
+
validateTargets(ctx, tree);
|
|
387
|
+
if (!ctx.targets.length && !ctx.agentName)
|
|
388
|
+
throw new CliError("no_supported_platform", "未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy后再接入;不会自动安装客户端。");
|
|
389
|
+
const value = await bundle(ctx, tree);
|
|
390
|
+
await tree.directory(tree.home, false, async () => { });
|
|
391
|
+
for (const target of ctx.targets)
|
|
392
|
+
for (const asset of ctx.assets)
|
|
393
|
+
if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) === "modified")
|
|
394
|
+
throw skillConflict();
|
|
395
|
+
const existingCommand = await tree.read(value.command);
|
|
396
|
+
if (existingCommand && !existingCommand.equals(value.shim))
|
|
397
|
+
throw conflict();
|
|
398
|
+
if (existingCommand && ((await lstat(value.command)).mode & 0o100) === 0)
|
|
399
|
+
throw unsafe();
|
|
400
|
+
if (await bundleState(tree, value) === "modified")
|
|
401
|
+
throw conflict();
|
|
402
|
+
const lockPath = join(tree.home, ".caotang", ".setup.lock");
|
|
403
|
+
changesPossible = true;
|
|
404
|
+
const release = await tree.directory(dirname(lockPath), true, async (directories) => acquireLocalLock(lockPath, directories.at(-1), {
|
|
405
|
+
unsafe,
|
|
406
|
+
busy: () => new CliError("installation_busy", "另一项本地安装正在进行或锁状态无法确认。", "等待本地安装结束;状态不明时保留锁,由你人工核查,不自动删除。"),
|
|
407
|
+
}));
|
|
408
|
+
if (!release)
|
|
409
|
+
throw unsafe();
|
|
410
|
+
try {
|
|
411
|
+
const state = await bundleState(tree, value);
|
|
412
|
+
if (state === "modified")
|
|
413
|
+
throw conflict();
|
|
414
|
+
for (const target of ctx.targets) {
|
|
415
|
+
const statuses = await Promise.all(ctx.assets.map(asset => skillState(tree, target, bindSkillAgent(asset, ctx.agentName))));
|
|
416
|
+
if (statuses.includes("modified"))
|
|
417
|
+
throw skillConflict();
|
|
418
|
+
if (statuses.every(status => status === "missing"))
|
|
419
|
+
newlyInstalledPlatforms.push(target.id);
|
|
420
|
+
}
|
|
421
|
+
if (state === "missing") {
|
|
422
|
+
const created = await tree.createDirectory(value.directory);
|
|
423
|
+
if (!created)
|
|
424
|
+
throw conflict();
|
|
425
|
+
changed.push("bundle");
|
|
426
|
+
for (const file of value.files)
|
|
427
|
+
await tree.writeNew(join(value.directory, file.path), file.bytes, file.mode);
|
|
428
|
+
if (await bundleState(tree, value) !== "installed")
|
|
429
|
+
throw conflict();
|
|
430
|
+
}
|
|
431
|
+
for (const target of ctx.targets)
|
|
432
|
+
for (const asset of ctx.assets) {
|
|
433
|
+
if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) === "installed")
|
|
434
|
+
continue;
|
|
435
|
+
const dir = join(target.skillDirectory, asset.name), created = await tree.createDirectory(dir);
|
|
436
|
+
if (!created && (await tree.list(dir))?.length !== 0)
|
|
437
|
+
throw skillConflict();
|
|
438
|
+
changed.push(`${target.id}:${asset.name}`);
|
|
439
|
+
await tree.writeNew(join(dir, "SKILL.md"), Buffer.from(bindSkillAgent(asset, ctx.agentName).content), 0o600);
|
|
440
|
+
if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) !== "installed")
|
|
441
|
+
throw skillConflict();
|
|
442
|
+
}
|
|
443
|
+
if (await tree.writeNew(value.command, value.shim, 0o700))
|
|
444
|
+
changed.push("command");
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
release();
|
|
448
|
+
}
|
|
449
|
+
return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms };
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
const safe = error instanceof CliError ? error
|
|
453
|
+
: ["EPERM", "EACCES"].includes(String(asRecord(error)?.code)) ? permissionDenied(join(tree.home, ".caotang")) : unsafe();
|
|
454
|
+
throw new CliError(safe.code, safe.message, safe.nextAction, safe.exitCode, { ...safe.details, local_changes_possible: changesPossible, local_changes: changed });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
export async function inspectLocalInstallation(ctx) {
|
|
458
|
+
const tree = new OwnedTree(ctx.homeDir);
|
|
459
|
+
validateTargets(ctx, tree);
|
|
460
|
+
const value = await bundle(ctx, tree), issues = [];
|
|
461
|
+
const issue = (code, message, next_action) => issues.push({ code, message, next_action });
|
|
462
|
+
let installed = false;
|
|
463
|
+
const current = await tree.read(value.command);
|
|
464
|
+
if (!current)
|
|
465
|
+
issue("command_not_installed", "稳定命令行尚未安装。", "运行 setup 完成本地安装。");
|
|
466
|
+
else if (!current.equals(value.shim) || await bundleState(tree, value) !== "installed" || ((await lstat(value.command)).mode & 0o100) === 0)
|
|
467
|
+
issue("command_modified", "已有命令行与当前候选不一致。", "保留现有文件并人工核对,不自动覆盖。");
|
|
468
|
+
else
|
|
469
|
+
installed = true;
|
|
470
|
+
const on_path = installed && await onPath(value.command, ctx.env);
|
|
471
|
+
if (!ctx.targets.some(target => target.detected))
|
|
472
|
+
issue("no_supported_platform", "尚未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy程序入口,再运行doctor。");
|
|
473
|
+
const platforms = [];
|
|
474
|
+
for (const target of ctx.targets) {
|
|
475
|
+
const skills = [];
|
|
476
|
+
for (const asset of ctx.assets) {
|
|
477
|
+
let status;
|
|
478
|
+
try {
|
|
479
|
+
status = await skillState(tree, target, bindSkillAgent(asset, ctx.agentName));
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
status = "unsafe";
|
|
483
|
+
}
|
|
484
|
+
skills.push({ name: asset.name, status });
|
|
485
|
+
if (target.detected && status !== "installed")
|
|
486
|
+
issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? `运行 setup --platform ${target.id} 安装缺失技能。` : "保留现有技能并人工核对,不自动覆盖或修复。");
|
|
487
|
+
}
|
|
488
|
+
platforms.push({ id: target.id, detected: target.detected, skill_directory: target.skillDirectory, skills });
|
|
489
|
+
}
|
|
490
|
+
return { command: { installed, on_path, version: value.version, ...(installed ? { executable_path: value.command } : {}) }, platforms, issues };
|
|
491
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { knowledgeGrowthMetadata } from "./generated/knowledge-growth.js";
|
|
2
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
3
|
+
// Presentation only: the API remains the source of enums and read projections.
|
|
4
|
+
// No recursive status search, fallback enum, request, or permission expansion.
|
|
5
|
+
function readable(row, parent, event = false) {
|
|
6
|
+
for (const scope of [parent, row]) {
|
|
7
|
+
const value = scope.missing_sections;
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
continue;
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return false;
|
|
12
|
+
for (const item of value) {
|
|
13
|
+
const missing = record(item);
|
|
14
|
+
if (!missing)
|
|
15
|
+
return false;
|
|
16
|
+
const capability = missing.required_capability ?? missing.capability;
|
|
17
|
+
if (capability === "read_knowledge")
|
|
18
|
+
return false;
|
|
19
|
+
// Daily items are already visibility-filtered; an aggregate draft gap
|
|
20
|
+
// describes excluded records, not the readable event returned here.
|
|
21
|
+
if (capability === "read_drafts" && row.publication_status !== "published" && !(event && scope === parent))
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
function card(value, parent) {
|
|
28
|
+
const row = record(value);
|
|
29
|
+
if (!row || typeof row.id !== "string" || !row.id || !readable(row, parent))
|
|
30
|
+
return value;
|
|
31
|
+
const growth = knowledgeGrowthMetadata(row.internalization_status);
|
|
32
|
+
return growth ? { ...row, growth } : value;
|
|
33
|
+
}
|
|
34
|
+
function field(body, key, list = false) {
|
|
35
|
+
const value = body[key];
|
|
36
|
+
const mapped = list && Array.isArray(value) ? value.map(row => card(row, body)) : list ? value : card(value, body);
|
|
37
|
+
return mapped === value || Array.isArray(value) && Array.isArray(mapped) && value.every((row, i) => row === mapped[i])
|
|
38
|
+
? body : { ...body, [key]: mapped };
|
|
39
|
+
}
|
|
40
|
+
export function withKnowledgeGrowth(path, value, status) {
|
|
41
|
+
const body = record(value);
|
|
42
|
+
if (!body || ![200, 201].includes(status))
|
|
43
|
+
return value;
|
|
44
|
+
const route = path.split("?")[0].replace(/^\/api\/v1\/agent/, "");
|
|
45
|
+
if (route === "/m1/knowledge")
|
|
46
|
+
return field(body, "items", true);
|
|
47
|
+
if (/^\/m1\/knowledge\/[^/]+$/.test(route))
|
|
48
|
+
return field(body, "card");
|
|
49
|
+
if (/^\/m1\/projects\/[^/]+$/.test(route))
|
|
50
|
+
return field(body, "knowledge", true);
|
|
51
|
+
if (route === "/m1/workbench")
|
|
52
|
+
return field(body, "recent_knowledge", true);
|
|
53
|
+
if (route === "/m1/activity/daily" && Array.isArray(body.items)) {
|
|
54
|
+
let changed = false;
|
|
55
|
+
const items = body.items.map(value => {
|
|
56
|
+
const row = record(value);
|
|
57
|
+
if (!row || row.kind !== "knowledge_growth" || typeof row.id !== "string" || !row.id
|
|
58
|
+
|| typeof row.knowledge_card_id !== "string" || !row.knowledge_card_id || !readable(row, body, true))
|
|
59
|
+
return value;
|
|
60
|
+
const from = knowledgeGrowthMetadata(row.from_status), to = knowledgeGrowthMetadata(row.to_status);
|
|
61
|
+
if (!from || !to)
|
|
62
|
+
return value;
|
|
63
|
+
changed = true;
|
|
64
|
+
return { ...row, from_growth: from, to_growth: to };
|
|
65
|
+
});
|
|
66
|
+
return changed ? { ...body, items } : value;
|
|
67
|
+
}
|
|
68
|
+
if (/^\/m2\/knowledge-cards(?:\/[^/]+(?:\/project-link(?:\/[^/]+)?)?)?$/.test(route)
|
|
69
|
+
|| /^\/m2\/knowledge-cards\/[^/]+\/stage$/.test(route)) {
|
|
70
|
+
if (body.original_receipt === true || body.write_status !== undefined && body.write_status !== "succeeded"
|
|
71
|
+
|| body.moderation_pending === true || body.write_pending === true || body.moderation_status === "pending_review"
|
|
72
|
+
|| ["failed", "rejected", "unknown", "drafted"].includes(String(body.action_status))
|
|
73
|
+
|| ["failed", "rejected", "unknown", "drafted"].includes(String(record(body.action_log)?.action_status)))
|
|
74
|
+
return value;
|
|
75
|
+
if (route.endsWith("/stage")) {
|
|
76
|
+
const detail = record(body.knowledge);
|
|
77
|
+
if (!detail || !readable(detail, body))
|
|
78
|
+
return value;
|
|
79
|
+
const knowledge = field(detail, "card");
|
|
80
|
+
return knowledge === detail ? value : { ...body, knowledge };
|
|
81
|
+
}
|
|
82
|
+
return field(body, "knowledge_card");
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|