@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,200 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, lstat, readdir, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { asRecord, CliError } from "./errors.js";
|
|
5
|
+
const unsafe = () => new CliError("platform_path_unsafe", "平台目录未通过安全检查。", "请使用用户目录内的绝对路径,并检查目录和符号链接;不要提供目录中的配置内容。");
|
|
6
|
+
const metadataError = (error) => {
|
|
7
|
+
const code = asRecord(error)?.code;
|
|
8
|
+
return code === "EACCES" || code === "EPERM"
|
|
9
|
+
? new CliError("platform_permission_denied", "没有权限检查平台程序目录。", "请由本人检查程序目录的访问权限,不要自动提权或修改权限。", 4)
|
|
10
|
+
: code === "ELOOP" ? unsafe()
|
|
11
|
+
: new CliError("platform_detection_failed", "无法完成平台程序检查。", "请检查本地程序目录后重新执行只读检查,不要启动客户端或输出配置内容。");
|
|
12
|
+
};
|
|
13
|
+
const missing = (error) => ["ENOENT", "ENOTDIR"].includes(String(asRecord(error)?.code));
|
|
14
|
+
const absolute = (path) => typeof path === "string" && isAbsolute(path) && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(path);
|
|
15
|
+
const within = (home, path) => {
|
|
16
|
+
const part = relative(home, path);
|
|
17
|
+
return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part);
|
|
18
|
+
};
|
|
19
|
+
async function executable(path) {
|
|
20
|
+
try {
|
|
21
|
+
const info = await stat(path);
|
|
22
|
+
if (!info.isFile() || (info.mode & 0o111) === 0)
|
|
23
|
+
return false;
|
|
24
|
+
try {
|
|
25
|
+
await access(path, constants.X_OK);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (["EACCES", "EPERM", "ENOENT", "ENOTDIR"].includes(String(asRecord(error)?.code)))
|
|
29
|
+
return false;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (missing(error))
|
|
36
|
+
return false;
|
|
37
|
+
throw metadataError(error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function directories(path) {
|
|
41
|
+
try {
|
|
42
|
+
return (await readdir(path, { withFileTypes: true }))
|
|
43
|
+
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
|
44
|
+
.map((entry) => entry.name).sort();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (missing(error))
|
|
48
|
+
return [];
|
|
49
|
+
throw metadataError(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function skillDirectory(home, canonicalHome, root) {
|
|
53
|
+
if (!absolute(root))
|
|
54
|
+
throw unsafe();
|
|
55
|
+
const target = resolve(root, "skills");
|
|
56
|
+
if (!within(home, resolve(root)) || !within(home, target))
|
|
57
|
+
throw unsafe();
|
|
58
|
+
// Check existing ancestry as metadata only, including symlink escapes before a missing leaf.
|
|
59
|
+
let current = home;
|
|
60
|
+
for (const component of relative(home, target).split(sep).filter(Boolean)) {
|
|
61
|
+
current = join(current, component);
|
|
62
|
+
try {
|
|
63
|
+
const info = await lstat(current);
|
|
64
|
+
if (info.isSymbolicLink()) {
|
|
65
|
+
let resolved;
|
|
66
|
+
try {
|
|
67
|
+
resolved = await realpath(current);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (missing(error))
|
|
71
|
+
throw unsafe();
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (!within(canonicalHome, resolved) || !(await stat(current)).isDirectory())
|
|
75
|
+
throw unsafe();
|
|
76
|
+
}
|
|
77
|
+
else if (!info.isDirectory())
|
|
78
|
+
throw unsafe();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (asRecord(error)?.code === "ENOENT")
|
|
82
|
+
break;
|
|
83
|
+
if (error instanceof CliError)
|
|
84
|
+
throw error;
|
|
85
|
+
throw metadataError(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return target;
|
|
89
|
+
}
|
|
90
|
+
async function claudeDesktop(home) {
|
|
91
|
+
const root = join(home, "Library", "Application Support", "Claude", "claude-code");
|
|
92
|
+
for (const version of await directories(root)) {
|
|
93
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version))
|
|
94
|
+
continue;
|
|
95
|
+
for (const hash of await directories(join(root, version))) {
|
|
96
|
+
if (!/^[a-f0-9]{6,64}$/i.test(hash))
|
|
97
|
+
continue;
|
|
98
|
+
if (await executable(join(root, version, hash, "claude.app", "Contents", "MacOS", "claude")))
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
export async function detectPlatforms(options) {
|
|
105
|
+
if (!absolute(options.homeDir))
|
|
106
|
+
throw unsafe();
|
|
107
|
+
const home = resolve(options.homeDir);
|
|
108
|
+
let canonicalHome;
|
|
109
|
+
try {
|
|
110
|
+
canonicalHome = await realpath(home);
|
|
111
|
+
if (!(await stat(home)).isDirectory())
|
|
112
|
+
throw unsafe();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof CliError)
|
|
116
|
+
throw error;
|
|
117
|
+
throw metadataError(error);
|
|
118
|
+
}
|
|
119
|
+
const { env } = options;
|
|
120
|
+
const roots = options.applicationRoots ?? ["/Applications", join(home, "Applications")];
|
|
121
|
+
if (!roots.every(absolute))
|
|
122
|
+
throw unsafe();
|
|
123
|
+
const selected = (id) => options.platformIds === undefined || options.platformIds.includes(id);
|
|
124
|
+
const targets = [];
|
|
125
|
+
const candidates = [
|
|
126
|
+
{ id: "claude-code", root: () => env.CLAUDE_CONFIG_DIR ?? join(home, ".claude") },
|
|
127
|
+
{ id: "codex", root: () => join(home, ".agents") },
|
|
128
|
+
{ id: "openclaw", root: () => env.OPENCLAW_STATE_DIR ?? (env.OPENCLAW_HOME === undefined ? join(home, ".openclaw") : (() => {
|
|
129
|
+
if (!absolute(env.OPENCLAW_HOME) || !within(home, resolve(env.OPENCLAW_HOME)))
|
|
130
|
+
throw unsafe();
|
|
131
|
+
return join(env.OPENCLAW_HOME, ".openclaw");
|
|
132
|
+
})()) },
|
|
133
|
+
];
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
if (selected(candidate.id))
|
|
136
|
+
targets.push({ id: candidate.id, detected: false, evidence: "none", skillDirectory: await skillDirectory(home, canonicalHome, candidate.root()) });
|
|
137
|
+
}
|
|
138
|
+
const pathRoots = (env.PATH ?? "").split(delimiter).filter(absolute);
|
|
139
|
+
for (const target of targets) {
|
|
140
|
+
const command = target.id === "claude-code" ? "claude" : target.id;
|
|
141
|
+
for (const root of pathRoots) {
|
|
142
|
+
if (await executable(join(root, command))) {
|
|
143
|
+
target.detected = true;
|
|
144
|
+
target.evidence = "executable";
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (target.detected)
|
|
149
|
+
continue;
|
|
150
|
+
if (target.id === "claude-code") {
|
|
151
|
+
target.detected = await claudeDesktop(home);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const app = target.id === "codex" ? "Codex" : "OpenClaw";
|
|
155
|
+
for (const root of roots) {
|
|
156
|
+
if (await executable(join(root, `${app}.app`, "Contents", "MacOS", app))
|
|
157
|
+
|| (target.id === "codex" && await executable(join(root, "ChatGPT.app", "Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex")))) {
|
|
158
|
+
target.detected = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (target.detected)
|
|
164
|
+
target.evidence = "desktop";
|
|
165
|
+
}
|
|
166
|
+
if ((options.operatingSystem ?? process.platform) === "darwin") {
|
|
167
|
+
// Do not inspect an unselected new platform's personal paths. Without a
|
|
168
|
+
// selector, only a verified program makes its skill root relevant.
|
|
169
|
+
for (const [id, app, binary, rootPath, command] of [
|
|
170
|
+
["kimi-code", "Kimi Code", "Kimi Code", env.KIMI_CODE_HOME ?? join(home, ".kimi-code"), "kimi"],
|
|
171
|
+
["kimi-work", "Kimi", "Kimi", join(home, "Library/Application Support/kimi-desktop/daimon-share/daimon"), null],
|
|
172
|
+
["workbuddy", "WorkBuddy", "Electron", join(home, ".workbuddy"), null],
|
|
173
|
+
]) {
|
|
174
|
+
if (!selected(id))
|
|
175
|
+
continue;
|
|
176
|
+
let detected = false;
|
|
177
|
+
let evidence = "none";
|
|
178
|
+
if (command)
|
|
179
|
+
for (const root of pathRoots) {
|
|
180
|
+
if (await executable(join(root, command))) {
|
|
181
|
+
detected = true;
|
|
182
|
+
evidence = "executable";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (!detected)
|
|
187
|
+
for (const root of roots) {
|
|
188
|
+
if (await executable(join(root, `${app}.app`, "Contents", "MacOS", binary))) {
|
|
189
|
+
detected = true;
|
|
190
|
+
evidence = "desktop";
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!detected && options.platformIds === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
targets.push({ id, detected, evidence, skillDirectory: await skillDirectory(home, canonicalHome, rootPath) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return targets;
|
|
200
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { asRecord, CliError, expectOK } from "./errors.js";
|
|
2
|
+
import { validDate } from "./query-validation.js";
|
|
3
|
+
import { safeSearchWarnings } from "./query-search.js";
|
|
4
|
+
const leap = (year) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
5
|
+
function ordinal(year, month, day) {
|
|
6
|
+
const prior = year - 1;
|
|
7
|
+
const months = [31, leap(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
8
|
+
return 365 * prior + Math.floor(prior / 4) - Math.floor(prior / 100) + Math.floor(prior / 400) + months.slice(0, month - 1).reduce((sum, value) => sum + value, 0) + day - 1;
|
|
9
|
+
}
|
|
10
|
+
export function validateDailyDates(flags) {
|
|
11
|
+
const start = flags["start-date"], end = flags["end-date"];
|
|
12
|
+
if (!validDate(start) || !validDate(end))
|
|
13
|
+
throw new CliError("invalid_arguments", "日汇总起止日期参数无效。", "必须同时提供 --start-date 和 --end-date,使用真实 YYYY-MM-DD(0001至9999年),不可为空。");
|
|
14
|
+
const [sy, sm, sd] = start.split("-").map(Number), [ey, em, ed] = end.split("-").map(Number);
|
|
15
|
+
// Use civil ordinals, not JS Date years 0..99, timezone offsets or 366 days.
|
|
16
|
+
// Internal year 10000 is valid for the exclusive bound of wire year 9999.
|
|
17
|
+
const nextMonth = sm === 2 && sd === 29 && !leap(sy + 1) ? 3 : sm;
|
|
18
|
+
const nextDay = nextMonth !== sm ? 1 : sd;
|
|
19
|
+
if (start > end || ordinal(ey, em, ed) + 1 > ordinal(sy + 1, nextMonth, nextDay))
|
|
20
|
+
throw new CliError("invalid_arguments", "日汇总日期范围无效。", "起始不得晚于结束;含首尾日期最多一个日历年,闰日周年按次年3月1日计算,不按固定366天计算。");
|
|
21
|
+
}
|
|
22
|
+
export function isDailyParameterError(value) {
|
|
23
|
+
return typeof value === "string" && ["start_date is required", "end_date is required", "date range exceeds one calendar year", "unsupported daily parameter", "invalid daily query", "invalid start_date", "invalid end_date", "invalid date range", "invalid limit", "invalid offset"].some(message => value === message || value === `${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
const coverages = ["unavailable", "not_recorded", "partial", "complete"];
|
|
26
|
+
function validDailyMetadata(value) {
|
|
27
|
+
const body = asRecord(value);
|
|
28
|
+
if (!body || !Array.isArray(body.days) || typeof body.partial !== "boolean" || (body.coverage_start !== null && typeof body.coverage_start !== "string"))
|
|
29
|
+
return false;
|
|
30
|
+
return body.days.every(day => {
|
|
31
|
+
const row = asRecord(day);
|
|
32
|
+
return row !== null && validDate(row.date) && typeof row.knowledge_growth_coverage === "string" && coverages.includes(row.knowledge_growth_coverage)
|
|
33
|
+
&& ["progress", "knowledge_created", "knowledge_growth", "redacted_progress_count"].every(key => row[key] === null || typeof row[key] === "number" && Number.isSafeInteger(row[key]) && row[key] >= 0);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function formatDailyNotice(value) {
|
|
37
|
+
const body = asRecord(value);
|
|
38
|
+
if (!body || body.timezone !== "Asia/Shanghai" || !Array.isArray(body.days))
|
|
39
|
+
return "";
|
|
40
|
+
const days = body.days.map(asRecord).filter((row) => row !== null);
|
|
41
|
+
let result = "每日数量是完整日期范围的统计,不是当前页明细数;明细total只含可读事件。\n";
|
|
42
|
+
if (days.some(row => [row.progress, row.knowledge_created, row.knowledge_growth, row.redacted_progress_count].includes(null)))
|
|
43
|
+
result += "null表示不可读取或未完整记录,不能当作0。\n";
|
|
44
|
+
if (days.some(row => row.knowledge_growth_coverage === "not_recorded"))
|
|
45
|
+
result += "知识长势覆盖起点之前未完整记录,不能据此判断没有变化。\n";
|
|
46
|
+
if (days.some(row => row.knowledge_growth_coverage === "partial"))
|
|
47
|
+
result += "知识长势覆盖起点当天仅部分记录,不代表完整一天。\n";
|
|
48
|
+
if (body.partial === true)
|
|
49
|
+
result += "本次结果不完整;草稿被过滤时数字不含草稿,不能理解成当天没有活动。\n";
|
|
50
|
+
if (days.some(row => typeof row.redacted_progress_count === "number" && row.redacted_progress_count > 0))
|
|
51
|
+
result += "部分知识应用进展仅保留匿名日计数,不返回身份、关联或正文。\n";
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function expectDailyOK(result) {
|
|
55
|
+
if (result.status === 400 && isDailyParameterError(result.body))
|
|
56
|
+
throw new CliError("invalid_arguments", "日汇总参数无效。", "仅支持必填起止日期与limit/offset;真实日期含首尾最多一个日历年,limit为1至50,offset为非负安全整数。", 2, { http_status: 400 });
|
|
57
|
+
if (result.status === 404)
|
|
58
|
+
throw new CliError("not_found", "当前日汇总入口不可用。", "请核对服务版本与访问范围;这不表示没有活动数据。", 2, { http_status: 404 });
|
|
59
|
+
const body = asRecord(result.body), code = body?.code ?? body?.error;
|
|
60
|
+
if (result.status === 403 && code === "capability_disabled" && (body?.code === undefined || body.code === code) && (body?.error === undefined || body.error === code)) {
|
|
61
|
+
const expected = [{ section: "daily_progress", required_capability: "read_projects", reason: "capability_disabled" }, { section: "daily_knowledge", required_capability: "read_knowledge", reason: "capability_disabled" }];
|
|
62
|
+
const rows = Array.isArray(body?.missing_sections) ? body.missing_sections : [];
|
|
63
|
+
if (!Object.hasOwn(body, "required_capability") && expected.every(want => rows.some(row => { const item = asRecord(row); return item?.section === want.section && item.required_capability === want.required_capability && item.reason === want.reason; })))
|
|
64
|
+
throw new CliError("capability_disabled", "“读取项目”和“读取知识”开关都未开启。", "请本人到网页的 Agent 管理核对并开启所需开关;不要重新授权、换入口或重试来绕过限制。", 4, { http_status: 403, missing_sections: expected });
|
|
65
|
+
}
|
|
66
|
+
const checked = expectOK(result, "read");
|
|
67
|
+
if (!validDailyMetadata(checked.body))
|
|
68
|
+
throw new CliError("invalid_response", "日汇总返回了无法确认的计数或覆盖信息。", "请核对服务返回合同;不要把未知计数当作0或输出未经确认的身份信息。", 7);
|
|
69
|
+
return { ...checked, body: safeSearchWarnings(checked.body) };
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { asRecord, CliError } from "./errors.js";
|
|
2
|
+
import { expectQueryOK } from "./query-validation.js";
|
|
3
|
+
const trimSearch = (value) => value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, "");
|
|
4
|
+
export function validateSearch(q, flags) {
|
|
5
|
+
if (q !== undefined && (q.includes("\0") || /[\uD800-\uDFFF]/u.test(q) || [...trimSearch(q)].length > 200)) {
|
|
6
|
+
throw new CliError("invalid_arguments", "搜索词参数无效。", "搜索词首尾空白不计,最多200个Unicode字符;不能含NUL或无效字符。可省略搜索词按日期浏览。");
|
|
7
|
+
}
|
|
8
|
+
if (["project", "topic"].some(key => flags[key] !== undefined && (typeof flags[key] !== "string" || !trimSearch(flags[key])))) {
|
|
9
|
+
throw new CliError("invalid_arguments", "知识分支筛选参数无效。", "--project 和 --topic 不可显式为空;这些参数只筛知识分支,不筛其它类型。");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isSearchParameterError(value) {
|
|
13
|
+
return typeof value === "string" && ["invalid q", "invalid status", "invalid topic", "invalid publication_status", "invalid project_id", "invalid core_only", "unsupported core filter"].some(message => value === message || value === `${message}\n`);
|
|
14
|
+
}
|
|
15
|
+
export const searchWarningNotice = "历史手写进展与项目进展存在无法唯一确认的镜像关系,已保留记录,结果中可能有少量重复。\n";
|
|
16
|
+
export function safeSearchWarnings(value) {
|
|
17
|
+
const body = asRecord(value);
|
|
18
|
+
if (!body || !Object.hasOwn(body, "warnings"))
|
|
19
|
+
return value;
|
|
20
|
+
const { warnings, ...rest } = body;
|
|
21
|
+
return Array.isArray(warnings) && warnings.includes("historical_mirror_ambiguous") ? { ...rest, warnings: ["historical_mirror_ambiguous"] } : rest;
|
|
22
|
+
}
|
|
23
|
+
export function formatSearchWarnings(value) {
|
|
24
|
+
const warnings = asRecord(value)?.warnings;
|
|
25
|
+
return Array.isArray(warnings) && warnings.includes("historical_mirror_ambiguous") ? searchWarningNotice : "";
|
|
26
|
+
}
|
|
27
|
+
export function expectSearchOK(result) {
|
|
28
|
+
if (result.status === 400 && isSearchParameterError(result.body)) {
|
|
29
|
+
throw new CliError("invalid_arguments", "搜索参数无效。", "搜索词最多200个Unicode字符;status/topic/publication-status/project只筛知识且不可显式为空。只看核心请使用 --core-only。", 2, { http_status: 400 });
|
|
30
|
+
}
|
|
31
|
+
const body = asRecord(result.body), code = body?.code ?? body?.error;
|
|
32
|
+
if (result.status === 403 && code === "capability_disabled" && (body?.code === undefined || body.code === code) && (body?.error === undefined || body.error === code)) {
|
|
33
|
+
const expected = [{ section: "search_projects", required_capability: "read_projects", reason: "capability_disabled" }, { section: "search_knowledge", required_capability: "read_knowledge", reason: "capability_disabled" }];
|
|
34
|
+
const rows = Array.isArray(body?.missing_sections) ? body.missing_sections : [];
|
|
35
|
+
if (!Object.hasOwn(body, "required_capability") && expected.every(want => rows.some(row => { const item = asRecord(row); return item?.section === want.section && item.required_capability === want.required_capability && item.reason === want.reason; }))) {
|
|
36
|
+
throw new CliError("capability_disabled", "“读取项目”和“读取知识”开关都未开启。", "请本人到网页的 Agent 管理核对并开启所需开关;不要重新授权、换入口或重试来绕过限制。", 4, { http_status: 403, missing_sections: expected });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const checked = expectQueryOK(result);
|
|
40
|
+
const cleaned = safeSearchWarnings(checked.body);
|
|
41
|
+
return cleaned === checked.body ? checked : { ...checked, body: cleaned };
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { CliError, expectOK } from "./errors.js";
|
|
2
|
+
export const dateFlags = ["start-date", "end-date", "date-field"];
|
|
3
|
+
export function validDate(value) {
|
|
4
|
+
if (typeof value !== "string" || value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
5
|
+
return false;
|
|
6
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
7
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
8
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
9
|
+
return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1];
|
|
10
|
+
}
|
|
11
|
+
export function validateDateFlags(flags, createdOnly = false) {
|
|
12
|
+
if ((flags["start-date"] !== undefined && !validDate(flags["start-date"]))
|
|
13
|
+
|| (flags["end-date"] !== undefined && !validDate(flags["end-date"]))) {
|
|
14
|
+
throw new CliError("invalid_arguments", "日期参数无效。", "--start-date 和 --end-date 请使用真实的 YYYY-MM-DD 日期(0001 至 9999 年);省略表示不设该侧边界,不可留空。按北京时间包含首尾两天。");
|
|
15
|
+
}
|
|
16
|
+
if (typeof flags["start-date"] === "string" && typeof flags["end-date"] === "string" && flags["start-date"] > flags["end-date"]) {
|
|
17
|
+
throw new CliError("invalid_arguments", "起止日期参数顺序无效。", "--start-date 不得晚于 --end-date;可以只提供一侧日期。");
|
|
18
|
+
}
|
|
19
|
+
const field = flags["date-field"];
|
|
20
|
+
if (field !== undefined && (createdOnly ? field !== "created_at" : field !== "created_at" && field !== "updated_at")) {
|
|
21
|
+
throw new CliError("invalid_arguments", "--date-field 参数无效。", createdOnly ? "项目进展和手写进展固定按 created_at 写入时间筛选;可省略 --date-field。" : "--date-field 请使用 created_at 或 updated_at;省略时按 updated_at 筛选,不可留空。");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function isPagedQueryPath(path) {
|
|
25
|
+
return /^\/api\/v1\/agent\/(?:m1\/(?:projects(?:\/[^/]+\/updates)?|knowledge|checkins|search|activity\/daily)|pending-actions)$/.test(path);
|
|
26
|
+
}
|
|
27
|
+
export function isProjectIDParameterError(path, value) {
|
|
28
|
+
return /^\/api\/v1\/agent\/m1\/(?:projects\/[^/]+\/updates|knowledge)$/.test(path)
|
|
29
|
+
&& (value === "invalid project_id" || value === "invalid project_id\n");
|
|
30
|
+
}
|
|
31
|
+
// Go http.Error has a narrow plain-text contract. Adopt exact known bodies only;
|
|
32
|
+
// JSON messages, suffixes, and other server text are never reflected to users.
|
|
33
|
+
export function isQueryParameterError(value) {
|
|
34
|
+
return typeof value === "string" && ["invalid limit", "invalid offset", "invalid start_date", "invalid end_date", "invalid date range", "invalid date_field"].some(message => value === message || value === `${message}\n`);
|
|
35
|
+
}
|
|
36
|
+
export function expectQueryOK(result, maxLimit = 50) {
|
|
37
|
+
if (result.status === 400 && isQueryParameterError(result.body)) {
|
|
38
|
+
const nextAction = result.body.startsWith("invalid limit")
|
|
39
|
+
? maxLimit === 50 ? "--limit 必须是 1 至 50 的整数。" : "--limit 必须是 1 至 100 的整数。"
|
|
40
|
+
: result.body.startsWith("invalid offset") ? "--offset 必须是 0 至 9007199254740991 的整数。"
|
|
41
|
+
: "日期须为真实 YYYY-MM-DD,起始不得晚于结束且显式值不可为空;项目/知识可选 created_at 或 updated_at,进展固定 created_at。";
|
|
42
|
+
throw new CliError("invalid_arguments", "查询参数无效。", nextAction, 2, { http_status: 400 });
|
|
43
|
+
}
|
|
44
|
+
return expectOK(result, "read");
|
|
45
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { agentIdentity, shellWord } from "./agent-identity.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, open } from "node:fs/promises";
|
|
5
|
+
import { isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { asRecord, CliError } from "./errors.js";
|
|
8
|
+
// One release path allowlist, checked against the real package manifest.
|
|
9
|
+
// Never use an arbitrary manifest entry as a filesystem installation target.
|
|
10
|
+
export const skillNames = Object.freeze(["caotang-context", "caotang-save-knowledge", "caotang-project-progress", "caotang-search", "caotang-workday", "caotang-first-connect"]);
|
|
11
|
+
export function completeSkillInventory(names) {
|
|
12
|
+
return names.length === skillNames.length && new Set(names).size === skillNames.length && names.every(name => skillNames.includes(name));
|
|
13
|
+
}
|
|
14
|
+
const manifestLimit = 16 * 1024;
|
|
15
|
+
const contentLimit = 64 * 1024;
|
|
16
|
+
const invalid = () => new CliError("skill_assets_invalid", "本包技能资源未通过安全或格式检查。", "请使用完整、未修改的一间草堂命令行安装包,不要复制不明技能文件。", 7);
|
|
17
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
18
|
+
const unchanged = (a, b) => sameFile(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
|
|
19
|
+
async function addDirectory(directories, path) {
|
|
20
|
+
const stat = await lstat(path);
|
|
21
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
22
|
+
throw invalid();
|
|
23
|
+
directories.push({ path, stat });
|
|
24
|
+
}
|
|
25
|
+
async function checkDirectories(directories) {
|
|
26
|
+
for (const directory of directories) {
|
|
27
|
+
const current = await lstat(directory.path);
|
|
28
|
+
if (!current.isDirectory() || current.isSymbolicLink() || !sameFile(directory.stat, current))
|
|
29
|
+
throw invalid();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function checkRegular(stat, limit) {
|
|
33
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size < 1 || stat.size > limit)
|
|
34
|
+
throw invalid();
|
|
35
|
+
}
|
|
36
|
+
async function readAsset(path, limit, directories) {
|
|
37
|
+
await checkDirectories(directories);
|
|
38
|
+
const before = await lstat(path);
|
|
39
|
+
checkRegular(before, limit);
|
|
40
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const opened = await handle.stat();
|
|
43
|
+
checkRegular(opened, limit);
|
|
44
|
+
if (!unchanged(before, opened))
|
|
45
|
+
throw invalid();
|
|
46
|
+
// Bound the read even if a regular file grows after fstat; never wait on a FIFO.
|
|
47
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
48
|
+
let length = 0;
|
|
49
|
+
while (length < buffer.length) {
|
|
50
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
51
|
+
if (bytesRead === 0)
|
|
52
|
+
break;
|
|
53
|
+
length += bytesRead;
|
|
54
|
+
}
|
|
55
|
+
const after = await handle.stat();
|
|
56
|
+
const current = await lstat(path);
|
|
57
|
+
checkRegular(after, limit);
|
|
58
|
+
checkRegular(current, limit);
|
|
59
|
+
if (length > limit || length !== opened.size || !unchanged(opened, after) || !unchanged(after, current))
|
|
60
|
+
throw invalid();
|
|
61
|
+
await checkDirectories(directories);
|
|
62
|
+
const bytes = buffer.subarray(0, length);
|
|
63
|
+
const content = bytes.toString("utf8");
|
|
64
|
+
if (!Buffer.from(content, "utf8").equals(bytes))
|
|
65
|
+
throw invalid();
|
|
66
|
+
return content;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await handle.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function metadata(content) {
|
|
73
|
+
const manifest = asRecord(JSON.parse(content));
|
|
74
|
+
if (!manifest || Object.keys(manifest).sort().join(",") !== "formatVersion,skills" || manifest.formatVersion !== 1
|
|
75
|
+
|| !Array.isArray(manifest.skills) || manifest.skills.length !== skillNames.length)
|
|
76
|
+
throw invalid();
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
return manifest.skills.map((value) => {
|
|
79
|
+
const item = asRecord(value);
|
|
80
|
+
if (!item || Object.keys(item).sort().join(",") !== "description,name" || typeof item.name !== "string"
|
|
81
|
+
|| !skillNames.some(name => name === item.name) || seen.has(item.name)
|
|
82
|
+
|| typeof item.description !== "string" || !item.description.trim() || item.description.trim() !== item.description
|
|
83
|
+
|| item.description.length > 1000 || /[\x00-\x1f\x7f]/.test(item.description))
|
|
84
|
+
throw invalid();
|
|
85
|
+
seen.add(item.name);
|
|
86
|
+
return { name: item.name, description: item.description };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function loadSkillAssets(packageRoot) {
|
|
90
|
+
try {
|
|
91
|
+
const requested = packageRoot ?? fileURLToPath(new URL("../", import.meta.url));
|
|
92
|
+
if (typeof requested !== "string" || !requested || !isAbsolute(requested) || requested.includes("\0"))
|
|
93
|
+
throw invalid();
|
|
94
|
+
const root = resolve(requested);
|
|
95
|
+
const directories = [];
|
|
96
|
+
let path = parse(root).root;
|
|
97
|
+
await addDirectory(directories, path);
|
|
98
|
+
for (const part of root.slice(path.length).split(sep).filter(Boolean)) {
|
|
99
|
+
path = join(path, part);
|
|
100
|
+
await addDirectory(directories, path);
|
|
101
|
+
}
|
|
102
|
+
const skills = join(root, "skills");
|
|
103
|
+
await addDirectory(directories, skills);
|
|
104
|
+
const entries = metadata(await readAsset(join(skills, "manifest.json"), manifestLimit, directories));
|
|
105
|
+
for (const name of skillNames)
|
|
106
|
+
await addDirectory(directories, join(skills, name));
|
|
107
|
+
const assets = [];
|
|
108
|
+
for (const item of entries) {
|
|
109
|
+
const content = await readAsset(join(skills, item.name, "SKILL.md"), contentLimit, directories);
|
|
110
|
+
// JSON strings are valid YAML double-quoted scalars; no general YAML parser is needed.
|
|
111
|
+
const prefix = `---\nname: ${item.name}\ndescription: ${JSON.stringify(item.description)}\n---\n`;
|
|
112
|
+
if (!content.startsWith(prefix) || !content.slice(prefix.length).trim())
|
|
113
|
+
throw invalid();
|
|
114
|
+
assets.push({ ...item, content, sha256: createHash("sha256").update(content, "utf8").digest("hex") });
|
|
115
|
+
}
|
|
116
|
+
await checkDirectories(directories);
|
|
117
|
+
return assets;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw invalid();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Bind only the files installed into one AI platform; the shared runtime bundle
|
|
124
|
+
// remains byte-for-byte independent of the selected identity.
|
|
125
|
+
export function bindSkillAgent(asset, name) {
|
|
126
|
+
if (name === undefined)
|
|
127
|
+
return asset;
|
|
128
|
+
const agent = agentIdentity(name), word = shellWord(agent.key);
|
|
129
|
+
const marker = asset.content.indexOf("\n---\n");
|
|
130
|
+
if (marker < 0)
|
|
131
|
+
throw invalid();
|
|
132
|
+
const prefix = asset.content.slice(0, marker + 5);
|
|
133
|
+
const body = asset.content.slice(marker + 5).replace(/\bcaotang(?=\s+(?:--|[a-z]))/g, `caotang --agent ${word}`);
|
|
134
|
+
const content = `${prefix}\n本技能使用的 AI 名字是 ${JSON.stringify(agent.key)}。每条草堂命令都必须保留 --agent,不能借用其它 AI 的连接。\n${body}`;
|
|
135
|
+
return { ...asset, content, sha256: createHash("sha256").update(content, "utf8").digest("hex") };
|
|
136
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, resolve } from "node:path";
|
|
4
|
+
import { CliError, expectOK, apiError, asRecord } from "./errors.js";
|
|
5
|
+
import { validateInput, invalidInput, inputDetails } from "./input-validation.js";
|
|
6
|
+
const maxBytes = 10485760;
|
|
7
|
+
const contentType = "application/pdf";
|
|
8
|
+
const runeLength = (value) => [...value].length;
|
|
9
|
+
function invalidFile() {
|
|
10
|
+
throw new CliError("invalid_upload", "请选择1至10485760字节的本地普通PDF文件。", "请明确指定本地PDF,不接受符号链接、网址、目录或标准输入。");
|
|
11
|
+
}
|
|
12
|
+
const unverifiedError = (status) => new CliError("upload_unverified", "尚未取得匹配的上传完成回执。", "请先在网页核对文件和卡片;不要再次PUT或自动申请替代上传凭证。", 2, status === undefined ? {} : { http_status: status });
|
|
13
|
+
function unverified(status) { throw unverifiedError(status); }
|
|
14
|
+
async function readPDF(path) {
|
|
15
|
+
let handle;
|
|
16
|
+
try {
|
|
17
|
+
if (!path || path === "-" || /^[a-z][a-z0-9+.-]*:/i.test(path))
|
|
18
|
+
invalidFile();
|
|
19
|
+
const name = basename(path);
|
|
20
|
+
if (!/\.pdf$/i.test(name) || runeLength(name) < 5 || runeLength(name) > 180 || /[\x00-\x1f\x7f]/.test(name))
|
|
21
|
+
invalidFile();
|
|
22
|
+
if (await realpath(path) !== resolve(path))
|
|
23
|
+
invalidFile();
|
|
24
|
+
const before = await lstat(path);
|
|
25
|
+
if (before.isSymbolicLink() || !before.isFile() || before.size < 1 || before.size > maxBytes)
|
|
26
|
+
invalidFile();
|
|
27
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
28
|
+
const opened = await handle.stat();
|
|
29
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size)
|
|
30
|
+
invalidFile();
|
|
31
|
+
// One extra byte detects growth without an unbounded readFile allocation.
|
|
32
|
+
const bytes = Buffer.alloc(opened.size + 1);
|
|
33
|
+
let length = 0;
|
|
34
|
+
while (length < bytes.length) {
|
|
35
|
+
const read = await handle.read(bytes, length, bytes.length - length, null);
|
|
36
|
+
if (read.bytesRead === 0)
|
|
37
|
+
break;
|
|
38
|
+
length += read.bytesRead;
|
|
39
|
+
}
|
|
40
|
+
const after = await handle.stat();
|
|
41
|
+
const current = await lstat(path);
|
|
42
|
+
if (current.isSymbolicLink() || current.dev !== opened.dev || current.ino !== opened.ino || await realpath(path) !== resolve(path) || length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || bytes.subarray(0, 5).toString("ascii") !== "%PDF-")
|
|
43
|
+
invalidFile();
|
|
44
|
+
return { name, bytes: bytes.subarray(0, length) };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return invalidFile();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await handle?.close().catch(() => { });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function grantFields(result, baseUrl, size) {
|
|
54
|
+
expectOK(result);
|
|
55
|
+
if (result.status !== 200 && result.status !== 201)
|
|
56
|
+
unverified(result.status);
|
|
57
|
+
const upload = asRecord(asRecord(result.body)?.upload);
|
|
58
|
+
if (!upload || typeof upload.file_key !== "string" || !upload.file_key || runeLength(upload.file_key) > 500 || /[\x00-\x1f\x7f]/.test(upload.file_key) || upload.method !== "PUT" || upload.size_bytes !== size || upload.content_type !== contentType || !Number.isSafeInteger(upload.max_bytes) || upload.max_bytes < size || upload.max_bytes > maxBytes || typeof upload.upload_url !== "string")
|
|
59
|
+
unverified();
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(upload.upload_url);
|
|
62
|
+
if (url.origin !== new URL(baseUrl).origin || url.username || url.password || url.search || url.hash || !/^\/api\/v1\/knowledge-files\/uploads\/[A-Za-z0-9_-]+$/.test(url.pathname))
|
|
63
|
+
unverified();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
unverified();
|
|
67
|
+
}
|
|
68
|
+
const required = asRecord(upload.required_headers);
|
|
69
|
+
if (!required || Object.keys(required).length !== 2)
|
|
70
|
+
unverified();
|
|
71
|
+
const headers = {};
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const [name, value] of Object.entries(required)) {
|
|
74
|
+
const key = name.toLowerCase();
|
|
75
|
+
if (!["content-type", "x-opc-upload-token"].includes(key) || seen.has(key) || typeof value !== "string" || !value || value.length > 8192 || /[\s\x00-\x1f\x7f]/.test(value))
|
|
76
|
+
unverified();
|
|
77
|
+
if (key === "content-type" && value !== contentType)
|
|
78
|
+
unverified();
|
|
79
|
+
seen.add(key);
|
|
80
|
+
headers[name] = value;
|
|
81
|
+
}
|
|
82
|
+
if (!seen.has("content-type") || !seen.has("x-opc-upload-token"))
|
|
83
|
+
unverified();
|
|
84
|
+
return { fileKey: upload.file_key, url: upload.upload_url, headers };
|
|
85
|
+
}
|
|
86
|
+
export async function executeUpload(path, flags, ctx) {
|
|
87
|
+
const operationAudit = [];
|
|
88
|
+
try {
|
|
89
|
+
validateInput(Object.fromEntries(Object.entries(flags).filter(([, value]) => value !== undefined)), {
|
|
90
|
+
fields: {
|
|
91
|
+
card: value => typeof value === "string" && !!value.trim() && ![".", ".."].includes(value) && !/[\x00-\x1f\x7f]/.test(value),
|
|
92
|
+
"source-title": value => typeof value === "string" && runeLength(value) <= 300,
|
|
93
|
+
summary: value => typeof value === "string" && runeLength(value) <= 1200,
|
|
94
|
+
}, required: ["card"],
|
|
95
|
+
}, ["upload"]);
|
|
96
|
+
let card;
|
|
97
|
+
try {
|
|
98
|
+
card = encodeURIComponent(flags.card);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw invalidInput(["upload"], { invalid: ["card"] });
|
|
102
|
+
}
|
|
103
|
+
const { name, bytes } = await readPDF(path);
|
|
104
|
+
const issued = await ctx.api.request("/api/v1/agent/m2/knowledge-files/upload-url", { method: "POST", token: ctx.token, body: { file_name: name, size_bytes: bytes.length } });
|
|
105
|
+
const grant = grantFields(issued, ctx.api.baseUrl, bytes.length);
|
|
106
|
+
const grantBody = asRecord(issued.body);
|
|
107
|
+
if (grantBody?.audit_pending === true) {
|
|
108
|
+
if (grantBody.audit_recovery !== "automatic" && grantBody.audit_recovery !== "manual_required")
|
|
109
|
+
throw new CliError("upload_audit_unverified", "无法确认上传凭证的审计状态。", "请先在网页核对再处理上传,不要自动申请替代凭证。", 7);
|
|
110
|
+
operationAudit.push({ stage: "upload_grant", http_status: issued.status, audit_pending: true, audit_recovery: grantBody.audit_recovery });
|
|
111
|
+
}
|
|
112
|
+
const stored = await ctx.api.upload(grant.url, grant.headers, bytes);
|
|
113
|
+
if (stored.status < 200 || stored.status >= 300)
|
|
114
|
+
throw apiError(stored);
|
|
115
|
+
const receipt = asRecord(stored.body);
|
|
116
|
+
if (![200, 201].includes(stored.status) || !receipt || Object.keys(receipt).length !== 3 || receipt.file_key !== grant.fileKey || receipt.size_bytes !== bytes.length || receipt.content_type !== contentType)
|
|
117
|
+
unverified(stored.status);
|
|
118
|
+
const attached = expectOK(await ctx.api.request(`/api/v1/agent/m2/knowledge-cards/${card}/file-source`, {
|
|
119
|
+
method: "POST", token: ctx.token,
|
|
120
|
+
body: { file_key: grant.fileKey, ...(flags["source-title"] === undefined ? {} : { source_title: flags["source-title"] }), ...(flags.summary === undefined ? {} : { summary: flags.summary }) }
|
|
121
|
+
}));
|
|
122
|
+
return operationAudit.length ? { ...attached, operationAudit } : attached;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
const safe = error instanceof CliError ? error : unverifiedError();
|
|
126
|
+
if (safe.code === "invalid_upload")
|
|
127
|
+
Object.assign(safe.details, inputDetails(["upload"], { invalid: ["<pdf>"] }));
|
|
128
|
+
throw operationAudit.length ? new CliError(safe.code, safe.message, safe.nextAction, safe.exitCode, { ...safe.details, operation_audit: operationAudit }) : safe;
|
|
129
|
+
}
|
|
130
|
+
}
|