@yijiancaotang/cli 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +311 -2
- package/bin/caotang.cjs +15 -0
- package/dist/agent-identity.js +14 -0
- package/dist/auth.js +340 -0
- 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 +85 -0
- package/dist/cli.js +257 -0
- package/dist/command-help.js +105 -0
- package/dist/commands.js +356 -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 +221 -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 +32 -0
- package/dist/habit-wire.js +87 -0
- package/dist/http.js +211 -0
- package/dist/input-validation.js +76 -0
- package/dist/installation.js +761 -0
- package/dist/knowledge-growth.js +85 -0
- package/dist/local-commands.js +229 -0
- package/dist/local-lock.js +245 -0
- package/dist/local-records.js +149 -0
- package/dist/local-types.js +3 -0
- package/dist/managed-skill-baseline.js +35 -0
- package/dist/missing-sections.js +72 -0
- package/dist/onboarding.js +48 -0
- package/dist/output.js +54 -0
- package/dist/pending-requests.js +45 -0
- package/dist/platforms.js +239 -0
- package/dist/query-daily.js +70 -0
- package/dist/query-search.js +43 -0
- package/dist/query-validation.js +45 -0
- 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/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/dist/write-routing.js +26 -0
- package/package.json +27 -4
- package/skills/caotang-context/SKILL.md +52 -0
- package/skills/caotang-first-connect/SKILL.md +74 -0
- package/skills/caotang-project-progress/SKILL.md +91 -0
- package/skills/caotang-save-knowledge/SKILL.md +42 -0
- package/skills/caotang-schedule/SKILL.md +54 -0
- package/skills/caotang-search/SKILL.md +79 -0
- package/skills/caotang-workday/SKILL.md +81 -0
- package/skills/manifest.json +33 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { shellWord } from "./agent-identity.js";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { authLogin } from "./auth.js";
|
|
7
|
+
import { doctor } from "./doctor.js";
|
|
8
|
+
import { CliError, asRecord } from "./errors.js";
|
|
9
|
+
import { inspectLocalInstallation, inspectManagedOrigins, installLocal } from "./installation.js";
|
|
10
|
+
import { isPlatformId, platformSupported } from "./local-types.js";
|
|
11
|
+
import { detectPlatforms, knownSkillTargets } from "./platforms.js";
|
|
12
|
+
import { loadSkillAssets } from "./skill-assets.js";
|
|
13
|
+
import { LocalRecords } from "./local-records.js";
|
|
14
|
+
import { defaultConnectionNames, inspectAccountState, onboardingMessage } from "./onboarding.js";
|
|
15
|
+
import { connectionCopy, setupFailureMessage } from "./connection-copy.js";
|
|
16
|
+
const root = () => fileURLToPath(new URL("../", import.meta.url));
|
|
17
|
+
const invalid = () => new CliError("invalid_arguments", "命令或参数无效。", "运行 --help;平台支持claude-code、codex、openclaw,以及仅macOS支持的kimi-code、kimi-work和workbuddy。");
|
|
18
|
+
export function selectedPlatforms(value, operatingSystem = process.platform) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
throw invalid();
|
|
23
|
+
const values = value.split(",").map(item => item.trim());
|
|
24
|
+
if (!values.length || new Set(values).size !== values.length || values.some(item => !isPlatformId(item)))
|
|
25
|
+
throw invalid();
|
|
26
|
+
if (values.some(id => !platformSupported(id, operatingSystem)))
|
|
27
|
+
throw new CliError("platform_unsupported", "所选新增平台尚不支持当前系统。", "kimi-code、kimi-work和workbuddy仅支持macOS本机安装;不要套用或猜测其它系统的技能目录。");
|
|
28
|
+
return values;
|
|
29
|
+
}
|
|
30
|
+
export async function localVersion(packageRoot = root()) {
|
|
31
|
+
const failed = () => new CliError("version_unavailable", "无法核对当前命令行版本。", "请使用完整的一间草堂命令行构建;不要运行不明升级脚本。", 7);
|
|
32
|
+
try {
|
|
33
|
+
const file = await open(join(packageRoot, "package.json"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
34
|
+
try {
|
|
35
|
+
const info = await file.stat();
|
|
36
|
+
if (!info.isFile() || info.nlink !== 1 || info.size < 1 || info.size > 32768)
|
|
37
|
+
throw failed();
|
|
38
|
+
const bytes = Buffer.alloc(32769), { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
39
|
+
if (bytesRead !== info.size || bytesRead > 32768)
|
|
40
|
+
throw failed();
|
|
41
|
+
const data = asRecord(JSON.parse(bytes.subarray(0, bytesRead).toString("utf8")));
|
|
42
|
+
if (data?.name !== "@yijiancaotang/cli" || (data.private !== true && asRecord(data.publishConfig)?.access !== "public") || typeof data.version !== "string" || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(data.version))
|
|
43
|
+
throw failed();
|
|
44
|
+
return { status: 200, body: { brand: "一间草堂", name: data.name, version: data.version, node_version: process.versions.node, release_status: "released", update_policy: "manual" } };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await file.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw failed();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function manualUpdateInstructions() {
|
|
55
|
+
return { status: 200, body: { operation: "update", update_policy: "manual", next_action: "运行 npx @yijiancaotang/cli@latest setup --agent <你的名字> 更新到最新版。" } };
|
|
56
|
+
}
|
|
57
|
+
function connectionTimeInBeijing(value) {
|
|
58
|
+
if (!value)
|
|
59
|
+
return undefined;
|
|
60
|
+
const timestamp = Date.parse(value);
|
|
61
|
+
if (!Number.isFinite(timestamp))
|
|
62
|
+
return undefined;
|
|
63
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
64
|
+
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
|
|
65
|
+
hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
66
|
+
}).formatToParts(timestamp);
|
|
67
|
+
const fields = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
|
68
|
+
return `${fields.year}-${fields.month}-${fields.day} ${fields.hour}:${fields.minute}`;
|
|
69
|
+
}
|
|
70
|
+
const recordsFor = (ctx) => ctx.agent ? new LocalRecords(ctx.homeDir, ctx.agent, ctx.auth.environment ?? "production", new URL(ctx.auth.api.baseUrl).origin) : undefined;
|
|
71
|
+
async function targetsFor(ctx, ids, setup, assets, recorded) {
|
|
72
|
+
const inspect = (targets) => inspectManagedOrigins({ ...ctx, agentName: ctx.agent?.key, assets, targets });
|
|
73
|
+
if (ctx.agent) {
|
|
74
|
+
if (ids && ids.length !== 1)
|
|
75
|
+
throw new CliError("invalid_arguments", "每次接入只给一个 AI 平台安装技能。", "请带自己的 --agent 名字,一次只选一个 --platform;其它 AI 分别接入。");
|
|
76
|
+
if (ids === undefined && recorded.length === 0) {
|
|
77
|
+
const candidates = await knownSkillTargets({ ...ctx, skipInvalidCandidates: true }), origins = await inspect(candidates);
|
|
78
|
+
const own = candidates.filter(target => origins.platforms.some(platform => platform.id === target.id && platform.skill_directory === target.skillDirectory && platform.ownKnownCount > 0))
|
|
79
|
+
.map(target => ({ ...target, detected: true, evidence: "skills" }));
|
|
80
|
+
if (own.length > 0 || !ctx.agent.platform)
|
|
81
|
+
return { targets: own, origins };
|
|
82
|
+
}
|
|
83
|
+
ids = ids ?? (recorded.length ? recorded.map(target => target.id) : ctx.agent.platform ? [ctx.agent.platform] : []);
|
|
84
|
+
}
|
|
85
|
+
const redirected = (id) => id === "claude-code" ? ctx.env.CLAUDE_CONFIG_DIR !== undefined
|
|
86
|
+
: id === "openclaw" ? ctx.env.OPENCLAW_STATE_DIR !== undefined || ctx.env.OPENCLAW_HOME !== undefined
|
|
87
|
+
: id === "kimi-code" && ctx.env.KIMI_CODE_HOME !== undefined;
|
|
88
|
+
const preferred = recorded.filter(target => (!ids || ids.includes(target.id)) && !redirected(target.id));
|
|
89
|
+
if (preferred.some(target => !platformSupported(target.id, ctx.operatingSystem)))
|
|
90
|
+
throw new CliError("platform_unsupported", "已记录的技能平台不支持当前系统。", "请在原支持的系统上检查该平台;不会猜测新目录或搬动技能。", 2);
|
|
91
|
+
const discoveryIds = ids?.filter(id => !preferred.some(target => target.id === id));
|
|
92
|
+
// A recorded custom target must not cause inspection of an unrelated default
|
|
93
|
+
// directory just to rediscover the same platform.
|
|
94
|
+
const discovered = discoveryIds?.length === 0 ? [] : await detectPlatforms({ homeDir: ctx.homeDir, env: ctx.env, applicationRoots: ctx.applicationRoots, operatingSystem: ctx.operatingSystem, platformIds: discoveryIds });
|
|
95
|
+
const all = [...preferred.map(target => ({ ...target })), ...discovered];
|
|
96
|
+
const origins = await inspect(all);
|
|
97
|
+
for (const target of all) {
|
|
98
|
+
if (ctx.agent && !target.detected && origins.platforms.some(platform => platform.id === target.id && platform.skill_directory === target.skillDirectory && platform.ownKnownCount > 0)) {
|
|
99
|
+
target.detected = true;
|
|
100
|
+
target.evidence = "skills";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { targets: ids ? all.filter(target => ids.includes(target.id)) : setup ? all.filter(target => target.detected) : all, origins };
|
|
104
|
+
}
|
|
105
|
+
export async function localDoctor(ctx, flags, authFailure) {
|
|
106
|
+
return doctor({
|
|
107
|
+
auth: ctx.auth, authFailure, agent: ctx.agent,
|
|
108
|
+
inspectLocal: async () => {
|
|
109
|
+
const assets = await loadSkillAssets(ctx.packageRoot ?? root());
|
|
110
|
+
const recorded = await recordsFor(ctx)?.targets() ?? [];
|
|
111
|
+
const selection = await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), false, assets, recorded);
|
|
112
|
+
const inspected = await inspectLocalInstallation({ ...ctx, agentName: ctx.agent?.key, assets, targets: selection.targets });
|
|
113
|
+
const platforms = inspected.platforms.map(platform => {
|
|
114
|
+
const origin = selection.origins.platforms.find(origin => origin.id === platform.id && origin.skill_directory === platform.skill_directory);
|
|
115
|
+
return origin ? { ...platform, skills: origin.skills } : platform;
|
|
116
|
+
});
|
|
117
|
+
const issues = inspected.issues.filter(issue => !["skill_missing", "skill_modified", "skill_unsafe"].includes(issue.code));
|
|
118
|
+
let command = inspected.command;
|
|
119
|
+
if (selection.origins.command.legacyManaged) {
|
|
120
|
+
command = { ...command, installed: true, ...selection.origins.command };
|
|
121
|
+
for (let index = issues.length - 1; index >= 0; index--)
|
|
122
|
+
if (issues[index].code === "command_modified")
|
|
123
|
+
issues.splice(index, 1);
|
|
124
|
+
issues.push({ code: "command_outdated", message: "已核对为旧版受管命令行,需要更新。", next_action: "沿用原来的名字运行 npx @yijiancaotang/cli@latest setup --agent <你的名字> 更新,不需要重新批准连接。" });
|
|
125
|
+
}
|
|
126
|
+
const skillPlatforms = selection.targets.filter(target => recorded.some(record => record.id === target.id)
|
|
127
|
+
|| selection.origins.platforms.some(origin => origin.id === target.id && origin.skill_directory === target.skillDirectory && origin.ownKnownCount > 0)).map(target => target.id);
|
|
128
|
+
return { ...inspected, command, platforms, issues, skill_platforms: skillPlatforms };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export async function localSetup(ctx, flags) {
|
|
133
|
+
const records = recordsFor(ctx), recorded = await records?.targets() ?? [];
|
|
134
|
+
const assets = await loadSkillAssets(ctx.packageRoot ?? root()), { targets } = await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), true, assets, recorded);
|
|
135
|
+
const local = { ...ctx, agentName: ctx.agent?.key, assets, targets };
|
|
136
|
+
const installed = await installLocal(local);
|
|
137
|
+
await records?.rememberInstallation(targets, assets, installed.bundleSha);
|
|
138
|
+
for (const target of targets) {
|
|
139
|
+
if (!target.detected) {
|
|
140
|
+
target.detected = true;
|
|
141
|
+
target.evidence = "skills";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const newEntries = installed.newlyInstalledPlatforms;
|
|
145
|
+
let authFailure;
|
|
146
|
+
let authorizationAttempted = false;
|
|
147
|
+
const currentSecrets = new Set(ctx.auth.tokenOverride ? [ctx.auth.tokenOverride] : []);
|
|
148
|
+
// Observe the actual start request after authLogin's credential/abort checks.
|
|
149
|
+
// Validation and doctor requests do not constitute a new authorization attempt.
|
|
150
|
+
const observedAuth = {
|
|
151
|
+
...ctx.auth,
|
|
152
|
+
api: {
|
|
153
|
+
baseUrl: ctx.auth.api.baseUrl,
|
|
154
|
+
request(path, options) {
|
|
155
|
+
if (path === "/api/v1/agent/device-auth/start" && options?.method === "POST")
|
|
156
|
+
authorizationAttempted = true;
|
|
157
|
+
if (path === "/api/v1/agent/mcp-token/validate" && typeof options?.token === "string")
|
|
158
|
+
currentSecrets.add(options.token);
|
|
159
|
+
return ctx.auth.api.request(path, options);
|
|
160
|
+
},
|
|
161
|
+
upload: (url, headers, bytes) => ctx.auth.api.upload(url, headers, bytes),
|
|
162
|
+
outputSecrets: () => ctx.auth.api.outputSecrets?.() ?? [],
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
let newAuthorizationOwner;
|
|
166
|
+
let loginIdentity;
|
|
167
|
+
let loginResult = null;
|
|
168
|
+
const names = defaultConnectionNames(targets.map(target => target.id), ctx.computerName, ctx.agent?.name);
|
|
169
|
+
try {
|
|
170
|
+
const login = await authLogin(observedAuth, { device_name: flags["device-name"] ?? names.deviceName, client_name: flags["client-name"] ?? names.clientName, noOpen: flags["no-open"] === true });
|
|
171
|
+
const who = asRecord(login.body);
|
|
172
|
+
loginResult = who;
|
|
173
|
+
if (typeof who?.user_id === "string" && typeof who.token_id === "string" && (who.kind === "manual" || who.kind === "device") && (who.scope === "read_only" || who.scope === "read_write")) {
|
|
174
|
+
loginIdentity = { userId: who.user_id, tokenId: who.token_id, kind: who.kind, scope: who.scope };
|
|
175
|
+
}
|
|
176
|
+
if (who?.new_authorization_confirmed === true && typeof who.user_id === "string")
|
|
177
|
+
newAuthorizationOwner = who.user_id;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
authFailure = error instanceof CliError ? error : new CliError("authorization_delivery_unknown", "无法确认授权操作的结果。", "先在网页核对并吊销未保存的设备,不要自动重新签发。", 6);
|
|
181
|
+
}
|
|
182
|
+
let checkedIdentity;
|
|
183
|
+
const report = await doctor({ auth: ctx.auth, authFailure, agent: ctx.agent, inspectLocal: async () => ({ ...await inspectLocalInstallation(local), skill_platforms: targets.map(target => target.id) }), onIdentity: who => { checkedIdentity = who; } });
|
|
184
|
+
const body = asRecord(report.result.body);
|
|
185
|
+
if (!body)
|
|
186
|
+
throw new CliError("diagnostic_unavailable", "本地安装已执行,但无法确认最终诊断。", "先运行doctor只读核对,不要直接重新授权。", 7);
|
|
187
|
+
const sameConnection = loginIdentity && checkedIdentity && ["userId", "tokenId", "kind", "scope"].every(key => loginIdentity[key] === checkedIdentity[key]);
|
|
188
|
+
const identityChanged = body.ready === true && !sameConnection;
|
|
189
|
+
if (identityChanged) {
|
|
190
|
+
const issue = { code: "authorization_identity_changed", message: "接入前后的连接核对不一致,尚不能确认接好。", next_action: "请先在网页 Agent 管理和本机 auth status 核对连接;不自动切换账号、重新授权或写入。" };
|
|
191
|
+
body.ready = false;
|
|
192
|
+
body.auth = { status: "unknown" };
|
|
193
|
+
body.issues = [...(Array.isArray(body.issues) ? body.issues : []), issue];
|
|
194
|
+
body.next_actions = [...(Array.isArray(body.next_actions) ? body.next_actions : []), issue.next_action];
|
|
195
|
+
report.exitCode = 6;
|
|
196
|
+
}
|
|
197
|
+
const confirmed = authorizationAttempted && newAuthorizationOwner !== undefined && report.exitCode === 0 && body.ready === true;
|
|
198
|
+
const accountState = newEntries.length && body.ready === true && checkedIdentity ? await inspectAccountState(ctx.auth, checkedIdentity.userId) : "unknown";
|
|
199
|
+
const greeting = body.ready === true && checkedIdentity ? onboardingMessage(newEntries, accountState, checkedIdentity.scope)
|
|
200
|
+
: { account_state: "unknown", onboarding: { action: "none", newly_connected_platforms: [] }, user_message: setupFailureMessage(identityChanged ? "authorization_identity_changed" : authFailure?.code) };
|
|
201
|
+
const reused = Boolean(sameConnection && loginResult?.reused_existing === true);
|
|
202
|
+
let connection;
|
|
203
|
+
if (sameConnection && ctx.agent && checkedIdentity) {
|
|
204
|
+
if (reused) {
|
|
205
|
+
connection = ctx.auth.tokenOverride !== undefined ? { connection_name: ctx.agent.key, connection_name_source: "environment_override" }
|
|
206
|
+
: await records?.connection(checkedIdentity.tokenId, [...currentSecrets]) ?? { connection_name: ctx.agent.key, connection_name_source: "local_agent_key" };
|
|
207
|
+
}
|
|
208
|
+
else if (body.ready === true && loginResult?.new_authorization_confirmed === true && typeof loginResult.connection_name === "string" && typeof loginResult.connected_at === "string") {
|
|
209
|
+
connection = { connection_name: loginResult.connection_name, connection_name_source: "local_authorization_record", connected_at: loginResult.connected_at, connection_time_source: "device_authorization" };
|
|
210
|
+
await records?.rememberConnection(checkedIdentity.tokenId, connection, [...currentSecrets]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const onboardingCopy = reused && checkedIdentity && (connection?.connection_name_source !== "environment_override"
|
|
214
|
+
? greeting.onboarding.action !== "none" || checkedIdentity.scope === "read_only"
|
|
215
|
+
: greeting.onboarding.action === "none" && checkedIdentity.scope === "read_only")
|
|
216
|
+
? { onboarding_message: greeting.onboarding.action !== "none" ? greeting.user_message : connectionCopy.read_only } : {};
|
|
217
|
+
const connectionDisplayTime = connectionTimeInBeijing(connection?.connected_at);
|
|
218
|
+
const reuseCopy = reused && connection && connection.connection_name_source !== "environment_override" ? {
|
|
219
|
+
user_message: "这个名字已经接过了,沿用原来的连接,不用再批准。",
|
|
220
|
+
next_action: `已沿用名为 ${connection.connection_name} 的连接${connectionDisplayTime ? `(${connectionDisplayTime} 接入)` : ""}。如果你不是它,请用你自己的名字重新运行 setup --agent <你的名字>,不要继续用这条连接。`,
|
|
221
|
+
...(connection.connection_name_source === "local_agent_key" ? { connection_name_note: "本机仅记有规范化名字,原连接名称和接入时间未记录。" } : {}),
|
|
222
|
+
} : reused && connection?.connection_name_source === "environment_override" ? {
|
|
223
|
+
reuse_notice: "本次沿用环境中提供的授权,不是本机保存的同名连接。若要独立接入,请由本人先处理环境授权覆盖,再用自己的名字运行 setup --agent <你的名字>。",
|
|
224
|
+
} : {};
|
|
225
|
+
return { exitCode: report.exitCode, result: { status: 200, body: { ...body, operation: "setup", local_installation: { changed: installed.changed, bundle_sha: installed.bundleSha }, authorization_attempted: authorizationAttempted,
|
|
226
|
+
new_authorization_confirmed: confirmed, ...(confirmed ? { new_authorization_owner_id: newAuthorizationOwner } : {}), reused_existing: reused, ...connection, ...greeting, ...onboardingCopy, ...reuseCopy,
|
|
227
|
+
...(ctx.agent && !targets.length ? { generic_usage: { message: "暂未定位到这个名字的专用技能;若曾使用自定义目录,请保留原目录参数再检查。仍可按下列命令使用自己的连接。", commands: ["doctor", "context --start", "projects", "knowledge", "search", "auth status"].map(command => `caotang --agent ${shellWord(ctx.agent.name)} ${command}`) } } : {}),
|
|
228
|
+
...(body.ready === true ? { next_conversation: "请开新对话说‘开工了’。" } : {}) } } };
|
|
229
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { constants, openSync, closeSync, lstatSync, fstatSync, readSync, writeSync, fsyncSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { asRecord } from "./errors.js";
|
|
5
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
6
|
+
const chunkSize = 65536;
|
|
7
|
+
const recordLimit = 256;
|
|
8
|
+
export function deletionDenied(error) {
|
|
9
|
+
return ["EPERM", "EACCES", "EROFS"].includes(String(asRecord(error)?.code))
|
|
10
|
+
|| (error instanceof Error && error.message.includes("[safe-delete] broker denied delete"));
|
|
11
|
+
}
|
|
12
|
+
function dead(pid) {
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
return asRecord(error)?.code === "ESRCH";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Local regular files only: a single O_APPEND write orders each ticket. The first
|
|
22
|
+
// unreleased live/unknown ticket owns the lock. Never replace or truncate this
|
|
23
|
+
// inode: delayed contenders must see the same history, even after a crash.
|
|
24
|
+
// File count is constant; journal bytes grow with use. Do not mix with the old
|
|
25
|
+
// rename/compaction protocol or put this journal on a network filesystem.
|
|
26
|
+
export function acquireLocalLock(path, parent, failures) {
|
|
27
|
+
const { busy, unsafe } = failures, name = basename(path);
|
|
28
|
+
const ticket = { op: "acquire", pid: process.pid, id: randomUUID() };
|
|
29
|
+
let fd = -1, identity, appended = false;
|
|
30
|
+
const check = (info) => {
|
|
31
|
+
if (!info.isFile() || typeof process.getuid !== "function" || info.uid !== process.getuid()
|
|
32
|
+
|| (info.nlink !== 1 && info.nlink !== 0) || (info.mode & 0o7777) !== 0o600
|
|
33
|
+
|| !Number.isSafeInteger(info.size) || info.size < 0)
|
|
34
|
+
throw unsafe();
|
|
35
|
+
if (info.nlink === 0)
|
|
36
|
+
throw busy();
|
|
37
|
+
};
|
|
38
|
+
const pinned = (action) => {
|
|
39
|
+
const original = process.cwd();
|
|
40
|
+
try {
|
|
41
|
+
process.chdir(parent.path);
|
|
42
|
+
const info = lstatSync(".");
|
|
43
|
+
if (dirname(path) !== parent.path || !info.isDirectory() || !same(info, parent.identity)
|
|
44
|
+
|| info.uid !== parent.identity.uid || (info.mode & 0o7777) !== 0o700)
|
|
45
|
+
throw unsafe();
|
|
46
|
+
return action();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
process.chdir(original);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const current = () => {
|
|
53
|
+
const opened = fstatSync(fd);
|
|
54
|
+
check(opened);
|
|
55
|
+
let named;
|
|
56
|
+
try {
|
|
57
|
+
named = lstatSync(name);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (asRecord(error)?.code === "ENOENT")
|
|
61
|
+
throw busy();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
check(named);
|
|
65
|
+
if (!same(opened, identity) || !same(named, identity))
|
|
66
|
+
throw busy();
|
|
67
|
+
};
|
|
68
|
+
const decode = (line) => {
|
|
69
|
+
if (!line.length || Buffer.byteLength(line) > recordLimit)
|
|
70
|
+
throw busy();
|
|
71
|
+
let entry;
|
|
72
|
+
try {
|
|
73
|
+
entry = JSON.parse(line);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw busy();
|
|
77
|
+
}
|
|
78
|
+
if (!entry || Object.keys(entry).sort().join(",") !== "id,op,pid"
|
|
79
|
+
|| !["acquire", "release"].includes(entry.op) || !Number.isSafeInteger(entry.pid)
|
|
80
|
+
|| entry.pid <= 0 || entry.pid > 2147483647 || typeof entry.id !== "string"
|
|
81
|
+
|| !/^[a-f0-9-]{36}$/.test(entry.id))
|
|
82
|
+
throw busy();
|
|
83
|
+
return entry;
|
|
84
|
+
};
|
|
85
|
+
const legacyMarkers = () => {
|
|
86
|
+
for (const legacy of [...Array.from({ length: 16 }, (_, index) => `${name}.admission-${index}`), `${name}.admission-released`, `${name}.next`]) {
|
|
87
|
+
let before;
|
|
88
|
+
try {
|
|
89
|
+
before = lstatSync(legacy);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (asRecord(error)?.code === "ENOENT")
|
|
93
|
+
continue;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
check(before);
|
|
97
|
+
if (before.size > recordLimit)
|
|
98
|
+
throw unsafe();
|
|
99
|
+
const reader = openSync(legacy, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
100
|
+
try {
|
|
101
|
+
const opened = fstatSync(reader);
|
|
102
|
+
check(opened);
|
|
103
|
+
if (opened.size > recordLimit)
|
|
104
|
+
throw unsafe();
|
|
105
|
+
if (!same(opened, before))
|
|
106
|
+
throw busy();
|
|
107
|
+
const bytes = Buffer.alloc(recordLimit + 1);
|
|
108
|
+
let count = 0;
|
|
109
|
+
while (count < bytes.length) {
|
|
110
|
+
const read = readSync(reader, bytes, count, bytes.length - count, count);
|
|
111
|
+
if (!read)
|
|
112
|
+
break;
|
|
113
|
+
count += read;
|
|
114
|
+
}
|
|
115
|
+
const after = fstatSync(reader);
|
|
116
|
+
check(after);
|
|
117
|
+
let named;
|
|
118
|
+
try {
|
|
119
|
+
named = lstatSync(legacy);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (asRecord(error)?.code === "ENOENT")
|
|
123
|
+
throw busy();
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
check(named);
|
|
127
|
+
if (count > recordLimit || after.size > recordLimit || named.size > recordLimit)
|
|
128
|
+
throw unsafe();
|
|
129
|
+
if (!same(after, opened) || !same(after, named) || before.size !== after.size
|
|
130
|
+
|| before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || count !== after.size)
|
|
131
|
+
throw busy();
|
|
132
|
+
if (!count || bytes[count - 1] !== 10)
|
|
133
|
+
throw busy();
|
|
134
|
+
const entry = decode(bytes.subarray(0, count - 1).toString("utf8"));
|
|
135
|
+
if (entry.op !== "release" && !dead(entry.pid))
|
|
136
|
+
throw busy();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
closeSync(reader);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const pending = () => {
|
|
144
|
+
current();
|
|
145
|
+
const before = fstatSync(fd), buffer = Buffer.alloc(chunkSize);
|
|
146
|
+
const active = new Map(), seen = new Set();
|
|
147
|
+
let offset = 0, remainder = "";
|
|
148
|
+
while (offset < before.size) {
|
|
149
|
+
const count = readSync(fd, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
150
|
+
if (!count)
|
|
151
|
+
throw busy();
|
|
152
|
+
offset += count;
|
|
153
|
+
const lines = (remainder + buffer.subarray(0, count).toString("utf8")).split("\n");
|
|
154
|
+
remainder = lines.pop();
|
|
155
|
+
if (Buffer.byteLength(remainder) > recordLimit)
|
|
156
|
+
throw busy();
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const entry = decode(line);
|
|
159
|
+
if (entry.op === "acquire") {
|
|
160
|
+
if (seen.has(entry.id))
|
|
161
|
+
throw busy();
|
|
162
|
+
seen.add(entry.id);
|
|
163
|
+
active.set(entry.id, entry);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
if (active.get(entry.id)?.pid !== entry.pid)
|
|
167
|
+
throw busy();
|
|
168
|
+
active.delete(entry.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
current();
|
|
173
|
+
const after = fstatSync(fd);
|
|
174
|
+
if (remainder || after.size < before.size
|
|
175
|
+
|| (after.size === before.size && (after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)))
|
|
176
|
+
throw busy();
|
|
177
|
+
return [...active.values()].filter(entry => !dead(entry.pid));
|
|
178
|
+
};
|
|
179
|
+
const append = (entry) => {
|
|
180
|
+
current();
|
|
181
|
+
const bytes = Buffer.from(JSON.stringify(entry) + "\n");
|
|
182
|
+
if (bytes.length > recordLimit || writeSync(fd, bytes) !== bytes.length)
|
|
183
|
+
throw busy();
|
|
184
|
+
fsyncSync(fd);
|
|
185
|
+
current();
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
pinned(() => {
|
|
189
|
+
let created = false;
|
|
190
|
+
try {
|
|
191
|
+
fd = openSync(name, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_APPEND | constants.O_NOFOLLOW, 0o600);
|
|
192
|
+
created = true;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (asRecord(error)?.code !== "EEXIST")
|
|
196
|
+
throw error;
|
|
197
|
+
const before = lstatSync(name);
|
|
198
|
+
if (before.isDirectory())
|
|
199
|
+
throw busy();
|
|
200
|
+
check(before);
|
|
201
|
+
fd = openSync(name, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
202
|
+
check(fstatSync(fd));
|
|
203
|
+
if (!same(before, fstatSync(fd)))
|
|
204
|
+
throw busy();
|
|
205
|
+
}
|
|
206
|
+
identity = fstatSync(fd);
|
|
207
|
+
current();
|
|
208
|
+
legacyMarkers();
|
|
209
|
+
if (created ? identity.size !== 0 : pending().length > 0)
|
|
210
|
+
throw busy();
|
|
211
|
+
append(ticket);
|
|
212
|
+
appended = true;
|
|
213
|
+
if (pending()[0]?.id !== ticket.id)
|
|
214
|
+
throw busy();
|
|
215
|
+
current();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (appended) {
|
|
220
|
+
try {
|
|
221
|
+
pinned(() => append({ ...ticket, op: "release" }));
|
|
222
|
+
}
|
|
223
|
+
catch { /* Keep uncertain ownership closed. */ }
|
|
224
|
+
}
|
|
225
|
+
if (fd !== -1)
|
|
226
|
+
closeSync(fd);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
let released = false;
|
|
230
|
+
return () => {
|
|
231
|
+
if (released)
|
|
232
|
+
throw busy();
|
|
233
|
+
released = true;
|
|
234
|
+
try {
|
|
235
|
+
pinned(() => {
|
|
236
|
+
if (pending()[0]?.id !== ticket.id)
|
|
237
|
+
throw busy();
|
|
238
|
+
append({ ...ticket, op: "release" });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
closeSync(fd);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|