@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.
Files changed (50) hide show
  1. package/README.md +70 -26
  2. package/dist/auth.js +7 -4
  3. package/dist/calendar-contract.js +358 -0
  4. package/dist/calendar-help.js +12 -0
  5. package/dist/calendar.js +64 -0
  6. package/dist/capability-result.js +5 -0
  7. package/dist/cli.js +58 -10
  8. package/dist/command-help.js +18 -8
  9. package/dist/commands.js +11 -0
  10. package/dist/doctor.js +13 -5
  11. package/dist/generated/agent-operations.js +12 -0
  12. package/dist/habit-read.js +3 -1
  13. package/dist/habit-wire.js +36 -5
  14. package/dist/handoff-read.js +29 -0
  15. package/dist/handoff-wire.js +42 -0
  16. package/dist/http.js +11 -1
  17. package/dist/input-validation.js +14 -0
  18. package/dist/installation.js +334 -45
  19. package/dist/local-commands.js +133 -16
  20. package/dist/local-records.js +150 -0
  21. package/dist/local-types.js +1 -0
  22. package/dist/managed-skill-baseline.js +35 -0
  23. package/dist/missing-sections.js +1 -0
  24. package/dist/onboarding.js +4 -20
  25. package/dist/output.js +7 -1
  26. package/dist/platforms.js +69 -12
  27. package/dist/project-binding-cli.js +110 -0
  28. package/dist/project-binding.js +370 -0
  29. package/dist/query-search.js +3 -2
  30. package/dist/reviewed-managed-bundle-baseline.js +356 -0
  31. package/dist/schedule-contract.js +289 -0
  32. package/dist/schedule-help.js +35 -0
  33. package/dist/schedule-start-wire.js +80 -0
  34. package/dist/schedule.js +70 -0
  35. package/dist/setup-guidance.js +40 -0
  36. package/dist/skill-assets.js +1 -1
  37. package/dist/skills-export.js +206 -0
  38. package/dist/web-citations.js +1 -1
  39. package/dist/work-context-human.js +67 -0
  40. package/dist/work-context-wire.js +391 -0
  41. package/dist/write-routing.js +26 -0
  42. package/package.json +2 -2
  43. package/skills/caotang-context/SKILL.md +20 -7
  44. package/skills/caotang-first-connect/SKILL.md +48 -34
  45. package/skills/caotang-project-progress/SKILL.md +6 -2
  46. package/skills/caotang-save-knowledge/SKILL.md +6 -4
  47. package/skills/caotang-schedule/SKILL.md +54 -0
  48. package/skills/caotang-search/SKILL.md +11 -0
  49. package/skills/caotang-workday/SKILL.md +56 -9
  50. package/skills/manifest.json +28 -6
