@yijiancaotang/cli 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -26
- package/dist/auth.js +7 -4
- package/dist/calendar-contract.js +358 -0
- package/dist/calendar-help.js +12 -0
- package/dist/calendar.js +64 -0
- package/dist/capability-result.js +5 -0
- package/dist/cli.js +58 -10
- package/dist/command-help.js +18 -8
- package/dist/commands.js +11 -0
- package/dist/doctor.js +13 -5
- package/dist/generated/agent-operations.js +12 -0
- package/dist/habit-read.js +3 -1
- package/dist/habit-wire.js +36 -5
- package/dist/handoff-read.js +29 -0
- package/dist/handoff-wire.js +42 -0
- package/dist/http.js +11 -1
- package/dist/input-validation.js +14 -0
- package/dist/installation.js +334 -45
- package/dist/local-commands.js +133 -16
- package/dist/local-records.js +150 -0
- package/dist/local-types.js +1 -0
- package/dist/managed-skill-baseline.js +35 -0
- package/dist/missing-sections.js +1 -0
- package/dist/onboarding.js +4 -20
- package/dist/output.js +7 -1
- package/dist/platforms.js +69 -12
- package/dist/project-binding-cli.js +110 -0
- package/dist/project-binding.js +370 -0
- package/dist/query-search.js +3 -2
- package/dist/reviewed-managed-bundle-baseline.js +356 -0
- package/dist/schedule-contract.js +289 -0
- package/dist/schedule-help.js +35 -0
- package/dist/schedule-start-wire.js +80 -0
- package/dist/schedule.js +70 -0
- package/dist/setup-guidance.js +40 -0
- package/dist/skill-assets.js +1 -1
- package/dist/skills-export.js +206 -0
- package/dist/web-citations.js +1 -1
- package/dist/work-context-human.js +67 -0
- package/dist/work-context-wire.js +391 -0
- package/dist/write-routing.js +26 -0
- package/package.json +2 -2
- package/skills/caotang-context/SKILL.md +20 -7
- package/skills/caotang-first-connect/SKILL.md +48 -34
- package/skills/caotang-project-progress/SKILL.md +6 -2
- package/skills/caotang-save-knowledge/SKILL.md +6 -4
- package/skills/caotang-schedule/SKILL.md +54 -0
- package/skills/caotang-search/SKILL.md +11 -0
- package/skills/caotang-workday/SKILL.md +56 -9
- package/skills/manifest.json +28 -6
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { verifiedHabit } from "./habit-wire.js";
|
|
2
|
+
import { validDate } from "./query-validation.js";
|
|
3
|
+
const MAX_HANDOFF_BYTES = 64 * 1024;
|
|
4
|
+
const deferredSections = [
|
|
5
|
+
"project_overview", "project_next_steps", "knowledge_index",
|
|
6
|
+
"recent_connections", "conditional_reminders", "project_selection"
|
|
7
|
+
];
|
|
8
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
9
|
+
function utcTimestamp(value) {
|
|
10
|
+
if (typeof value !== "string")
|
|
11
|
+
return false;
|
|
12
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/.exec(value);
|
|
13
|
+
return !!match && validDate(match[1])
|
|
14
|
+
&& Number(match[2]) <= 23 && Number(match[3]) <= 59 && Number(match[4]) <= 59;
|
|
15
|
+
}
|
|
16
|
+
export function verifiedHandoff(value) {
|
|
17
|
+
try {
|
|
18
|
+
if (Buffer.byteLength(JSON.stringify(value) ?? "", "utf8") > MAX_HANDOFF_BYTES)
|
|
19
|
+
return;
|
|
20
|
+
const body = record(value);
|
|
21
|
+
if (!body || body.schema_version !== 1 || body.mode !== "handoff"
|
|
22
|
+
|| body.coverage !== "existing_habit_sections" || !utcTimestamp(body.as_of))
|
|
23
|
+
return;
|
|
24
|
+
const deferred = body.deferred_sections;
|
|
25
|
+
if (!Array.isArray(deferred) || deferred.length !== deferredSections.length
|
|
26
|
+
|| new Set(deferred).size !== deferredSections.length
|
|
27
|
+
|| deferred.some(section => !deferredSections.includes(section)))
|
|
28
|
+
return;
|
|
29
|
+
const context = verifiedHabit(body.context, true);
|
|
30
|
+
if (!context || typeof context.owner_id !== "string" || !Array.isArray(context.missing_sections)
|
|
31
|
+
|| context.missing_sections.some(gap => record(gap)?.section === "calendar_start" || record(gap)?.reason === "record_failed"))
|
|
32
|
+
return;
|
|
33
|
+
return {
|
|
34
|
+
schema_version: 1, mode: "handoff", as_of: body.as_of,
|
|
35
|
+
coverage: "existing_habit_sections", context,
|
|
36
|
+
deferred_sections: [...deferredSections]
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/http.js
CHANGED
|
@@ -3,6 +3,8 @@ import { isPagedQueryPath, isQueryParameterError, isProjectIDParameterError } fr
|
|
|
3
3
|
import { isSearchParameterError } from "./query-search.js";
|
|
4
4
|
import { isDailyParameterError } from "./query-daily.js";
|
|
5
5
|
import { isCreatePath } from "./create-bundle.js";
|
|
6
|
+
import { isScheduleHeaderPath, scheduleHeaders } from "./schedule-contract.js";
|
|
7
|
+
import { isCalendarHeaderPath, calendarHeaders } from "./calendar-contract.js";
|
|
6
8
|
const maxJSONBytes = 4 * 1024 * 1024;
|
|
7
9
|
const invalidResponse = () => new CliError("invalid_response", "一间草堂返回了无法确认的响应。", "请先核对网页中的实际结果,不要自动重复写入。", 7);
|
|
8
10
|
const invalidRequest = () => new CliError("invalid_request", "请求参数无效。", "请检查命令帮助和输入数据。");
|
|
@@ -100,7 +102,15 @@ export class ApiClient {
|
|
|
100
102
|
}
|
|
101
103
|
if (options.body && typeof options.body === "object" && "device_code" in options.body && typeof options.body.device_code === "string")
|
|
102
104
|
this.secrets.add(options.body.device_code);
|
|
103
|
-
if (
|
|
105
|
+
if (isScheduleHeaderPath(url.pathname, method)) {
|
|
106
|
+
for (const [name, value] of Object.entries(scheduleHeaders(url.pathname, method, options.headers)))
|
|
107
|
+
headers.set(name, value);
|
|
108
|
+
}
|
|
109
|
+
else if (isCalendarHeaderPath(url.pathname, method)) {
|
|
110
|
+
for (const [name, value] of Object.entries(calendarHeaders(url.pathname, method, options.headers)))
|
|
111
|
+
headers.set(name, value);
|
|
112
|
+
}
|
|
113
|
+
else if (options.headers !== undefined) {
|
|
104
114
|
if (!options.headers || Object.keys(options.headers).some((key) => key !== "Idempotency-Key"))
|
|
105
115
|
throw invalidRequest();
|
|
106
116
|
const key = options.headers["Idempotency-Key"];
|
package/dist/input-validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validWorkContextProjectID } from "./work-context-wire.js";
|
|
1
2
|
import { asRecord, CliError } from "./errors.js";
|
|
2
3
|
import { commandHelp } from "./command-help.js";
|
|
3
4
|
export const trimGoSpace = (value) => value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, "");
|
|
@@ -63,8 +64,21 @@ export function validateCommandArguments(command, flags) {
|
|
|
63
64
|
return;
|
|
64
65
|
if (Object.entries(flags).some(([key, value]) => value !== undefined && !help.options.includes(`--${key}`)))
|
|
65
66
|
throw flagError(command);
|
|
67
|
+
if (help.command === "context" && flags.handoff === true && Object.keys(flags).some(key => key !== "handoff" && key !== "project" && flags[key] !== undefined))
|
|
68
|
+
throw flagError(command);
|
|
69
|
+
if (help.command === "context" && flags.project !== undefined && (flags.handoff !== true || !validWorkContextProjectID(flags.project)))
|
|
70
|
+
throw invalidInput(command, { invalid: ["--project"] });
|
|
66
71
|
if (help.command === "context" && flags.start === true && Object.keys(flags).some(key => key !== "start" && flags[key] !== undefined))
|
|
67
72
|
throw flagError(command);
|
|
73
|
+
if (["project bind", "project binding", "project unbind"].includes(help.command)) {
|
|
74
|
+
if (flags.confirm !== undefined && (typeof flags.confirm !== "string" || !/^[a-f0-9]{64}$/.test(flags.confirm)))
|
|
75
|
+
throw invalidInput(command, { invalid: ["--confirm"] });
|
|
76
|
+
for (const flag of ["local-workspace", "include-subdirs"])
|
|
77
|
+
if (flags[flag] !== undefined && flags[flag] !== true)
|
|
78
|
+
throw flagError(command);
|
|
79
|
+
if (help.command === "project bind" && command.length >= 3 && !validWorkContextProjectID(command[2]))
|
|
80
|
+
throw invalidInput(command, { invalid: ["<id>"] });
|
|
81
|
+
}
|
|
68
82
|
const count = (help.usage.match(/<[^>]+>/g) ?? []).length;
|
|
69
83
|
const words = help.command.split(" ").length;
|
|
70
84
|
const positionalCount = help.command === "upload" ? 1 : count;
|
package/dist/installation.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
|
|
1
|
+
import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, ftruncateSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
|
|
2
2
|
import { access, lstat, stat, realpath, open, readdir } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { CliError, asRecord } from "./errors.js";
|
|
7
|
-
import { PLATFORM_IDS,
|
|
7
|
+
import { PLATFORM_IDS, isSkillTargetId, platformSupported } from "./local-types.js";
|
|
8
8
|
import { bindSkillAgent, completeSkillInventory } from "./skill-assets.js";
|
|
9
9
|
import { acquireLocalLock } from "./local-lock.js";
|
|
10
|
+
import { LEGACY_MANAGED_SKILLS } from "./managed-skill-baseline.js";
|
|
11
|
+
import { REVIEWED_MANAGED_BUNDLE_1_1_0 } from "./reviewed-managed-bundle-baseline.js";
|
|
10
12
|
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
11
13
|
const missing = (error) => asRecord(error)?.code === "ENOENT";
|
|
12
14
|
const unsafe = () => new CliError("installation_unsafe", "本地安装位置未通过安全检查。", "请人工检查属主、权限、符号链接与目标目录;不要自动删除、覆盖或修改凭据。");
|
|
@@ -128,8 +130,8 @@ export class OwnedTree {
|
|
|
128
130
|
if (!info.isFile() || info.uid !== this.uid || info.nlink !== 1 || (info.mode & 0o022) !== 0 || info.size > max)
|
|
129
131
|
throw unsafe();
|
|
130
132
|
}
|
|
131
|
-
readFile(name, max, executable = false) {
|
|
132
|
-
const check = (info) => { this.checkFile(info, max); if (executable && (info.mode & 0o100) === 0)
|
|
133
|
+
readFile(name, max, executable = false, mode) {
|
|
134
|
+
const check = (info) => { this.checkFile(info, max); if (executable && (info.mode & 0o100) === 0 || mode !== undefined && (info.mode & 0o7777) !== mode)
|
|
133
135
|
throw unsafe(); };
|
|
134
136
|
let before;
|
|
135
137
|
try {
|
|
@@ -158,9 +160,9 @@ export class OwnedTree {
|
|
|
158
160
|
closeSync(fd);
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
|
-
async read(path, max = 1048576) {
|
|
163
|
+
async read(path, max = 1048576, mode) {
|
|
162
164
|
return this.directory(dirname(path), false, async (directories) => {
|
|
163
|
-
return pinned(directories.at(-1), () => this.readFile(basename(path), max));
|
|
165
|
+
return pinned(directories.at(-1), () => this.readFile(basename(path), max, false, mode));
|
|
164
166
|
});
|
|
165
167
|
}
|
|
166
168
|
async list(path) {
|
|
@@ -237,6 +239,43 @@ export class OwnedTree {
|
|
|
237
239
|
});
|
|
238
240
|
return result === true;
|
|
239
241
|
}
|
|
242
|
+
// Never truncate on open. Only an exact, single-link managed inode may change;
|
|
243
|
+
// the caller durably preserves its public before/after bytes before calling.
|
|
244
|
+
async replaceManaged(entry, rollback = false) {
|
|
245
|
+
const result = await this.directory(dirname(entry.path), false, async (directories) => pinned(directories.at(-1), () => {
|
|
246
|
+
const name = basename(entry.path), before = lstatSync(name);
|
|
247
|
+
const check = (info) => { this.checkFile(info, 1048576); if ((info.mode & 0o7777) !== entry.mode)
|
|
248
|
+
throw unsafe(); };
|
|
249
|
+
check(before);
|
|
250
|
+
const fd = openSync(name, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
251
|
+
try {
|
|
252
|
+
const opened = fstatSync(fd);
|
|
253
|
+
check(opened);
|
|
254
|
+
if (!unchanged(before, opened) || rollback && (!entry.identity || !same(entry.identity, opened)))
|
|
255
|
+
throw unsafe();
|
|
256
|
+
const expected = rollback ? entry.after : entry.before, next = rollback ? entry.before : entry.after;
|
|
257
|
+
if (!readDescriptor(fd, 1048576).equals(expected) || !unchanged(opened, fstatSync(fd)) || !unchanged(opened, lstatSync(name)))
|
|
258
|
+
throw unsafe();
|
|
259
|
+
if (!rollback)
|
|
260
|
+
entry.identity = opened;
|
|
261
|
+
writeFileSync(fd, next);
|
|
262
|
+
ftruncateSync(fd, next.length);
|
|
263
|
+
fsyncSync(fd);
|
|
264
|
+
const written = fstatSync(fd), named = lstatSync(name);
|
|
265
|
+
check(written);
|
|
266
|
+
check(named);
|
|
267
|
+
if (!same(opened, written) || !unchanged(written, named) || written.size !== next.length || !readDescriptor(fd, 1048576).equals(next)
|
|
268
|
+
|| !unchanged(written, fstatSync(fd)) || !unchanged(written, lstatSync(name)))
|
|
269
|
+
throw unsafe();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
closeSync(fd);
|
|
274
|
+
}
|
|
275
|
+
}));
|
|
276
|
+
if (result !== true)
|
|
277
|
+
throw unsafe();
|
|
278
|
+
}
|
|
240
279
|
async removeDirectory(path, expected) {
|
|
241
280
|
await this.directory(dirname(path), false, async (directories) => {
|
|
242
281
|
pinned(directories.at(-1), () => {
|
|
@@ -281,6 +320,9 @@ async function readSource(path, max = 1048576) {
|
|
|
281
320
|
throw sourceInvalid();
|
|
282
321
|
}
|
|
283
322
|
}
|
|
323
|
+
function managedShim(directory) {
|
|
324
|
+
return 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`);
|
|
325
|
+
}
|
|
284
326
|
async function bundle(ctx, tree) {
|
|
285
327
|
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
328
|
throw sourceInvalid();
|
|
@@ -296,16 +338,35 @@ async function bundle(ctx, tree) {
|
|
|
296
338
|
if (!(await lstat(dist)).isDirectory() || await realpath(dist) !== dist)
|
|
297
339
|
throw sourceInvalid();
|
|
298
340
|
const modules = (await readdir(dist)).filter(name => name.endsWith(".js")).sort();
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
if (!modules.includes("cli.js")
|
|
341
|
+
// Only reviewed generated dependencies may enter a stable runtime bundle.
|
|
342
|
+
// Old fixtures/releases with no operations import retain the original closure.
|
|
343
|
+
if (!modules.includes("cli.js"))
|
|
302
344
|
throw sourceInvalid();
|
|
303
345
|
for (const name of modules) {
|
|
304
346
|
if (!/^[a-z][a-z0-9-]*\.js$/.test(name))
|
|
305
347
|
throw sourceInvalid();
|
|
306
348
|
files.push({ path: `dist/${name}`, bytes: await readSource(join(dist, name)), mode: 0o600 });
|
|
307
349
|
}
|
|
308
|
-
|
|
350
|
+
const generatedModules = ["knowledge-growth.js"];
|
|
351
|
+
const operationsPath = join(dist, "generated", "agent-operations.js");
|
|
352
|
+
let hasOperations = false;
|
|
353
|
+
try {
|
|
354
|
+
await lstat(operationsPath);
|
|
355
|
+
hasOperations = true;
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (!missing(error))
|
|
359
|
+
throw sourceInvalid();
|
|
360
|
+
}
|
|
361
|
+
const requiresOperations = files.some(file => file.path.startsWith("dist/") && file.bytes.toString("utf8").includes("generated/agent-operations.js"));
|
|
362
|
+
if (requiresOperations && !hasOperations)
|
|
363
|
+
throw sourceInvalid();
|
|
364
|
+
if (hasOperations)
|
|
365
|
+
generatedModules.push("agent-operations.js");
|
|
366
|
+
if (modules.length + generatedModules.length > 64)
|
|
367
|
+
throw sourceInvalid();
|
|
368
|
+
for (const name of generatedModules)
|
|
369
|
+
files.push({ path: `dist/generated/${name}`, bytes: await readSource(join(dist, "generated", name)), mode: 0o600 });
|
|
309
370
|
files.push({ path: "skills/manifest.json", bytes: Buffer.from(JSON.stringify({ formatVersion: 1, skills: ctx.assets.map(({ name, description }) => ({ name, description })) })), mode: 0o600 });
|
|
310
371
|
for (const asset of ctx.assets)
|
|
311
372
|
files.push({ path: `skills/${asset.name}/SKILL.md`, bytes: Buffer.from(asset.content), mode: 0o600 });
|
|
@@ -316,14 +377,14 @@ async function bundle(ctx, tree) {
|
|
|
316
377
|
for (const file of files)
|
|
317
378
|
digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
|
|
318
379
|
const sha = digest.digest("hex"), directory = join(tree.home, ".caotang", "cli", "releases", sha), command = join(tree.home, ".local", "bin", "caotang");
|
|
319
|
-
const shim =
|
|
380
|
+
const shim = managedShim(directory);
|
|
320
381
|
return { files, sha, directory, command, shim, version: info.version };
|
|
321
382
|
}
|
|
322
383
|
function validateTargets(ctx, tree) {
|
|
323
|
-
if (ctx.targets.length > PLATFORM_IDS.length || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
|
|
384
|
+
if (ctx.targets.length > PLATFORM_IDS.length + 1 || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
|
|
324
385
|
throw unsafe();
|
|
325
386
|
for (const target of ctx.targets) {
|
|
326
|
-
if (!
|
|
387
|
+
if (!isSkillTargetId(target.id) || target.id !== "generic" && !platformSupported(target.id, ctx.operatingSystem) || !isAbsolute(target.skillDirectory) || tree.within(target.skillDirectory) === tree.home)
|
|
327
388
|
throw unsafe();
|
|
328
389
|
}
|
|
329
390
|
}
|
|
@@ -378,25 +439,160 @@ async function onPath(command, env) {
|
|
|
378
439
|
}
|
|
379
440
|
return false;
|
|
380
441
|
}
|
|
442
|
+
// The fixed 1.1.0 baseline is admitted only after its complete public runtime
|
|
443
|
+
// inventory verifies. Never infer provenance from a version or receipt.
|
|
444
|
+
async function reviewedV110Bundle(tree, value) {
|
|
445
|
+
const pin = REVIEWED_MANAGED_BUNDLE_1_1_0, directory = join(tree.home, ".caotang", "cli", "releases", pin.sha);
|
|
446
|
+
const children = new Map([["", new Set()]]);
|
|
447
|
+
for (const file of pin.files) {
|
|
448
|
+
const parts = file.path.split("/");
|
|
449
|
+
let parent = "";
|
|
450
|
+
for (const [index, part] of parts.entries()) {
|
|
451
|
+
children.get(parent).add(part);
|
|
452
|
+
if (index < parts.length - 1) {
|
|
453
|
+
parent = parent ? `${parent}/${part}` : part;
|
|
454
|
+
if (!children.has(parent))
|
|
455
|
+
children.set(parent, new Set());
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const verifyInventory = async () => {
|
|
460
|
+
for (const [path, expected] of children)
|
|
461
|
+
if ((await tree.list(join(directory, path)))?.join(",") !== [...expected].sort().join(","))
|
|
462
|
+
throw conflict();
|
|
463
|
+
};
|
|
464
|
+
await verifyInventory();
|
|
465
|
+
const files = [], digest = createHash("sha256");
|
|
466
|
+
for (const file of pin.files) {
|
|
467
|
+
const bytes = await tree.read(join(directory, file.path), file.bytes, file.mode);
|
|
468
|
+
if (!bytes || bytes.length !== file.bytes || hash(bytes) !== file.sha256)
|
|
469
|
+
throw conflict();
|
|
470
|
+
files.push({ path: file.path, bytes, mode: file.mode });
|
|
471
|
+
digest.update(`${file.path}\0${file.mode}\0${bytes.length}\0`).update(bytes);
|
|
472
|
+
}
|
|
473
|
+
if (digest.digest("hex") !== pin.sha)
|
|
474
|
+
throw conflict();
|
|
475
|
+
await verifyInventory();
|
|
476
|
+
const manifest = JSON.parse(files.find(file => file.path === "skills/manifest.json").bytes.toString("utf8"));
|
|
477
|
+
if (!completeSkillInventory(manifest.skills.map(item => item.name)))
|
|
478
|
+
throw conflict();
|
|
479
|
+
const assets = manifest.skills.map(item => { const bytes = files.find(file => file.path === `skills/${item.name}/SKILL.md`).bytes; return { ...item, content: bytes.toString("utf8"), sha256: hash(bytes) }; });
|
|
480
|
+
return { bundle: { files, sha: pin.sha, version: pin.version, directory, command: value.command, shim: managedShim(directory) }, assets };
|
|
481
|
+
}
|
|
482
|
+
// Keep the reviewed 1.0.0 six-skill baseline unchanged. A directory digest alone
|
|
483
|
+
// cannot authorize replacing somebody's same-name skill.
|
|
484
|
+
async function legacyBundles(ctx, tree, value) {
|
|
485
|
+
if (!["1.0.1", "1.1.0", "1.1.1", "1.2.0"].includes(value.version) || ctx.agentName === undefined)
|
|
486
|
+
return [];
|
|
487
|
+
const releases = join(tree.home, ".caotang", "cli", "releases"), entries = await tree.list(releases) ?? [];
|
|
488
|
+
if (entries.length > 128)
|
|
489
|
+
throw unsafe();
|
|
490
|
+
const found = [];
|
|
491
|
+
const runtime = Buffer.from(JSON.stringify({ name: "@yijiancaotang/cli", version: "1.0.0", private: true, type: "module", engines: { node: ">=20" }, bin: { caotang: "bin/caotang.cjs" } }));
|
|
492
|
+
const manifest = Buffer.from(JSON.stringify({ formatVersion: 1, skills: LEGACY_MANAGED_SKILLS.map(({ name, description }) => ({ name, description })) }));
|
|
493
|
+
for (const sha of entries) {
|
|
494
|
+
if (!/^[a-f0-9]{64}$/.test(sha) || sha === value.sha)
|
|
495
|
+
continue;
|
|
496
|
+
const directory = join(releases, sha), packageBytes = await tree.read(join(directory, "package.json"), 32768, 0o600);
|
|
497
|
+
if (!packageBytes?.equals(runtime))
|
|
498
|
+
continue;
|
|
499
|
+
const root = await tree.list(directory), bin = await tree.list(join(directory, "bin")), dist = await tree.list(join(directory, "dist"));
|
|
500
|
+
const skills = await tree.list(join(directory, "skills")), generated = await tree.list(join(directory, "dist/generated"));
|
|
501
|
+
if (root?.join(",") !== "bin,dist,package.json,skills" || bin?.join(",") !== "caotang.cjs" || generated?.join(",") !== "knowledge-growth.js"
|
|
502
|
+
|| !dist || dist.length > 64 || !dist.includes("cli.js") || !dist.includes("generated") || dist.some(name => name !== "generated" && !/^[a-z][a-z0-9-]*\.js$/.test(name))
|
|
503
|
+
|| skills?.join(",") !== [...LEGACY_MANAGED_SKILLS.map(item => item.name), "manifest.json"].sort().join(","))
|
|
504
|
+
throw conflict();
|
|
505
|
+
if (!(await tree.read(join(directory, "skills/manifest.json"), 16384, 0o600))?.equals(manifest))
|
|
506
|
+
throw conflict();
|
|
507
|
+
const files = [{ path: "package.json", bytes: runtime, mode: 0o600 }, { path: "skills/manifest.json", bytes: manifest, mode: 0o600 }];
|
|
508
|
+
const assets = [];
|
|
509
|
+
for (const item of LEGACY_MANAGED_SKILLS) {
|
|
510
|
+
if ((await tree.list(join(directory, "skills", item.name)))?.join(",") !== "SKILL.md")
|
|
511
|
+
throw conflict();
|
|
512
|
+
const bytes = await tree.read(join(directory, "skills", item.name, "SKILL.md"), 65536, 0o600);
|
|
513
|
+
if (!bytes || hash(bytes) !== item.sha256)
|
|
514
|
+
throw conflict();
|
|
515
|
+
assets.push({ ...item, content: bytes.toString("utf8") });
|
|
516
|
+
files.push({ path: `skills/${item.name}/SKILL.md`, bytes, mode: 0o600 });
|
|
517
|
+
}
|
|
518
|
+
for (const path of ["bin/caotang.cjs", ...dist.filter(name => name !== "generated").map(name => `dist/${name}`), "dist/generated/knowledge-growth.js"]) {
|
|
519
|
+
const mode = path === "bin/caotang.cjs" ? 0o700 : 0o600, bytes = await tree.read(join(directory, path), 1048576, mode);
|
|
520
|
+
if (!bytes?.length)
|
|
521
|
+
throw conflict();
|
|
522
|
+
files.push({ path, bytes, mode });
|
|
523
|
+
}
|
|
524
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
525
|
+
if (files.reduce((total, file) => total + file.bytes.length, 0) > 5242880)
|
|
526
|
+
throw conflict();
|
|
527
|
+
const digest = createHash("sha256");
|
|
528
|
+
for (const file of files)
|
|
529
|
+
digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
|
|
530
|
+
if (digest.digest("hex") !== sha)
|
|
531
|
+
throw conflict();
|
|
532
|
+
found.push({ bundle: { files, sha, version: "1.0.0", directory, command: value.command, shim: managedShim(directory) }, assets });
|
|
533
|
+
}
|
|
534
|
+
if (["1.1.0", "1.1.1", "1.2.0"].includes(value.version) && value.sha !== REVIEWED_MANAGED_BUNDLE_1_1_0.sha && entries.includes(REVIEWED_MANAGED_BUNDLE_1_1_0.sha))
|
|
535
|
+
found.push(await reviewedV110Bundle(tree, value));
|
|
536
|
+
return found;
|
|
537
|
+
}
|
|
538
|
+
async function installPlan(ctx, tree, value) {
|
|
539
|
+
const missing = [], modified = [];
|
|
540
|
+
const newlyInstalledPlatforms = [], upgradedPlatforms = [], replacements = [];
|
|
541
|
+
for (const target of ctx.targets) {
|
|
542
|
+
let absent = 0;
|
|
543
|
+
for (const raw of ctx.assets) {
|
|
544
|
+
const asset = bindSkillAgent(raw, ctx.agentName), dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
|
|
545
|
+
if (entries === null || entries.length === 0) {
|
|
546
|
+
missing.push({ target, asset });
|
|
547
|
+
absent++;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (entries.length !== 1 || entries[0] !== "SKILL.md")
|
|
551
|
+
throw skillConflict();
|
|
552
|
+
const bytes = await tree.read(join(dir, "SKILL.md"), 65536);
|
|
553
|
+
if (!bytes)
|
|
554
|
+
throw skillConflict();
|
|
555
|
+
if (!bytes.equals(Buffer.from(asset.content)))
|
|
556
|
+
modified.push({ target, asset, bytes });
|
|
557
|
+
}
|
|
558
|
+
if (absent === ctx.assets.length)
|
|
559
|
+
newlyInstalledPlatforms.push(target.id);
|
|
560
|
+
}
|
|
561
|
+
const command = await tree.read(value.command);
|
|
562
|
+
if (command && ((await lstat(value.command)).mode & 0o100) === 0)
|
|
563
|
+
throw unsafe();
|
|
564
|
+
const legacy = modified.length || command && !command.equals(value.shim) ? await legacyBundles(ctx, tree, value) : [];
|
|
565
|
+
for (const item of modified) {
|
|
566
|
+
const known = legacy.some(source => source.assets.some(asset => asset.name === item.asset.name && Buffer.from(bindSkillAgent(asset, ctx.agentName).content).equals(item.bytes)));
|
|
567
|
+
if (!known)
|
|
568
|
+
throw skillConflict();
|
|
569
|
+
const path = join(item.target.skillDirectory, item.asset.name, "SKILL.md");
|
|
570
|
+
if (!(await tree.read(path, 65536, 0o600))?.equals(item.bytes))
|
|
571
|
+
throw unsafe();
|
|
572
|
+
replacements.push({ path, before: item.bytes, after: Buffer.from(item.asset.content), mode: 0o600, label: `${item.target.id}:${item.asset.name}` });
|
|
573
|
+
if (!upgradedPlatforms.includes(item.target.id))
|
|
574
|
+
upgradedPlatforms.push(item.target.id);
|
|
575
|
+
}
|
|
576
|
+
if (command && !command.equals(value.shim)) {
|
|
577
|
+
if (!legacy.some(source => source.bundle.shim.equals(command)))
|
|
578
|
+
throw conflict();
|
|
579
|
+
if (!(await tree.read(value.command, 1048576, 0o700))?.equals(command))
|
|
580
|
+
throw unsafe();
|
|
581
|
+
// Always switch the shared stable command after every selected skill succeeds.
|
|
582
|
+
replacements.push({ path: value.command, before: command, after: value.shim, mode: 0o700, label: "command" });
|
|
583
|
+
}
|
|
584
|
+
return { missing, replacements, newlyInstalledPlatforms, upgradedPlatforms };
|
|
585
|
+
}
|
|
381
586
|
export async function installLocal(ctx) {
|
|
382
587
|
const tree = new OwnedTree(ctx.homeDir), changed = [];
|
|
383
588
|
let changesPossible = false;
|
|
384
|
-
const newlyInstalledPlatforms = [];
|
|
385
589
|
try {
|
|
386
590
|
validateTargets(ctx, tree);
|
|
387
591
|
if (!ctx.targets.length && !ctx.agentName)
|
|
388
592
|
throw new CliError("no_supported_platform", "未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy后再接入;不会自动安装客户端。");
|
|
389
593
|
const value = await bundle(ctx, tree);
|
|
390
594
|
await tree.directory(tree.home, false, async () => { });
|
|
391
|
-
|
|
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();
|
|
595
|
+
await installPlan(ctx, tree, value);
|
|
400
596
|
if (await bundleState(tree, value) === "modified")
|
|
401
597
|
throw conflict();
|
|
402
598
|
const lockPath = join(tree.home, ".caotang", ".setup.lock");
|
|
@@ -411,13 +607,7 @@ export async function installLocal(ctx) {
|
|
|
411
607
|
const state = await bundleState(tree, value);
|
|
412
608
|
if (state === "modified")
|
|
413
609
|
throw conflict();
|
|
414
|
-
|
|
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
|
-
}
|
|
610
|
+
const originalPlan = await installPlan(ctx, tree, value);
|
|
421
611
|
if (state === "missing") {
|
|
422
612
|
const created = await tree.createDirectory(value.directory);
|
|
423
613
|
if (!created)
|
|
@@ -428,25 +618,79 @@ export async function installLocal(ctx) {
|
|
|
428
618
|
if (await bundleState(tree, value) !== "installed")
|
|
429
619
|
throw conflict();
|
|
430
620
|
}
|
|
431
|
-
for (const target of
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
621
|
+
for (const { target, asset } of originalPlan.missing) {
|
|
622
|
+
const dir = join(target.skillDirectory, asset.name), created = await tree.createDirectory(dir);
|
|
623
|
+
if (!created && (await tree.list(dir))?.length !== 0)
|
|
624
|
+
throw skillConflict();
|
|
625
|
+
changed.push(`${target.id}:${asset.name}`);
|
|
626
|
+
await tree.writeNew(join(dir, "SKILL.md"), Buffer.from(asset.content), 0o600);
|
|
627
|
+
if (await skillState(tree, target, asset) !== "installed")
|
|
628
|
+
throw skillConflict();
|
|
629
|
+
}
|
|
630
|
+
const plan = await installPlan(ctx, tree, value), attempted = [];
|
|
631
|
+
let recoveryJournal;
|
|
632
|
+
if (plan.replacements.length) {
|
|
633
|
+
const snapshot = Buffer.from(JSON.stringify({ formatVersion: 1, targetBundleSha: value.sha, files: plan.replacements.map(entry => ({ path: relative(tree.home, entry.path), mode: entry.mode, before: entry.before.toString("base64"), after: entry.after.toString("base64") })) }));
|
|
634
|
+
if (snapshot.length > 1048576)
|
|
635
|
+
throw unsafe();
|
|
636
|
+
recoveryJournal = join(tree.home, ".caotang", "cli", "upgrades", hash(snapshot) + ".json");
|
|
637
|
+
await tree.writeNew(recoveryJournal, snapshot, 0o600);
|
|
638
|
+
if (!(await tree.read(recoveryJournal, 1048576, 0o600))?.equals(snapshot))
|
|
639
|
+
throw unsafe();
|
|
640
|
+
await tree.directory(dirname(recoveryJournal), false, async (directories) => {
|
|
641
|
+
for (const directory of directories)
|
|
642
|
+
await directory.handle.sync();
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
for (const entry of plan.replacements) {
|
|
647
|
+
attempted.push(entry);
|
|
648
|
+
changed.push(entry.label);
|
|
649
|
+
await tree.replaceManaged(entry);
|
|
650
|
+
}
|
|
651
|
+
if (await tree.writeNew(value.command, value.shim, 0o700))
|
|
652
|
+
changed.push("command");
|
|
653
|
+
if (!(await tree.read(value.command))?.equals(value.shim) || ((await lstat(value.command)).mode & 0o100) === 0 || await bundleState(tree, value) !== "installed")
|
|
654
|
+
throw conflict();
|
|
655
|
+
for (const target of ctx.targets)
|
|
656
|
+
for (const asset of ctx.assets)
|
|
657
|
+
if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) !== "installed")
|
|
658
|
+
throw skillConflict();
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
if (!recoveryJournal)
|
|
662
|
+
throw error;
|
|
663
|
+
let restored = true;
|
|
664
|
+
for (const entry of attempted.reverse()) {
|
|
665
|
+
try {
|
|
666
|
+
const current = await tree.read(entry.path, 1048576, entry.mode);
|
|
667
|
+
if (!current?.equals(entry.before)) {
|
|
668
|
+
if (!entry.identity) {
|
|
669
|
+
restored = false;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
await tree.replaceManaged(entry, true);
|
|
673
|
+
}
|
|
674
|
+
if (!(await tree.read(entry.path, 1048576, entry.mode))?.equals(entry.before)
|
|
675
|
+
|| entry.identity && !same(await lstat(entry.path), entry.identity))
|
|
676
|
+
restored = false;
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
restored = false;
|
|
680
|
+
}
|
|
442
681
|
}
|
|
443
|
-
|
|
444
|
-
|
|
682
|
+
const recovery = { upgrade_status: restored ? "rolled_back" : "unknown", recovery_journal: recoveryJournal };
|
|
683
|
+
if (restored && error instanceof CliError && error.code === "installation_permission_denied") {
|
|
684
|
+
Object.assign(error.details, recovery);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
throw new CliError(restored ? "installation_upgrade_failed" : "installation_upgrade_unknown", restored ? "本地升级未完成,已核对并恢复本轮改动的原文件。" : "无法确认本地升级结果,部分受管文件可能尚未恢复。", "保留旧版本与恢复原件,请本人核对 recovery_journal;不要强制覆盖、删除、重新授权或重复业务写入。", 7, recovery);
|
|
688
|
+
}
|
|
689
|
+
return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms: originalPlan.newlyInstalledPlatforms, upgradedPlatforms: originalPlan.upgradedPlatforms };
|
|
445
690
|
}
|
|
446
691
|
finally {
|
|
447
692
|
release();
|
|
448
693
|
}
|
|
449
|
-
return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms };
|
|
450
694
|
}
|
|
451
695
|
catch (error) {
|
|
452
696
|
const safe = error instanceof CliError ? error
|
|
@@ -483,9 +727,54 @@ export async function inspectLocalInstallation(ctx) {
|
|
|
483
727
|
}
|
|
484
728
|
skills.push({ name: asset.name, status });
|
|
485
729
|
if (target.detected && status !== "installed")
|
|
486
|
-
issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? `运行 setup --platform ${target.id} 安装缺失技能。` : "保留现有技能并人工核对,不自动覆盖或修复。");
|
|
730
|
+
issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? (target.id === "generic" ? "保留本次 --skills-dir 的明确目录再运行 setup;不会猜测其他技能位置。" : `运行 setup --platform ${target.id} 安装缺失技能。`) : "保留现有技能并人工核对,不自动覆盖或修复。");
|
|
487
731
|
}
|
|
488
732
|
platforms.push({ id: target.id, detected: target.detected, skill_directory: target.skillDirectory, skills });
|
|
489
733
|
}
|
|
490
734
|
return { command: { installed, on_path, version: value.version, ...(installed ? { executable_path: value.command } : {}) }, platforms, issues };
|
|
491
735
|
}
|
|
736
|
+
// Read-only evidence for known platform paths, never an installation/overwrite
|
|
737
|
+
// permit. Only the current name's exact public templates count as its skills.
|
|
738
|
+
export async function inspectManagedOrigins(ctx) {
|
|
739
|
+
if (ctx.agentName === undefined)
|
|
740
|
+
throw new CliError("agent_name_required", "请用你自己的名字再运行 setup --agent <你的名字>。", "之后每条命令都带同一个 --agent <你的名字>;不会借用其他 AI 的连接。");
|
|
741
|
+
const tree = new OwnedTree(ctx.homeDir);
|
|
742
|
+
validateTargets(ctx, tree);
|
|
743
|
+
const value = await bundle(ctx, tree), assets = ctx.assets.map(asset => bindSkillAgent(asset, ctx.agentName));
|
|
744
|
+
let legacy = [];
|
|
745
|
+
try {
|
|
746
|
+
legacy = await legacyBundles(ctx, tree, value);
|
|
747
|
+
}
|
|
748
|
+
catch { /* Unverified old files provide no evidence; never promote them. */ }
|
|
749
|
+
const platforms = [];
|
|
750
|
+
for (const target of ctx.targets) {
|
|
751
|
+
const skills = [];
|
|
752
|
+
for (const asset of assets) {
|
|
753
|
+
let status;
|
|
754
|
+
try {
|
|
755
|
+
status = await skillState(tree, target, asset);
|
|
756
|
+
if (status === "modified") {
|
|
757
|
+
const dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
|
|
758
|
+
if (entries?.join(",") === "SKILL.md") {
|
|
759
|
+
const bytes = await tree.read(join(dir, "SKILL.md"), 65536, 0o600);
|
|
760
|
+
if (bytes && legacy.some(source => source.assets.some(old => old.name === asset.name && Buffer.from(bindSkillAgent(old, ctx.agentName).content).equals(bytes))))
|
|
761
|
+
status = "outdated";
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
status = "unsafe";
|
|
767
|
+
}
|
|
768
|
+
skills.push({ name: asset.name, status });
|
|
769
|
+
}
|
|
770
|
+
platforms.push({ id: target.id, skill_directory: target.skillDirectory, ownKnownCount: skills.filter(skill => skill.status === "installed" || skill.status === "outdated").length, skills });
|
|
771
|
+
}
|
|
772
|
+
let command = { legacyManaged: false, on_path: false };
|
|
773
|
+
try {
|
|
774
|
+
const bytes = await tree.read(value.command, 1048576, 0o700), old = legacy.find(source => bytes?.equals(source.bundle.shim));
|
|
775
|
+
if (old)
|
|
776
|
+
command = { legacyManaged: true, on_path: await onPath(value.command, ctx.env), version: old.bundle.version, executable_path: value.command };
|
|
777
|
+
}
|
|
778
|
+
catch { /* An unsafe command must not be called a verified old command. */ }
|
|
779
|
+
return { platforms, command };
|
|
780
|
+
}
|