@@ -0,0 +1,110 @@
1
+ import { authStatus } from "./auth.js";
2
+ import { asRecord, CliError } from "./errors.js";
3
+ import { previewProjectBinding, confirmProjectBinding, readProjectBinding, unbindProjectBinding } from "./project-binding.js";
4
+ export function isProjectBindingCommand(command) { return command[0] === "project" && ["bind", "binding", "unbind"].includes(command[1]); }
5
+ function operation(command, flags) { return command[1] === "bind" ? flags.confirm === undefined ? "project_bind_preview" : "project_bind" : command[1] === "unbind" ? flags.confirm === undefined ? "project_unbind_preview" : "project_unbind" : "project_binding"; }
6
+ export function projectBindingLocalScopeOutcome(command, flags) {
7
+ if (!isProjectBindingCommand(command) || flags["local-workspace"] === true || command[1] === "bind" && flags.confirm === undefined)
8
+ return;
9
+ return { result: { status: 200, body: { schema_version: 1, operation: operation(command, flags), status: "local_scope_required", reason: "independent_local_workspace_not_confirmed" } }, exitCode: 2 };
10
+ }
11
+ function serviceOrigin(value) {
12
+ try {
13
+ const url = new URL(value);
14
+ if (typeof value !== "string" || value.trim() !== value || url.username || url.password || url.search || url.hash || url.pathname !== "/" || url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))
15
+ throw Error();
16
+ return url.origin;
17
+ }
18
+ catch {
19
+ throw new CliError("project_binding_origin_unverified", "当前关联操作的服务地址未通过核对。", "保留当前账号和本机元数据,先核对实际服务;不要借环境标签使用另一服务的关联。", 7);
20
+ }
21
+ }
22
+ const identityError = () => new CliError("invalid_auth_response", "无法核对本次关联操作的账号身份。", "先核对当前连接,不读取旧关联或自动切换账号。", 7);
23
+ const cancelled = (auth) => { if (auth.signal?.aborted)
24
+ throw new CliError("authorization_cancelled", "本次关联操作已取消。", "需要时重新查看本机预览,不自动确认或重写。", 130); };
25
+ // The caller selects the credential once. Flags carry an already-obtained human
26
+ // decision; neither a flag nor a digest is proof that the human consented.
27
+ export async function createVerifiedProjectBindingConnection(auth, selectedToken, options) {
28
+ cancelled(auth);
29
+ const origin = serviceOrigin(auth.api.baseUrl);
30
+ if (auth.store.origin !== origin || options.expectedOrigin !== undefined && serviceOrigin(options.expectedOrigin) !== origin)
31
+ throw identityError();
32
+ const who = asRecord((await authStatus({ ...auth, tokenOverride: selectedToken })).body);
33
+ if (!who || who.status !== "authenticated" || typeof who.user_id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(who.user_id) || serviceOrigin(auth.api.baseUrl) !== origin || auth.store.origin !== origin)
34
+ throw identityError();
35
+ const owner = who.user_id;
36
+ let validation;
37
+ const context = {
38
+ homeDir: options.homeDir, cwd: options.cwd, apiBaseUrl: origin, serviceBase: "/api/v1/agent", verifiedOwnerId: owner, localScopeConfirmed: options.localScopeConfirmed === true,
39
+ async readProject(id) {
40
+ cancelled(auth);
41
+ if (serviceOrigin(auth.api.baseUrl) !== origin || auth.store.origin !== origin)
42
+ throw identityError();
43
+ let response;
44
+ try {
45
+ response = await auth.api.request(`/api/v1/agent/m1/projects/${encodeURIComponent(id)}`, { method: "GET", token: selectedToken, signal: auth.signal });
46
+ }
47
+ catch {
48
+ validation = { reason: auth.signal?.aborted ? "cancelled" : "transport_unavailable" };
49
+ throw new CliError("project_binding_target_unverified", "未核实关联目标。", "保留本机元数据,不自动确认或重试。", auth.signal?.aborted ? 130 : 7);
50
+ }
51
+ cancelled(auth);
52
+ if (response.status === 403 || response.status === 404) {
53
+ validation = { reason: response.status === 403 ? "permission_denied" : "not_found_or_unreadable", http_status: response.status };
54
+ return null;
55
+ }
56
+ if (response.status !== 200) {
57
+ validation = { reason: response.status === 401 ? "authorization_unverified" : response.status >= 500 ? "server_unavailable" : "project_response_unverified", http_status: response.status };
58
+ throw new CliError("project_binding_target_unverified", "未核实关联目标。", "保留本机元数据,不自动确认、重试或扩大读取。", response.status === 401 ? 3 : 7);
59
+ }
60
+ const body = asRecord(response.body), project = asRecord(body?.project);
61
+ // This existing endpoint reads the one necessary project context, not a
62
+ // zero-content metadata API. Binding never receives summary/updates/cards.
63
+ if (!body || Object.hasOwn(body, "code") || Object.hasOwn(body, "error") || !project || project.owner_id !== owner || typeof project.id !== "string" || project.id.toLowerCase() !== id.toLowerCase() || typeof project.title !== "string" || typeof project.status !== "string" || serviceOrigin(auth.api.baseUrl) !== origin || auth.store.origin !== origin) {
64
+ validation = { reason: "project_owner_or_metadata_unverified", http_status: 200 };
65
+ throw new CliError("project_binding_target_unverified", "未核实关联目标的归属或元数据。", "保留本机元数据,不根据旧项目名称猜测或确认。", 7);
66
+ }
67
+ return { id: project.id, title: project.title, status: project.status };
68
+ },
69
+ };
70
+ return { context, projectCheck: () => validation };
71
+ }
72
+ function outcomeExit(body, check) {
73
+ if (["bound", "unbound", "not_bound", "confirmation_required"].includes(body.status))
74
+ return 0;
75
+ if (check?.reason === "cancelled")
76
+ return 130;
77
+ if (check?.http_status === 401)
78
+ return 3;
79
+ if (check?.http_status === 403)
80
+ return 4;
81
+ if (body.status === "unknown")
82
+ return body.reason === "project_binding_update_unknown" || body.reason === "project_binding_busy" ? 6 : 7;
83
+ return 2;
84
+ }
85
+ export async function executeProjectBindingCLI(command, flags, options) {
86
+ const scope = projectBindingLocalScopeOutcome(command, flags);
87
+ if (scope)
88
+ return scope;
89
+ let connection;
90
+ try {
91
+ connection = await createVerifiedProjectBindingConnection(options.auth, options.token, options);
92
+ }
93
+ catch (error) {
94
+ const known = error instanceof CliError ? error : undefined;
95
+ const http = asRecord(known?.details)?.http_status;
96
+ return { result: { status: 200, body: { schema_version: 1, operation: operation(command, flags), status: "unknown", reason: "identity_validation_unavailable", identity_check: { reason: known?.code === "unauthorized" ? "authorization_unverified" : known?.code === "project_binding_origin_unverified" ? "service_origin_unverified" : known?.exitCode === 130 ? "cancelled" : "identity_unverified", ...(typeof http === "number" ? { http_status: http } : {}) } } }, exitCode: known?.exitCode === 130 ? 130 : known?.code === "unauthorized" ? 3 : known?.exitCode === 4 ? 4 : 7 };
97
+ }
98
+ cancelled(options.auth);
99
+ let body;
100
+ if (command[1] === "bind") {
101
+ const binding = { projectId: command[2], includeSubdirs: flags["include-subdirs"] === true };
102
+ body = flags.confirm === undefined ? await previewProjectBinding(connection.context, binding) : await confirmProjectBinding(connection.context, { ...binding, previewDigest: String(flags.confirm), localScopeConfirmed: true });
103
+ }
104
+ else if (command[1] === "binding")
105
+ body = await readProjectBinding(connection.context);
106
+ else
107
+ body = await unbindProjectBinding(connection.context, { ...(flags.confirm === undefined ? {} : { previewDigest: String(flags.confirm), localScopeConfirmed: true }) });
108
+ const check = connection.projectCheck();
109
+ return { result: { status: 200, body: { ...body, ...(check ? { project_check: check } : {}) } }, exitCode: outcomeExit(body, check) };
110
+ }
@@ -0,0 +1,370 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ import { asRecord, CliError } from "./errors.js";
5
+ import { OwnedTree } from "./installation.js";
6
+ import { acquireLocalLock } from "./local-lock.js";
7
+ const serviceBase = "/api/v1/agent";
8
+ const maxMapBytes = 256 * 1024;
9
+ const maxEntries = 256;
10
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
11
+ const uuid = (value) => typeof value === "string" && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
12
+ const ownerID = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
13
+ const hex = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
14
+ const keys = (value, expected) => Object.keys(value).sort().join(",") === [...expected].sort().join(",");
15
+ const safePath = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && isAbsolute(value) && resolve(value) === value && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value);
16
+ const within = (parent, child) => { const part = relative(parent, child); return part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); };
17
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino && a.uid === b.uid;
18
+ const sameFacts = (a, b) => a.canonical_path === b.canonical_path && sameIdentity(a, b) && a.mode === b.mode;
19
+ class StateError extends Error {
20
+ reason;
21
+ constructor(reason) {
22
+ super("Local project binding metadata is unavailable or unsafe");
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ function result(operation, status, reason) { return { schema_version: 1, operation, status, ...(reason ? { reason } : {}) }; }
27
+ function changedPreview() { throw new CliError("project_binding_preview_changed", "本机项目关联的确认范围已变化,未写入。", "重新查看当前目录、账号、服务和项目的预览;摘要不能替代本人明确同意。", 2); }
28
+ function projectOptions(options) {
29
+ if (!options || !uuid(options.projectId) || options.includeSubdirs !== undefined && typeof options.includeSubdirs !== "boolean")
30
+ throw new CliError("invalid_arguments", "项目关联参数无效。", "指定真实项目ID;子目录范围须单独预览确认,不扫描目录或猜测同名项目。", 2);
31
+ return { projectId: options.projectId.toLowerCase(), includeSubdirs: options.includeSubdirs === true };
32
+ }
33
+ function identity(ctx) {
34
+ if (!ownerID(ctx.verifiedOwnerId) || ctx.serviceBase !== serviceBase || typeof ctx.apiBaseUrl !== "string" || ctx.apiBaseUrl.trim() !== ctx.apiBaseUrl)
35
+ return;
36
+ try {
37
+ const url = new URL(ctx.apiBaseUrl);
38
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/" || (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))))
39
+ return;
40
+ return { origin: url.origin, owner: ctx.verifiedOwnerId };
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ }
46
+ async function directoryFacts(path) {
47
+ if (!safePath(path) || typeof process.getuid !== "function")
48
+ throw new StateError("directory_unsafe");
49
+ const uid = process.getuid(), root = parse(path).root, ancestors = [];
50
+ let current = root;
51
+ for (const part of [null, ...relative(root, path).split(sep).filter(Boolean)]) {
52
+ if (part !== null)
53
+ current = join(current, part);
54
+ const info = await lstat(current, { bigint: true }), mode = Number(info.mode & 4095n), owner = Number(info.uid), final = current === path;
55
+ const stickySystemAncestor = !final && owner === 0 && (mode & 0o1000) !== 0;
56
+ if (!info.isDirectory() || (final ? owner !== uid : owner !== uid && owner !== 0) || (mode & 0o022) !== 0 && !stickySystemAncestor)
57
+ throw new StateError("directory_unsafe");
58
+ ancestors.push({ canonical_path: current, dev: info.dev.toString(), ino: info.ino.toString(), uid: owner, mode });
59
+ }
60
+ if (await realpath(path) !== path)
61
+ throw new StateError("directory_unsafe");
62
+ return { facts: ancestors.at(-1), ancestors };
63
+ }
64
+ async function base(ctx, operation, requireLocal) {
65
+ if (requireLocal && ctx.localScopeConfirmed !== true)
66
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
67
+ const who = identity(ctx);
68
+ if (!who || typeof ctx.readProject !== "function")
69
+ return result(operation, "unknown", "identity_or_service_unverified");
70
+ try {
71
+ const home = await directoryFacts(ctx.homeDir), directory = await directoryFacts(ctx.cwd), tree = new OwnedTree(ctx.homeDir);
72
+ await tree.directory(ctx.homeDir, false, async () => true);
73
+ return { tree, home: home.facts, directory: directory.facts, ancestors: directory.ancestors, uid: process.getuid(), ...who, root: join(ctx.homeDir, ".caotang", "project-bindings") };
74
+ }
75
+ catch {
76
+ return result(operation, "invalid", "directory_unsafe_or_changed");
77
+ }
78
+ }
79
+ function isBase(value) { return "tree" in value; }
80
+ function metadata(b) { return { service: { origin: b.origin, service_base: serviceBase }, owner_id: b.owner }; }
81
+ async function verifyBase(ctx, b) {
82
+ const who = identity(ctx);
83
+ if (ctx.localScopeConfirmed !== true || !who || who.origin !== b.origin || who.owner !== b.owner
84
+ || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home) || !sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory))
85
+ throw new StateError("scope_changed");
86
+ }
87
+ async function checkedProject(ctx, id, operation) {
88
+ let value;
89
+ try {
90
+ value = await ctx.readProject(id);
91
+ }
92
+ catch {
93
+ return result(operation, "unknown", "project_validation_unavailable");
94
+ }
95
+ if (!value)
96
+ return result(operation, "invalid", "project_not_accessible");
97
+ if (["hidden", "archived", "deleted"].includes(value.status))
98
+ return result(operation, "invalid", "project_not_eligible");
99
+ if (!uuid(value.id) || value.id.toLowerCase() !== id || !["active", "paused", "draft"].includes(value.status)
100
+ || typeof value.title !== "string" || !value.title.trim() || [...value.title].length > 80 || /[\p{Cc}\uD800-\uDFFF]/u.test(value.title))
101
+ return result(operation, "unknown", "project_metadata_unverified");
102
+ return { id, title: value.title, status: value.status };
103
+ }
104
+ function isProject(value) { return "id" in value; }
105
+ function bindDigest(b, project, includeSubdirs) {
106
+ return hash(JSON.stringify({ schema_version: 1, operation: "bind", home: b.home, directory: b.directory, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner, project, include_subdirs: includeSubdirs, scope: "independent_local_only" }));
107
+ }
108
+ async function bindingPreview(ctx, options) {
109
+ const wanted = projectOptions(options), operation = "project_bind_preview", b = await base(ctx, operation, false);
110
+ if (!isBase(b))
111
+ return b;
112
+ const project = await checkedProject(ctx, wanted.projectId, operation);
113
+ if (!isProject(project))
114
+ return project;
115
+ try {
116
+ if (!sameFacts((await directoryFacts(ctx.cwd)).facts, b.directory) || !sameFacts((await directoryFacts(ctx.homeDir)).facts, b.home))
117
+ return result(operation, "invalid", "directory_changed_during_preview");
118
+ }
119
+ catch {
120
+ return result(operation, "invalid", "directory_changed_during_preview");
121
+ }
122
+ return { b, project, includeSubdirs: wanted.includeSubdirs, preview: {
123
+ ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: wanted.includeSubdirs }, project,
124
+ preview_digest: bindDigest(b, project, wanted.includeSubdirs), requires_local_scope_confirmation: true, local_write_only: true,
125
+ } };
126
+ }
127
+ export async function previewProjectBinding(ctx, options) {
128
+ const outcome = await bindingPreview(ctx, options);
129
+ return "preview" in outcome ? outcome.preview : outcome;
130
+ }
131
+ function storedFacts(value) {
132
+ const v = asRecord(value);
133
+ if (!v || !keys(v, ["canonical_path", "dev", "ino", "uid", "mode"]) || !safePath(v.canonical_path)
134
+ || typeof v.dev !== "string" || !/^\d{1,40}$/.test(v.dev) || typeof v.ino !== "string" || !/^\d{1,40}$/.test(v.ino)
135
+ || typeof v.uid !== "number" || !Number.isSafeInteger(v.uid) || v.uid < 0 || typeof v.mode !== "number" || !Number.isInteger(v.mode) || v.mode < 0 || v.mode > 0o7777)
136
+ throw new StateError("local_metadata_malformed");
137
+ return { canonical_path: v.canonical_path, dev: v.dev, ino: v.ino, uid: v.uid, mode: v.mode };
138
+ }
139
+ function json(bytes) {
140
+ try {
141
+ const raw = bytes.toString("utf8");
142
+ if (!Buffer.from(raw, "utf8").equals(bytes))
143
+ throw Error();
144
+ const value = asRecord(JSON.parse(raw));
145
+ if (!value)
146
+ throw Error();
147
+ return value;
148
+ }
149
+ catch {
150
+ throw new StateError("local_metadata_malformed");
151
+ }
152
+ }
153
+ async function localState(b, create) {
154
+ const path = join(b.root, "local-state.json");
155
+ let bytes = await b.tree.read(path, 16384, 0o600);
156
+ if (!bytes && create) {
157
+ const fresh = { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: randomBytes(32).toString("hex"), home: b.home };
158
+ try {
159
+ await b.tree.writeNew(path, Buffer.from(JSON.stringify(fresh)), 0o600);
160
+ }
161
+ catch (error) {
162
+ if (!(error instanceof CliError) || error.code !== "installation_conflict")
163
+ throw error;
164
+ }
165
+ bytes = await b.tree.read(path, 16384, 0o600);
166
+ if (!bytes)
167
+ throw new StateError("local_state_unconfirmed");
168
+ }
169
+ if (!bytes)
170
+ return null;
171
+ const value = json(bytes);
172
+ if (!keys(value, ["schema_version", "scope", "local_state_id", "home"]) || value.schema_version !== 1 || value.scope !== "user_confirmed_independent_local" || !hex(value.local_state_id))
173
+ throw new StateError("local_metadata_malformed");
174
+ const home = storedFacts(value.home);
175
+ if (!sameFacts(home, b.home))
176
+ throw new StateError("local_home_identity_changed");
177
+ return { schema_version: 1, scope: "user_confirmed_independent_local", local_state_id: value.local_state_id, home };
178
+ }
179
+ function namespace(b, state) {
180
+ return hash(JSON.stringify({ schema_version: 1, local_state_id: state.local_state_id, home: state.home, uid: b.uid, origin: b.origin, service_base: serviceBase, owner_id: b.owner }));
181
+ }
182
+ async function storedMap(b, state) {
183
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), path = join(directory, "bindings.json"), bytes = await b.tree.read(path, maxMapBytes, 0o600);
184
+ if (!bytes)
185
+ return { namespace: ns, path, directory, bytes: null, value: { schema_version: 1, namespace: ns, entries: [] } };
186
+ const value = json(bytes);
187
+ if (!keys(value, ["schema_version", "namespace", "entries"]) || value.schema_version !== 1 || value.namespace !== ns || !Array.isArray(value.entries) || value.entries.length > maxEntries)
188
+ throw new StateError("local_metadata_malformed");
189
+ const entries = value.entries.map((raw) => {
190
+ const item = asRecord(raw);
191
+ if (!item || !keys(item, ["directory", "project_id", "include_subdirs"]) || !uuid(item.project_id) || typeof item.include_subdirs !== "boolean")
192
+ throw new StateError("local_metadata_malformed");
193
+ const directory = storedFacts(item.directory);
194
+ if (directory.uid !== b.uid)
195
+ throw new StateError("local_metadata_malformed");
196
+ return { directory, project_id: item.project_id.toLowerCase(), include_subdirs: item.include_subdirs };
197
+ });
198
+ if (new Set(entries.map(entry => entry.directory.canonical_path)).size !== entries.length)
199
+ throw new StateError("local_metadata_malformed");
200
+ return { namespace: ns, path, directory, bytes, value: { schema_version: 1, namespace: ns, entries } };
201
+ }
202
+ function selection(b, entries) {
203
+ const candidates = entries.filter(entry => entry.directory.canonical_path === b.directory.canonical_path || entry.include_subdirs && within(entry.directory.canonical_path, b.directory.canonical_path))
204
+ .sort((a, z) => z.directory.canonical_path.length - a.directory.canonical_path.length);
205
+ if (candidates.length)
206
+ return { entry: candidates[0], inherited: candidates[0].directory.canonical_path !== b.directory.canonical_path };
207
+ const moved = entries.some(entry => sameIdentity(entry.directory, b.directory) || entry.include_subdirs && b.ancestors.some(ancestor => sameIdentity(entry.directory, ancestor)));
208
+ return { moved };
209
+ }
210
+ function stateFailure(operation, error) {
211
+ if (error instanceof StateError)
212
+ return result(operation, "invalid", error.reason);
213
+ if (error instanceof CliError && ["project_binding_busy", "project_binding_update_unknown"].includes(error.code))
214
+ return result(operation, "unknown", error.code);
215
+ return result(operation, "invalid", "local_state_unsafe_or_unavailable");
216
+ }
217
+ async function changeMap(b, map, entries) {
218
+ if (entries.length > maxEntries)
219
+ throw new StateError("local_binding_capacity_exceeded");
220
+ const value = { schema_version: 1, namespace: map.namespace, entries: [...entries].sort((a, z) => a.directory.canonical_path.localeCompare(z.directory.canonical_path)) };
221
+ const after = Buffer.from(JSON.stringify(value));
222
+ if (after.length > maxMapBytes)
223
+ throw new StateError("local_binding_capacity_exceeded");
224
+ if (map.bytes?.equals(after))
225
+ return false;
226
+ let writeAttempted = false;
227
+ try {
228
+ if (!map.bytes) {
229
+ writeAttempted = true;
230
+ await b.tree.writeNew(map.path, after, 0o600);
231
+ }
232
+ else {
233
+ const journal = Buffer.from(JSON.stringify({ schema_version: 1, before_sha256: hash(map.bytes), after_sha256: hash(after), before_base64: map.bytes.toString("base64"), after_base64: after.toString("base64") }));
234
+ await b.tree.writeNew(join(map.directory, "history", `${hash(map.bytes)}.${hash(after)}.json`), journal, 0o600);
235
+ const entry = { path: map.path, before: map.bytes, after, mode: 0o600, label: "project_bindings" };
236
+ writeAttempted = true;
237
+ try {
238
+ await b.tree.replaceManaged(entry);
239
+ }
240
+ catch {
241
+ try {
242
+ await b.tree.replaceManaged(entry, true);
243
+ }
244
+ catch { /* Keep recovery bytes; never reset an unverified inode. */ }
245
+ throw new StateError("local_update_unconfirmed");
246
+ }
247
+ }
248
+ if (!(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(after))
249
+ throw new StateError("local_update_unconfirmed");
250
+ }
251
+ catch (error) {
252
+ if (!writeAttempted)
253
+ throw error;
254
+ throw new CliError("project_binding_update_unknown", "本机关联更新未完成最终核对。", "保留现有元数据和恢复记录,由本人核对;不要自动重写或影响草堂项目。", 6);
255
+ }
256
+ return true;
257
+ }
258
+ async function locked(b, state, action) {
259
+ const ns = namespace(b, state), directory = join(b.root, "namespaces", ns), lockPath = join(directory, ".binding.lock");
260
+ const release = await b.tree.directory(directory, true, async (parents) => acquireLocalLock(lockPath, parents.at(-1), {
261
+ unsafe: () => new StateError("local_lock_unsafe"),
262
+ busy: () => new CliError("project_binding_busy", "另一项本机项目关联操作未完成。", "先等待本机操作结束;状态未知时不要删锁或强制覆盖。", 6),
263
+ }));
264
+ if (!release)
265
+ throw new StateError("local_lock_unavailable");
266
+ try {
267
+ return await action(await storedMap(b, state));
268
+ }
269
+ finally {
270
+ release();
271
+ }
272
+ }
273
+ export async function confirmProjectBinding(ctx, options) {
274
+ const operation = "project_bind";
275
+ if (ctx.localScopeConfirmed !== true || options.localScopeConfirmed !== true)
276
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
277
+ const outcome = await bindingPreview(ctx, options);
278
+ if (!("preview" in outcome))
279
+ return { ...outcome, operation };
280
+ if (!hex(options.previewDigest) || options.previewDigest !== outcome.preview.preview_digest)
281
+ changedPreview();
282
+ const { b, project, includeSubdirs } = outcome;
283
+ try {
284
+ await verifyBase(ctx, b);
285
+ const state = await localState(b, true);
286
+ if (!state)
287
+ throw new StateError("local_state_unconfirmed");
288
+ const changed = await locked(b, state, async (map) => {
289
+ await verifyBase(ctx, b);
290
+ const entries = map.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path);
291
+ entries.push({ directory: b.directory, project_id: project.id, include_subdirs: includeSubdirs });
292
+ return changeMap(b, map, entries);
293
+ });
294
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: includeSubdirs }, project, inherited: false, changed, local_write_only: true };
295
+ }
296
+ catch (error) {
297
+ return stateFailure(operation, error);
298
+ }
299
+ }
300
+ export async function readProjectBinding(ctx) {
301
+ const operation = "project_binding", b = await base(ctx, operation, true);
302
+ if (!isBase(b))
303
+ return b;
304
+ try {
305
+ const state = await localState(b, false);
306
+ if (!state)
307
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
308
+ const map = await storedMap(b, state), chosen = selection(b, map.value.entries);
309
+ if (!chosen.entry)
310
+ return result(operation, chosen.moved ? "invalid" : "not_bound", chosen.moved ? "directory_moved" : "no_binding_in_current_scope");
311
+ const entry = chosen.entry;
312
+ if (!sameFacts((await directoryFacts(entry.directory.canonical_path)).facts, entry.directory))
313
+ return result(operation, "invalid", "directory_identity_changed");
314
+ const project = await checkedProject(ctx, entry.project_id, operation);
315
+ if (!isProject(project))
316
+ return project;
317
+ await verifyBase(ctx, b);
318
+ // A different local operation may have changed the map while the project
319
+ // validation was awaiting the server. Never label that old selection current.
320
+ const currentState = await localState(b, false);
321
+ if (!currentState || currentState.local_state_id !== state.local_state_id || !map.bytes
322
+ || !(await b.tree.read(map.path, maxMapBytes, 0o600))?.equals(map.bytes))
323
+ return result(operation, "unknown", "binding_changed_during_validation");
324
+ return { ...result(operation, "bound"), ...metadata(b), directory: { canonical_path: entry.directory.canonical_path, include_subdirs: entry.include_subdirs }, project, inherited: chosen.inherited === true };
325
+ }
326
+ catch (error) {
327
+ return stateFailure(operation, error);
328
+ }
329
+ }
330
+ function unbindDigest(b, map, entry) {
331
+ return hash(JSON.stringify({ schema_version: 1, operation: "unbind_exact", home: b.home, directory: b.directory, origin: b.origin, service_base: serviceBase, owner_id: b.owner, namespace: map.namespace, map_sha256: hash(map.bytes), entry }));
332
+ }
333
+ export async function unbindProjectBinding(ctx, options = {}) {
334
+ const operation = options.previewDigest === undefined ? "project_unbind_preview" : "project_unbind", b = await base(ctx, operation, true);
335
+ if (!isBase(b))
336
+ return b;
337
+ if (options.previewDigest !== undefined && options.localScopeConfirmed !== true)
338
+ return result(operation, "local_scope_required", "independent_local_workspace_not_confirmed");
339
+ try {
340
+ const state = await localState(b, false);
341
+ if (!state)
342
+ return result(operation, "not_bound", "no_local_state_in_current_scope");
343
+ const map = await storedMap(b, state), exact = map.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
344
+ if (!exact) {
345
+ const inherited = selection(b, map.value.entries).entry;
346
+ return result(operation, inherited ? "inherited_binding_requires_root" : "not_bound", inherited ? "confirm_at_exact_binding_directory" : "no_binding_in_current_scope");
347
+ }
348
+ if (!map.bytes)
349
+ throw new StateError("local_metadata_malformed");
350
+ const digest = unbindDigest(b, map, exact);
351
+ if (options.previewDigest === undefined)
352
+ return { ...result(operation, "confirmation_required"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, preview_digest: digest, requires_local_scope_confirmation: true, local_write_only: true };
353
+ if (!hex(options.previewDigest) || options.previewDigest !== digest)
354
+ changedPreview();
355
+ await verifyBase(ctx, b);
356
+ const changed = await locked(b, state, async (current) => {
357
+ const row = current.value.entries.find(entry => entry.directory.canonical_path === b.directory.canonical_path);
358
+ if (!row || !current.bytes || unbindDigest(b, current, row) !== options.previewDigest)
359
+ changedPreview();
360
+ await verifyBase(ctx, b);
361
+ return changeMap(b, current, current.value.entries.filter(entry => entry.directory.canonical_path !== b.directory.canonical_path));
362
+ });
363
+ return { ...result(operation, "unbound"), ...metadata(b), directory: { canonical_path: b.directory.canonical_path, include_subdirs: exact.include_subdirs }, changed, local_write_only: true };
364
+ }
365
+ catch (error) {
366
+ if (error instanceof CliError && error.code === "project_binding_preview_changed")
367
+ throw error;
368
+ return stateFailure(operation, error);
369
+ }
370
+ }
@@ -30,10 +30,11 @@ export function expectSearchOK(result) {
30
30
  }
31
31
  const body = asRecord(result.body), code = body?.code ?? body?.error;
32
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
33
  const rows = Array.isArray(body?.missing_sections) ? body.missing_sections : [];
34
+ const schedule = rows.some(row => { const item = asRecord(row); return item?.section === "search_schedule_items" || item?.required_capability === "read_workbench"; });
35
+ const expected = [{ section: "search_projects", required_capability: "read_projects", reason: "capability_disabled" }, { section: "search_knowledge", required_capability: "read_knowledge", reason: "capability_disabled" }, ...(schedule ? [{ section: "search_schedule_items", required_capability: "read_workbench", reason: "capability_disabled" }] : [])];
35
36
  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
+ throw new CliError("capability_disabled", schedule ? "“读取项目”、“读取知识”和“读取工作台”开关都未开启。" : "“读取项目”和“读取知识”开关都未开启。", "请本人到网页的 Agent 管理核对并开启所需开关;不要重新授权、换入口或重试来绕过限制。", 4, { http_status: 403, missing_sections: expected });
37
38
  }
38
39
  }
39
40
  const checked = expectQueryOK(result);