@hellolynk-ai/workbuddy-mcp 0.0.0-stage → 0.3.97

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 (176) hide show
  1. package/LICENSE +92 -0
  2. package/README.md +103 -2
  3. package/bin/hellolynk-client-setup.mjs +2748 -0
  4. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  5. package/clients/core/hellolynk-client-account.mjs +180 -0
  6. package/clients/core/hellolynk-client-core.mjs +770 -0
  7. package/clients/core/hellolynk-client-profile.mjs +191 -0
  8. package/clients/core/hellolynk-client-update-entry.mjs +31 -0
  9. package/clients/core/hellolynk-client-update-runtime.mjs +600 -0
  10. package/clients/core/hellolynk-client-update.mjs +453 -0
  11. package/clients/core/hellolynk-contact-request-e2ee.mjs +867 -0
  12. package/clients/core/hellolynk-device-enrollment.mjs +435 -0
  13. package/clients/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  14. package/clients/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  15. package/clients/core/hellolynk-host-auth.mjs +347 -0
  16. package/clients/core/hellolynk-host-protected-store.mjs +2721 -0
  17. package/clients/core/hellolynk-host-transport-client.mjs +946 -0
  18. package/clients/core/hellolynk-host.mjs +2374 -0
  19. package/clients/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  20. package/clients/core/hellolynk-human-relationship-controller.mjs +1234 -0
  21. package/clients/core/hellolynk-inbox-read-state.mjs +11 -0
  22. package/clients/core/hellolynk-indexed-message-store.mjs +1558 -0
  23. package/clients/core/hellolynk-instance-conversation.mjs +185 -0
  24. package/clients/core/hellolynk-local-contact-requests.mjs +1733 -0
  25. package/clients/core/hellolynk-local-contacts.mjs +1228 -0
  26. package/clients/core/hellolynk-local-data-migration.mjs +766 -0
  27. package/clients/core/hellolynk-local-data-protection.mjs +747 -0
  28. package/clients/core/hellolynk-local-identity-trust.mjs +274 -0
  29. package/clients/core/hellolynk-local-messaging.mjs +3043 -0
  30. package/clients/core/hellolynk-local-peer-transport.mjs +103 -0
  31. package/clients/core/hellolynk-local-process-lock.mjs +157 -0
  32. package/clients/core/hellolynk-model-inbox.mjs +230 -0
  33. package/clients/core/hellolynk-model-notices.cjs +60 -0
  34. package/clients/core/hellolynk-native-addressing-tools.cjs +231 -0
  35. package/clients/core/hellolynk-native-conversations.mjs +280 -0
  36. package/clients/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  37. package/clients/core/hellolynk-native-inbox-runtime.mjs +553 -0
  38. package/clients/core/hellolynk-native-notice.mjs +50 -0
  39. package/clients/core/hellolynk-native-reply-routes.mjs +96 -0
  40. package/clients/core/hellolynk-native-task-index.cjs +305 -0
  41. package/clients/core/hellolynk-native-worker-lease.mjs +84 -0
  42. package/clients/core/hellolynk-neutral-host-assembly.mjs +955 -0
  43. package/clients/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  44. package/clients/core/hellolynk-neutral-host-launcher.mjs +665 -0
  45. package/clients/core/hellolynk-neutral-host-process.mjs +4374 -0
  46. package/clients/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  47. package/clients/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  48. package/clients/core/hellolynk-platform-credential-store.mjs +773 -0
  49. package/clients/core/hellolynk-relay-client-cli.mjs +232 -0
  50. package/clients/core/hellolynk-release-retention.mjs +277 -0
  51. package/clients/core/hellolynk-runtime-safety.cjs +471 -0
  52. package/clients/core/hellolynk-workbench-action-grants.mjs +457 -0
  53. package/clients/core/hellolynk-workbench-connection.mjs +234 -0
  54. package/clients/platform/windows/credential-store.ps1 +844 -0
  55. package/clients/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  56. package/clients/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  57. package/clients/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  58. package/clients/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  59. package/clients/workbench/contract.mjs +1314 -0
  60. package/clients/workbench/service.mjs +485 -0
  61. package/hellolynk-client-pack-manifest.json +884 -0
  62. package/hellolynk-client-pack-pin.json +4 -0
  63. package/integrations/workbuddy/package.json +15 -0
  64. package/integrations/workbuddy/server/host-lifecycle.mjs +249 -0
  65. package/integrations/workbuddy/server/mcp-proxy.mjs +258 -0
  66. package/integrations/workbuddy/server/mcp-server.mjs +357 -0
  67. package/integrations/workbuddy/server/model-call-origin.mjs +87 -0
  68. package/integrations/workbuddy/server/native-task-cdp.mjs +175 -0
  69. package/integrations/workbuddy/server/native-task-debug-startup.mjs +117 -0
  70. package/integrations/workbuddy/server/native-task-debug-startup.ps1 +40 -0
  71. package/integrations/workbuddy/server/native-task-mcp-registration.mjs +30 -0
  72. package/integrations/workbuddy/server/native-task-service-registration.mjs +115 -0
  73. package/integrations/workbuddy/server/native-task-worker.mjs +127 -0
  74. package/integrations/workbuddy/server/neutral-host-adapter.mjs +639 -0
  75. package/integrations/workbuddy/server/origin-hook.mjs +111 -0
  76. package/integrations/workbuddy/server/origin-routes.mjs +211 -0
  77. package/integrations/workbuddy/server/package-bootstrap.mjs +111 -0
  78. package/integrations/workbuddy/server/workbench-candidate.mjs +202 -0
  79. package/integrations/workbuddy/server/workbench-hidden-launcher.vbs +46 -0
  80. package/integrations/workbuddy/server/workbench-process.mjs +373 -0
  81. package/integrations/workbuddy/server/workbench-task.ps1 +196 -0
  82. package/integrations/workbuddy/skills/hellolynk-inbox/SKILL.md +9 -0
  83. package/integrations/workbuddy/skills/hellolynk-inbox/workbuddy.json +6 -0
  84. package/package.json +65 -4
  85. package/runtime/connect-activate.mjs +837 -0
  86. package/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  87. package/runtime/core/hellolynk-client-account.mjs +180 -0
  88. package/runtime/core/hellolynk-client-core.mjs +770 -0
  89. package/runtime/core/hellolynk-client-profile.mjs +191 -0
  90. package/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  91. package/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  92. package/runtime/core/hellolynk-client-update.mjs +453 -0
  93. package/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  94. package/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  95. package/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  96. package/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  97. package/runtime/core/hellolynk-host-auth.mjs +347 -0
  98. package/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  99. package/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  100. package/runtime/core/hellolynk-host.mjs +2374 -0
  101. package/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  102. package/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  103. package/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  104. package/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  105. package/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  106. package/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  107. package/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  108. package/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  109. package/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  110. package/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  111. package/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  112. package/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  113. package/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  114. package/runtime/core/hellolynk-model-inbox.mjs +230 -0
  115. package/runtime/core/hellolynk-model-notices.cjs +60 -0
  116. package/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  117. package/runtime/core/hellolynk-native-conversations.mjs +280 -0
  118. package/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  119. package/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  120. package/runtime/core/hellolynk-native-notice.mjs +50 -0
  121. package/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  122. package/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  123. package/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  124. package/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  125. package/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  126. package/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  127. package/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  128. package/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  129. package/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  130. package/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  131. package/runtime/core/hellolynk-release-retention.mjs +277 -0
  132. package/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  133. package/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  134. package/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  135. package/runtime/neutral-host-bootstrap.mjs +57 -0
  136. package/runtime/neutral-host-release-entry.mjs +81 -0
  137. package/runtime/neutral-host-release-loader.mjs +156 -0
  138. package/runtime/neutral-host-release-manifest.json +302 -0
  139. package/runtime/neutral-host-release-pin.json +4 -0
  140. package/runtime/platform/windows/configure-agent.ps1 +281 -0
  141. package/runtime/platform/windows/credential-store.ps1 +844 -0
  142. package/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  143. package/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  144. package/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  145. package/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  146. package/runtime/workbench/candidate-bootstrap.mjs +54 -0
  147. package/runtime/workbench/contract.mjs +1314 -0
  148. package/runtime/workbench/hellolynk-workbench-build.json +215 -0
  149. package/runtime/workbench/host-capabilities.mjs +80 -0
  150. package/runtime/workbench/i18n.mjs +1127 -0
  151. package/runtime/workbench/loopback-surface.mjs +740 -0
  152. package/runtime/workbench/mcp-app-bundle.mjs +97 -0
  153. package/runtime/workbench/service.mjs +485 -0
  154. package/runtime/workbench/state.mjs +421 -0
  155. package/runtime/workbench/transports.mjs +171 -0
  156. package/runtime/workbench/ui/app.mjs +6120 -0
  157. package/runtime/workbench/ui/connection-form.mjs +155 -0
  158. package/runtime/workbench/ui/hellolynk-mark.png +0 -0
  159. package/runtime/workbench/ui/http-transport.mjs +247 -0
  160. package/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  161. package/runtime/workbench/ui/icons/close.svg +3 -0
  162. package/runtime/workbench/ui/icons/contacts.svg +3 -0
  163. package/runtime/workbench/ui/icons/inbox.svg +3 -0
  164. package/runtime/workbench/ui/icons/refresh.svg +3 -0
  165. package/runtime/workbench/ui/icons/search.svg +3 -0
  166. package/runtime/workbench/ui/icons/security.svg +3 -0
  167. package/runtime/workbench/ui/icons/settings.svg +3 -0
  168. package/runtime/workbench/ui/index.html +139 -0
  169. package/runtime/workbench/ui/loopback-entry.mjs +26 -0
  170. package/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  171. package/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  172. package/runtime/workbench/ui/styles.css +686 -0
  173. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
  174. package/tools/hellolynk-install-client-skills.mjs +1211 -0
  175. package/workbuddy-package-manifest.json +1 -0
  176. package/workbuddy-package-pin.json +1 -0
@@ -0,0 +1,1211 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const SUPPORTED_TARGETS = new Set(["trae", "trae-solo", "trae-ide", "workbuddy", "cursor", "all"]);
11
+
12
+ export async function installClientSkills(options = {}) {
13
+ const sourceRoot = path.resolve(options.sourceRoot || ROOT);
14
+ const home = path.resolve(options.home || os.homedir());
15
+ const appData = path.resolve(options.appData || process.env.APPDATA || path.join(home, "AppData", "Roaming"));
16
+ const localAppData = path.resolve(options.localAppData || process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"));
17
+ const target = String(options.target || "all").toLowerCase();
18
+ if (!SUPPORTED_TARGETS.has(target)) throw new Error(`client_skill_target_unsupported:${target}`);
19
+
20
+ const resolvedTargets = await resolveTargets(target, { home, appData, localAppData });
21
+ const plans = [];
22
+ for (const name of resolvedTargets) {
23
+ const location = await resolveProfileLocation(targetPlan(name, { sourceRoot, home, appData }), home);
24
+ const previousSource = name === "cursor" && options.previousSourceRoot
25
+ ? targetPlan(name, { sourceRoot: path.resolve(options.previousSourceRoot), home, appData }).source : null;
26
+ const previousState = previousSource ? await assertSkillSource({ ...location, source: previousSource }) : null;
27
+ const plan = Object.freeze({ ...location, ...(previousSource ? { previousSource, previousState } : {}),
28
+ ...(location.legacyDestination ? { backupRoot: path.join(location.trustedAnchor, ".hellolynk", "skill-backups", name) } : {}),
29
+ });
30
+ const sourceState = await assertSkillSource(plan);
31
+ const legacy = name === "cursor" ? Object.freeze({ active: false, status: "not-applicable", digest: null }) : await inspectLegacyClientSkill({
32
+ destination: plan.legacyDestination,
33
+ trustedAnchor: plan.trustedAnchor,
34
+ target: plan.target,
35
+ });
36
+ const destinationState = await inspectClientSkillDestination(plan, {
37
+ trustedAnchor: plan.trustedAnchor,
38
+ replace: name !== "cursor" && options.replace === true,
39
+ });
40
+ const chineseLegacy = name.startsWith("trae-") ? await inspectChineseLegacySkill({
41
+ destination: path.join(path.dirname(plan.destination), "HelloLynk收件箱"),
42
+ trustedAnchor: plan.trustedAnchor, target: name,
43
+ }) : null;
44
+ plans.push(Object.freeze({ ...plan, sourceState, legacy, chineseLegacy, destinationState }));
45
+ }
46
+ const fingerprint = clientSkillPlanFingerprint(plans);
47
+ if (target === "cursor" && options.apply === true && options.expectedPlanFingerprint == null) {
48
+ throw new Error("client_skill_preflight_required:cursor");
49
+ }
50
+ if (options.expectedPlanFingerprint != null
51
+ && String(options.expectedPlanFingerprint) !== fingerprint) {
52
+ throw new Error("client_skill_changed_since_preflight");
53
+ }
54
+ if (options.apply !== true) return Object.freeze({ ok: true, applied: false, fingerprint, targets: plans });
55
+
56
+ const committed = [];
57
+ const recoveryPaths = new Set();
58
+ try {
59
+ for (const plan of plans) {
60
+ const trustedAnchor = plan.trustedAnchor;
61
+ await assertProfileLocation(plan);
62
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(plan.destination), { allowMissing: true });
63
+ const afterActivate = async (value) => {
64
+ if (typeof options.afterActivate === "function") await options.afterActivate(value);
65
+ await assertProfileLocation(plan);
66
+ };
67
+ if (plan.target === "cursor") {
68
+ const installed = await installCursorSkill(plan, {
69
+ trustedAnchor, fingerprint, afterActivate,
70
+ });
71
+ committed.push(Object.freeze({
72
+ trustedAnchor,
73
+ install: installed,
74
+ retirement: Object.freeze({ undo: Object.freeze({ mode: "noop" }) }),
75
+ result: installed.result,
76
+ }));
77
+ continue;
78
+ }
79
+ const installed = await installOne(plan, {
80
+ replace: options.replace === true,
81
+ trustedAnchor,
82
+ recoveryPaths,
83
+ afterActivate,
84
+ });
85
+ let retired;
86
+ let chineseRetired;
87
+ try {
88
+ retired = await retireLegacySkill(plan, {
89
+ trustedAnchor,
90
+ afterRetire: typeof options.afterRetireLegacy === "function" ? options.afterRetireLegacy : null, recoveryPaths,
91
+ });
92
+ if (plan.chineseLegacy) chineseRetired = await retireLegacySkill({ ...plan,
93
+ legacyDestination: plan.chineseLegacy.destination, legacy: plan.chineseLegacy,
94
+ }, { trustedAnchor, afterRetire: typeof options.afterRetireLegacy === "function" ? options.afterRetireLegacy : null, recoveryPaths, chinese: true });
95
+ await verifyClientSkillMigration({
96
+ source: plan.source,
97
+ destination: plan.destination,
98
+ legacyDestination: plan.legacyDestination,
99
+ trustedAnchor,
100
+ target: plan.target,
101
+ });
102
+ await assertProfileLocation(plan);
103
+ } catch (error) {
104
+ try {
105
+ if (retired) {
106
+ await rollbackCompletedMigration({ install: installed, retirement: retired, chineseRetirement: chineseRetired }, trustedAnchor, recoveryPaths);
107
+ } else {
108
+ await rollbackCompletedInstall(installed, trustedAnchor, recoveryPaths);
109
+ }
110
+ } catch (rollbackError) {
111
+ const failure = new Error(`client_skill_migration_rollback_failed:${plan.target}`);
112
+ failure.cause = rollbackError;
113
+ failure.migrationCause = error;
114
+ throw failure;
115
+ }
116
+ throw error;
117
+ }
118
+ committed.push(Object.freeze({
119
+ trustedAnchor,
120
+ install: installed,
121
+ retirement: retired,
122
+ chineseRetirement: chineseRetired,
123
+ result: Object.freeze({ ...installed.result, legacy: retired.result, ...(chineseRetired ? { chineseLegacy: chineseRetired.result } : {}) }),
124
+ }));
125
+ }
126
+ } catch (error) {
127
+ const rollbackFailures = [];
128
+ for (const item of [...committed].reverse()) {
129
+ try {
130
+ await rollbackCompletedMigration(item, item.trustedAnchor, recoveryPaths);
131
+ } catch (rollbackError) {
132
+ rollbackFailures.push(rollbackError);
133
+ }
134
+ }
135
+ let failure = error instanceof Error ? error : new Error(String(error));
136
+ if (rollbackFailures.length > 0) {
137
+ failure = new Error("client_skill_batch_rollback_failed");
138
+ failure.cause = rollbackFailures[0];
139
+ failure.rollbackFailures = rollbackFailures;
140
+ failure.installationCause = error;
141
+ }
142
+ if (recoveryPaths.size > 0) failure.recoveryPaths = [...recoveryPaths];
143
+ throw failure;
144
+ }
145
+ return Object.freeze({
146
+ ok: true,
147
+ applied: true,
148
+ fingerprint,
149
+ targets: committed.map((item) => item.result),
150
+ });
151
+ }
152
+
153
+ function clientSkillPlanFingerprint(plans) {
154
+ const state = plans.map((item) => ({
155
+ target: item.target,
156
+ source: item.source,
157
+ destination: item.destination,
158
+ profileMapping: item.profileMapping,
159
+ legacyDestination: item.legacyDestination,
160
+ legacyActive: item.legacy?.active,
161
+ legacyDigest: item.legacy?.digest,
162
+ chineseLegacy: item.chineseLegacy,
163
+ sourceDigest: item.sourceState?.digest,
164
+ sourceName: item.sourceState?.name,
165
+ sourceMetadataIdentity: item.sourceState?.metadataIdentity,
166
+ previousSource: item.previousSource,
167
+ previousDigest: item.previousState?.digest,
168
+ destinationStatus: item.destinationState?.status,
169
+ destinationDigest: item.destinationState?.digest,
170
+ }));
171
+ return crypto.createHash("sha256").update(JSON.stringify(state), "utf8").digest("hex");
172
+ }
173
+
174
+ async function resolveProfileLocation(plan, home) {
175
+ const logicalRoot = path.dirname(path.dirname(plan.destination));
176
+ const metadata = await lstatMaybe(logicalRoot);
177
+ if (!metadata?.isSymbolicLink()) return Object.freeze({ ...plan, trustedAnchor: home, profileMapping: null });
178
+ const canonicalRoot = await fs.realpath(logicalRoot);
179
+ await assertClientSkillPathChain(canonicalRoot, canonicalRoot);
180
+ const identity = await fs.stat(canonicalRoot);
181
+ return Object.freeze({
182
+ ...plan,
183
+ destination: path.join(canonicalRoot, path.relative(logicalRoot, plan.destination)),
184
+ legacyDestination: plan.legacyDestination ? path.join(canonicalRoot, path.relative(logicalRoot, plan.legacyDestination)) : null,
185
+ trustedAnchor: canonicalRoot,
186
+ profileMapping: Object.freeze({ logicalRoot, canonicalRoot, dev: identity.dev, ino: identity.ino }),
187
+ });
188
+ }
189
+
190
+ async function assertProfileLocation(plan) {
191
+ const mapping = plan.profileMapping;
192
+ if (!mapping) return;
193
+ try {
194
+ const canonical = await fs.realpath(mapping.logicalRoot);
195
+ const identity = await fs.stat(canonical);
196
+ if (canonical === mapping.canonicalRoot && identity.dev === mapping.dev && identity.ino === mapping.ino) return;
197
+ } catch { /* Treat an unavailable or replaced mapping as a changed plan. */ }
198
+ throw new Error("client_skill_profile_path_changed");
199
+ }
200
+
201
+ async function resolveTargets(target, locations) {
202
+ if (target === "all") return [await resolveTraeTarget(locations), "workbuddy"];
203
+ if (target === "trae") return [await resolveTraeTarget(locations)];
204
+ return [target];
205
+ }
206
+
207
+ async function resolveTraeTarget({ appData, localAppData }) {
208
+ if (await exists(path.join(appData, "Trae CN"))) return "trae-ide";
209
+ const soloExecutable = path.join(localAppData, "Programs", "TRAE SOLO CN", "TRAE SOLO CN.exe");
210
+ const soloData = path.join(appData, "TRAE SOLO CN");
211
+ return (await exists(soloExecutable)) || (await exists(soloData)) ? "trae-solo" : "trae-ide";
212
+ }
213
+
214
+ function targetPlan(target, { sourceRoot, home, appData }) {
215
+ if (target === "cursor") return Object.freeze({
216
+ target,
217
+ source: path.join(sourceRoot, "integrations", "hellolynk-cursor", "skills", "hellolynk-messaging"),
218
+ destination: path.join(home, ".cursor", "skills", "hellolynk-messaging"),
219
+ legacyDestination: null,
220
+ restartClient: "Cursor",
221
+ });
222
+ if (target === "trae-solo") return Object.freeze({
223
+ target,
224
+ source: path.join(sourceRoot, "integrations", "trae-work", "skills", "hellolynk-inbox"),
225
+ destination: path.join(home, ".trae-cn", "skills", "hellolynk-inbox"),
226
+ legacyDestination: path.join(home, ".trae-cn", "skills", "lynk-inbox"),
227
+ restartClient: "TRAE SOLO CN",
228
+ });
229
+ if (target === "trae-ide") return Object.freeze({
230
+ target,
231
+ source: path.join(sourceRoot, "integrations", "trae-work", "skills", "hellolynk-inbox"),
232
+ // The official global Skill directory is .trae-cn/skills. The .agents
233
+ // directory in the IDE guide is project-scoped, not HOME-scoped.
234
+ destination: path.join(home, ".trae-cn", "skills", "hellolynk-inbox"),
235
+ legacyDestination: path.join(home, ".trae-cn", "skills", "lynk-inbox"),
236
+ restartClient: "TRAE CN",
237
+ });
238
+ return Object.freeze({
239
+ target,
240
+ source: path.join(sourceRoot, "integrations", "workbuddy", "skills", "hellolynk-inbox"),
241
+ destination: path.join(home, ".workbuddy", "skills", "hellolynk-inbox"),
242
+ legacyDestination: path.join(home, ".workbuddy", "skills", "lynk-inbox"),
243
+ restartClient: "WorkBuddy",
244
+ });
245
+ }
246
+
247
+ async function assertSkillSource(plan) {
248
+ const metadata = await fs.lstat(plan.source).catch(() => null);
249
+ if (!metadata?.isDirectory() || metadata.isSymbolicLink()) throw new Error(`client_skill_source_invalid:${plan.target}`);
250
+ const required = plan.target === "workbuddy" ? ["SKILL.md", "workbuddy.json"] : ["SKILL.md"];
251
+ for (const name of required) {
252
+ const item = await fs.lstat(path.join(plan.source, name)).catch(() => null);
253
+ if (!item?.isFile() || item.isSymbolicLink()) throw new Error(`client_skill_source_invalid:${plan.target}:${name}`);
254
+ }
255
+ if (plan.target === "cursor" && (await fs.readdir(plan.source)).sort().join(",") !== "SKILL.md") {
256
+ throw new Error("client_skill_source_invalid:cursor:unexpected_file");
257
+ }
258
+ const name = await assertSkillSourceMetadata(plan.source, plan.target === "cursor" ? "hellolynk-messaging" : "hellolynk-inbox");
259
+ const metadataIdentity = plan.target === "workbuddy"
260
+ ? await assertWorkBuddySourceMetadata(plan.source)
261
+ : `skill:${name}`;
262
+ let digest;
263
+ try { digest = await regularTreeDigest(plan.source); }
264
+ catch (error) {
265
+ if (error?.message === "client_skill_tree_invalid") {
266
+ throw new Error(`client_skill_source_file_invalid:${plan.target}`);
267
+ }
268
+ throw error;
269
+ }
270
+ const body = plan.target === "cursor" ? await fs.readFile(path.join(plan.source, "SKILL.md")) : null;
271
+ return Object.freeze({ digest, name, metadataIdentity, ...(body ? {
272
+ files: Object.freeze([Object.freeze({ path: "SKILL.md", bytes: body.length,
273
+ sha256: crypto.createHash("sha256").update(body).digest("hex") })]),
274
+ } : {}) });
275
+ }
276
+
277
+ async function assertSkillSourceMetadata(root, expectedName = "hellolynk-inbox") {
278
+ const skillPath = path.join(root, "SKILL.md");
279
+ const body = await fs.readFile(skillPath, "utf8");
280
+ const lines = body.split(/\r?\n/);
281
+ if (lines[0] !== "---") throw new Error("client_skill_source_metadata_invalid");
282
+ const closing = lines.findIndex((line, index) => index > 0 && line === "---");
283
+ if (closing < 2) throw new Error("client_skill_source_metadata_invalid");
284
+ const names = lines.slice(1, closing)
285
+ .filter((line) => /^\s*name\s*:/.test(line))
286
+ .map((line) => line.trim().replace(/^name\s*:\s*/, "").trim());
287
+ if (names.length !== 1 || names[0] !== expectedName) {
288
+ throw new Error("client_skill_source_metadata_invalid");
289
+ }
290
+ if (expectedName === "hellolynk-messaging") {
291
+ const descriptions = lines.slice(1, closing).filter((line) => /^description:\s*\S/.test(line));
292
+ if (descriptions.length !== 1) throw new Error("client_skill_source_metadata_invalid");
293
+ }
294
+ return names[0];
295
+ }
296
+
297
+ async function assertWorkBuddySourceMetadata(root) {
298
+ let value;
299
+ try { value = JSON.parse(await fs.readFile(path.join(root, "workbuddy.json"), "utf8")); }
300
+ catch { throw new Error("client_skill_source_metadata_invalid"); }
301
+ const keys = ["description_en", "description_zh", "display_name", "display_name_en"];
302
+ if (!value || typeof value !== "object" || Array.isArray(value)
303
+ || Object.keys(value).sort().join(",") !== keys.join(",")
304
+ || value.display_name !== "HelloLynk 通讯" || value.display_name_en !== "HelloLynk Messaging"
305
+ || typeof value.description_zh !== "string" || !value.description_zh.trim()
306
+ || typeof value.description_en !== "string" || !value.description_en.trim()) {
307
+ throw new Error("client_skill_source_metadata_invalid");
308
+ }
309
+ return "workbuddy:HelloLynk 通讯:HelloLynk Messaging";
310
+ }
311
+
312
+ async function inspectClientSkillDestination(plan, { trustedAnchor, replace }) {
313
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(plan.destination), { allowMissing: true });
314
+ const state = await lstatMaybe(plan.destination);
315
+ if (state === null) return Object.freeze({ status: "absent", digest: null, matchesSource: false });
316
+ await assertClientSkillPathChain(trustedAnchor, plan.destination);
317
+ const digest = await regularTreeDigest(plan.destination);
318
+ const matchesSource = await equalTrees(plan.source, plan.destination);
319
+ const matchesPrevious = plan.target === "cursor" && plan.previousState?.digest === digest;
320
+ if (!matchesSource && !matchesPrevious && replace !== true) throw new Error(`client_skill_destination_differs:${plan.target}`);
321
+ return Object.freeze({ status: matchesSource ? "current" : matchesPrevious ? "managed-upgrade" : "replaceable", digest, matchesSource });
322
+ }
323
+
324
+ export async function inspectLegacyClientSkill({ destination, trustedAnchor, target = "unknown" }) {
325
+ const resolvedDestination = path.resolve(String(destination || ""));
326
+ try {
327
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(resolvedDestination), { allowMissing: true });
328
+ const state = await lstatMaybe(resolvedDestination);
329
+ if (state === null) return Object.freeze({
330
+ destination: resolvedDestination,
331
+ active: false,
332
+ status: "absent",
333
+ name: null,
334
+ digest: null,
335
+ });
336
+ await assertClientSkillPathChain(trustedAnchor, resolvedDestination);
337
+ const legacyName = await assertLegacySkillMetadata(resolvedDestination);
338
+ if (legacyName === "HelloLynk收件箱" && (!target.startsWith("trae-")
339
+ || (path.basename(resolvedDestination) !== "HelloLynk收件箱"
340
+ && !path.basename(resolvedDestination).startsWith("lynk-inbox.retired-")))) {
341
+ throw new Error("client_skill_legacy_metadata_invalid");
342
+ }
343
+ const digest = await regularTreeDigest(resolvedDestination);
344
+ return Object.freeze({
345
+ destination: resolvedDestination,
346
+ active: true,
347
+ status: "ready-to-retire",
348
+ name: legacyName,
349
+ digest,
350
+ });
351
+ } catch (error) {
352
+ if (error?.message === "client_skill_destination_path_invalid") throw error;
353
+ const failure = new Error(`client_skill_legacy_invalid:${target}`);
354
+ failure.cause = error;
355
+ throw failure;
356
+ }
357
+ }
358
+
359
+ async function inspectChineseLegacySkill({ destination, trustedAnchor, target }) {
360
+ const resolved = path.resolve(destination);
361
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(resolved), { allowMissing: true });
362
+ const state = await lstatMaybe(resolved);
363
+ if (!state) return Object.freeze({ destination: resolved, active: false, status: "absent", name: null, digest: null });
364
+ await assertClientSkillPathChain(trustedAnchor, resolved);
365
+ const entries = await fs.readdir(resolved);
366
+ if (entries.length !== 1 || entries[0] !== "SKILL.md") {
367
+ return Object.freeze({ destination: resolved, active: false, status: "unknown", name: null, digest: null });
368
+ }
369
+ const metadata = await fs.lstat(path.join(resolved, "SKILL.md"));
370
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.nlink !== 1) {
371
+ throw new Error(`client_skill_legacy_invalid:${target}`);
372
+ }
373
+ let name;
374
+ try { name = await assertLegacySkillMetadata(resolved); }
375
+ catch (error) {
376
+ if (error?.message !== "client_skill_legacy_metadata_invalid") throw error;
377
+ return Object.freeze({ destination: resolved, active: false, status: "unknown", name: null, digest: null });
378
+ }
379
+ if (name !== "HelloLynk收件箱") return Object.freeze({ destination: resolved, active: false, status: "unknown", name: null, digest: null });
380
+ return Object.freeze({ destination: resolved, active: true, status: "ready-to-retire", name, digest: await regularTreeDigest(resolved) });
381
+ }
382
+
383
+ async function assertLegacySkillMetadata(root) {
384
+ const skillPath = path.join(root, "SKILL.md");
385
+ const metadata = await lstatMaybe(skillPath);
386
+ if (!metadata?.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
387
+ throw new Error("client_skill_legacy_metadata_invalid");
388
+ }
389
+ const body = await fs.readFile(skillPath, "utf8");
390
+ const lines = body.split(/\r?\n/);
391
+ if (lines[0] !== "---") throw new Error("client_skill_legacy_metadata_invalid");
392
+ const closing = lines.findIndex((line, index) => index > 0 && line === "---");
393
+ if (closing < 2) throw new Error("client_skill_legacy_metadata_invalid");
394
+ const names = lines.slice(1, closing)
395
+ .filter((line) => /^\s*name\s*:/.test(line))
396
+ .map((line) => line.trim().replace(/^name\s*:\s*/, "").trim());
397
+ const chinese = names.length === 1 && names[0] === "HelloLynk收件箱"
398
+ && (await fs.readdir(root)).length === 1
399
+ && body.includes("在 TRAE Work 中打开和使用 HelloLynk 通讯")
400
+ && body.includes("已有独立只读授权内调用 `hellolynk_read_authorized_inbox_message`")
401
+ && body.includes("`OpenPreview`");
402
+ if (names.length !== 1 || (names[0] !== "lynk-inbox" && !chinese)) {
403
+ throw new Error("client_skill_legacy_metadata_invalid");
404
+ }
405
+ return names[0];
406
+ }
407
+
408
+ async function retireLegacySkill(plan, { trustedAnchor, afterRetire, recoveryPaths, chinese = false }) {
409
+ const expected = plan.legacy;
410
+ const current = await (chinese ? inspectChineseLegacySkill : inspectLegacyClientSkill)({
411
+ destination: plan.legacyDestination,
412
+ trustedAnchor,
413
+ target: plan.target,
414
+ });
415
+ if (current.active !== expected.active || current.digest !== expected.digest) {
416
+ throw new Error(`client_skill_legacy_changed:${plan.target}`);
417
+ }
418
+ if (!current.active) return Object.freeze({
419
+ result: Object.freeze({
420
+ destination: plan.legacyDestination,
421
+ active: false,
422
+ status: current.status,
423
+ backup: null,
424
+ digest: null,
425
+ backupDigestVerified: false,
426
+ recoverable: false,
427
+ }),
428
+ undo: Object.freeze({ mode: "noop", createdParents: Object.freeze([]) }),
429
+ });
430
+
431
+ const createdParents = await ensureDirectoryChain(trustedAnchor, plan.backupRoot);
432
+ const backup = path.join(
433
+ plan.backupRoot,
434
+ `lynk-inbox.retired-${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${crypto.randomUUID()}`,
435
+ );
436
+ let moved = false;
437
+ try {
438
+ if (await lstatMaybe(backup)) throw new Error("client_skill_legacy_backup_conflict");
439
+ await fs.rename(plan.legacyDestination, backup);
440
+ moved = true;
441
+ if (await lstatMaybe(plan.legacyDestination)) throw new Error("client_skill_legacy_still_active");
442
+ const retired = await inspectLegacyClientSkill({
443
+ destination: backup,
444
+ trustedAnchor,
445
+ target: plan.target,
446
+ });
447
+ if (!retired.active || retired.digest !== current.digest) {
448
+ throw new Error("client_skill_legacy_backup_digest_invalid");
449
+ }
450
+ if (afterRetire) await afterRetire({
451
+ target: plan.target,
452
+ destination: plan.legacyDestination,
453
+ backup,
454
+ digest: current.digest,
455
+ });
456
+ return Object.freeze({
457
+ result: Object.freeze({
458
+ destination: plan.legacyDestination,
459
+ active: false,
460
+ status: "retired",
461
+ backup,
462
+ digest: current.digest,
463
+ backupDigestVerified: true,
464
+ recoverable: true,
465
+ }),
466
+ undo: Object.freeze({
467
+ mode: "retired",
468
+ destination: plan.legacyDestination,
469
+ backup,
470
+ digest: current.digest,
471
+ createdParents,
472
+ target: plan.target,
473
+ }),
474
+ });
475
+ } catch (error) {
476
+ try {
477
+ if (moved) {
478
+ await restoreRetiredLegacy({
479
+ mode: "retired",
480
+ destination: plan.legacyDestination,
481
+ backup,
482
+ digest: current.digest,
483
+ createdParents,
484
+ target: plan.target,
485
+ }, trustedAnchor);
486
+ } else {
487
+ await removeCreatedDirectories(createdParents, trustedAnchor);
488
+ }
489
+ } catch (restoreError) {
490
+ if (moved) await recordRecoveryPath(recoveryPaths, backup);
491
+ const failure = new Error(`client_skill_legacy_retirement_rollback_failed:${plan.target}`);
492
+ failure.cause = restoreError;
493
+ failure.retirementCause = error;
494
+ throw failure;
495
+ }
496
+ throw error;
497
+ }
498
+ }
499
+
500
+ export async function verifyClientSkillMigration({
501
+ source,
502
+ destination,
503
+ legacyDestination,
504
+ trustedAnchor,
505
+ target = "unknown",
506
+ }) {
507
+ await verifyInstalledClientSkill({ source, destination, trustedAnchor });
508
+ const legacy = await inspectLegacyClientSkill({
509
+ destination: legacyDestination,
510
+ trustedAnchor,
511
+ target,
512
+ });
513
+ const chineseLegacy = target.startsWith("trae-") ? await inspectChineseLegacySkill({
514
+ destination: path.join(path.dirname(destination), "HelloLynk收件箱"), trustedAnchor, target,
515
+ }) : null;
516
+ if (legacy.active || chineseLegacy?.active) throw new Error(`client_skill_legacy_still_active:${target}`);
517
+ return Object.freeze({ verified: true, legacyActive: false, legacyStatus: legacy.status });
518
+ }
519
+
520
+ // Cursor owns one file, including an unchanged file from a verified prior release.
521
+ // Never replace user content or
522
+ // retire unrelated legacy skills, and never recursively delete its destination.
523
+ async function installCursorSkill(plan, { trustedAnchor, fingerprint, afterActivate }) {
524
+ const sourceState = await assertSkillSource(plan);
525
+ const previousState = plan.previousSource
526
+ ? await assertSkillSource({ ...plan, source: plan.previousSource }) : null;
527
+ const destinationState = await inspectClientSkillDestination(plan, { trustedAnchor, replace: false });
528
+ if (clientSkillPlanFingerprint([{ ...plan, sourceState, previousState, destinationState }]) !== fingerprint) {
529
+ throw new Error("client_skill_changed_since_preflight");
530
+ }
531
+ const file = sourceState.files[0];
532
+ const receipt = Object.freeze({
533
+ contract: "hellolynk-cursor-skill-install-receipt-v1", target: "cursor",
534
+ destination: plan.destination, fingerprint,
535
+ files: Object.freeze([Object.freeze({ ...file })]),
536
+ });
537
+ if (destinationState.status === "current") {
538
+ return Object.freeze({ result: Object.freeze({ ...plan, changed: false, backup: null, receipt }),
539
+ undo: Object.freeze({ mode: "noop" }) });
540
+ }
541
+ if (destinationState.status === "managed-upgrade") {
542
+ return upgradeCursorSkill(plan, { trustedAnchor, receipt, afterActivate });
543
+ }
544
+
545
+ const directories = [];
546
+ let ownedFile = null;
547
+ let handle = null;
548
+ try {
549
+ await createCursorDirectoryChain(trustedAnchor, path.dirname(plan.destination), directories);
550
+ // mkdir must be exclusive: rename could replace a concurrently created
551
+ // empty user directory. The same rule applies to the single file below.
552
+ try { await fs.mkdir(plan.destination); }
553
+ catch (error) {
554
+ if (error?.code === "EEXIST") throw new Error("client_skill_changed_since_preflight");
555
+ throw error;
556
+ }
557
+ const directoryState = await fs.lstat(plan.destination);
558
+ directories.push(Object.freeze({ path: plan.destination, dev: directoryState.dev, ino: directoryState.ino }));
559
+ await assertClientSkillPathChain(trustedAnchor, plan.destination);
560
+ const body = await fs.readFile(path.join(plan.source, "SKILL.md"));
561
+ if (body.length !== file.bytes || sha256Bytes(body) !== file.sha256) {
562
+ throw new Error("client_skill_changed_since_preflight");
563
+ }
564
+ const destinationFile = path.join(plan.destination, "SKILL.md");
565
+ handle = await fs.open(destinationFile, "wx+", 0o600);
566
+ const identity = await handle.stat();
567
+ ownedFile = { path: destinationFile, dev: identity.dev, ino: identity.ino, bytes: 0,
568
+ sha256: sha256Bytes(Buffer.alloc(0)) };
569
+ try {
570
+ await handle.writeFile(body);
571
+ ownedFile.bytes = file.bytes;
572
+ ownedFile.sha256 = file.sha256;
573
+ } catch (error) {
574
+ // Capture a failed write's actual bytes through our still-owned handle.
575
+ const partial = Buffer.alloc((await handle.stat()).size);
576
+ await handle.read(partial, 0, partial.length, 0);
577
+ ownedFile.bytes = partial.length;
578
+ ownedFile.sha256 = sha256Bytes(partial);
579
+ throw error;
580
+ }
581
+ await handle.close();
582
+ handle = null;
583
+ if (afterActivate) await afterActivate({ target: "cursor", destination: plan.destination });
584
+ await verifyInstalledClientSkill({ source: plan.source, destination: plan.destination, trustedAnchor });
585
+ if ((await assertSkillSource(plan)).digest !== sourceState.digest) {
586
+ throw new Error("client_skill_changed_since_preflight");
587
+ }
588
+ return Object.freeze({
589
+ result: Object.freeze({ ...plan, changed: true, backup: null, receipt }),
590
+ undo: Object.freeze({ mode: "cursor-new", file: Object.freeze(ownedFile), directories: Object.freeze(directories) }),
591
+ });
592
+ } catch (error) {
593
+ await handle?.close().catch(() => {});
594
+ try { await rollbackCursorSkill({ file: ownedFile, directories }, trustedAnchor); }
595
+ catch (rollbackError) {
596
+ const failure = new Error("client_skill_cursor_rollback_preserved_changed_files");
597
+ failure.cause = rollbackError;
598
+ failure.installCause = error;
599
+ throw failure;
600
+ }
601
+ throw error;
602
+ }
603
+ }
604
+
605
+ async function upgradeCursorSkill(plan, { trustedAnchor, receipt, afterActivate }) {
606
+ const destinationFile = path.join(plan.destination, "SKILL.md");
607
+ const previous = await fs.readFile(path.join(plan.previousSource, "SKILL.md"));
608
+ const next = await fs.readFile(path.join(plan.source, "SKILL.md"));
609
+ if (sha256Bytes(previous) !== plan.previousState.files[0].sha256
610
+ || sha256Bytes(next) !== plan.sourceState.files[0].sha256) {
611
+ throw new Error("client_skill_changed_since_preflight");
612
+ }
613
+ let undo = null;
614
+ try {
615
+ const identity = await fs.lstat(destinationFile);
616
+ const owned = await replaceCursorOwnedFile(destinationFile, { ...identity, sha256: sha256Bytes(previous) }, next, trustedAnchor);
617
+ undo = Object.freeze({ mode: "cursor-upgrade", file: owned, previous });
618
+ if (afterActivate) await afterActivate({ target: "cursor", destination: plan.destination });
619
+ await verifyInstalledClientSkill({ source: plan.source, destination: plan.destination, trustedAnchor });
620
+ if ((await assertSkillSource(plan)).digest !== plan.sourceState.digest) throw new Error("client_skill_changed_since_preflight");
621
+ return Object.freeze({ result: Object.freeze({ ...plan, changed: true, backup: null, receipt }), undo });
622
+ } catch (error) {
623
+ if (undo) {
624
+ try { await replaceCursorOwnedFile(undo.file.path, undo.file, previous, trustedAnchor); }
625
+ catch (rollbackError) {
626
+ const failure = new Error("client_skill_cursor_rollback_preserved_changed_files");
627
+ failure.cause = rollbackError;
628
+ failure.installCause = error;
629
+ throw failure;
630
+ }
631
+ }
632
+ throw error;
633
+ }
634
+ }
635
+
636
+ // Stage a complete single file, then replace only the still-owned bytes. A
637
+ // failed write leaves the old file intact; rollback preserves concurrent edits.
638
+ async function replaceCursorOwnedFile(file, expected, body, trustedAnchor) {
639
+ const parent = path.dirname(file);
640
+ await assertClientSkillPathChain(trustedAnchor, parent);
641
+ const staging = path.join(parent, `.hellolynk-${crypto.randomUUID()}.tmp`);
642
+ let handle;
643
+ try {
644
+ handle = await fs.open(staging, "wx", 0o600);
645
+ const identity = await handle.stat();
646
+ await handle.writeFile(body);
647
+ await handle.sync();
648
+ await handle.close();
649
+ handle = null;
650
+ await assertClientSkillPathChain(trustedAnchor, parent);
651
+ const current = await fs.lstat(file);
652
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1
653
+ || current.dev !== expected.dev || current.ino !== expected.ino
654
+ || sha256Bytes(await fs.readFile(file)) !== expected.sha256) {
655
+ throw new Error("client_skill_changed_since_preflight");
656
+ }
657
+ await fs.rename(staging, file);
658
+ return Object.freeze({ path: file, dev: identity.dev, ino: identity.ino, sha256: sha256Bytes(body) });
659
+ } finally {
660
+ await handle?.close().catch(() => {});
661
+ await fs.unlink(staging).catch((error) => { if (error?.code !== "ENOENT") throw error; });
662
+ }
663
+ }
664
+
665
+ async function createCursorDirectoryChain(anchor, target, created) {
666
+ await assertClientSkillPathChain(anchor, target, { allowMissing: true });
667
+ let cursor = path.resolve(anchor);
668
+ for (const segment of path.relative(cursor, target).split(path.sep).filter(Boolean)) {
669
+ cursor = path.join(cursor, segment);
670
+ if (await lstatMaybe(cursor) === null) {
671
+ try {
672
+ await fs.mkdir(cursor);
673
+ const state = await fs.lstat(cursor);
674
+ created.push(Object.freeze({ path: cursor, dev: state.dev, ino: state.ino }));
675
+ } catch (error) { if (error?.code !== "EEXIST") throw error; }
676
+ }
677
+ await assertClientSkillPathChain(anchor, cursor);
678
+ }
679
+ }
680
+
681
+ async function rollbackCursorSkill(undo, trustedAnchor) {
682
+ let changedFile = false;
683
+ if (undo.file) {
684
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(undo.file.path));
685
+ const current = await lstatMaybe(undo.file.path);
686
+ if (current) {
687
+ if (current.isFile() && !current.isSymbolicLink() && current.nlink === 1
688
+ && current.dev === undo.file.dev && current.ino === undo.file.ino
689
+ && current.size === undo.file.bytes
690
+ && sha256Bytes(await fs.readFile(undo.file.path)) === undo.file.sha256) {
691
+ await fs.unlink(undo.file.path);
692
+ } else changedFile = true;
693
+ }
694
+ }
695
+ for (const directory of [...undo.directories].reverse()) {
696
+ const current = await lstatMaybe(directory.path);
697
+ if (!current || !current.isDirectory() || current.isSymbolicLink()
698
+ || current.dev !== directory.dev || current.ino !== directory.ino) continue;
699
+ await assertClientSkillPathChain(trustedAnchor, directory.path);
700
+ try { await fs.rmdir(directory.path); }
701
+ catch (error) { if (!["ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error; }
702
+ }
703
+ if (changedFile) throw new Error("client_skill_cursor_owned_file_changed");
704
+ }
705
+
706
+ function sha256Bytes(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
707
+
708
+ async function installOne(plan, { replace, trustedAnchor, afterActivate, recoveryPaths }) {
709
+ const parent = path.dirname(plan.destination);
710
+ const privateStagingRoot = path.join(trustedAnchor, ".hellolynk", "skill-installs");
711
+ const staging = path.join(privateStagingRoot, `hellolynk-inbox.staging-${crypto.randomUUID()}`);
712
+ const createdParents = await ensureDirectoryChain(trustedAnchor, parent);
713
+ await ensureDirectoryChain(trustedAnchor, privateStagingRoot);
714
+ let backup = null;
715
+ let previousDigest = null;
716
+ try {
717
+ await copyRegularTree(plan.source, staging);
718
+ await assertClientSkillPathChain(trustedAnchor, staging);
719
+ await verifyExactRegularTree(plan.source, staging);
720
+ if ((await lstatMaybe(plan.destination)) === null) {
721
+ await fs.rename(staging, plan.destination);
722
+ try {
723
+ if (afterActivate) await afterActivate({ destination: plan.destination, target: plan.target });
724
+ await verifyInstalledClientSkill({
725
+ source: plan.source,
726
+ destination: plan.destination,
727
+ trustedAnchor,
728
+ allowLegacyActive: true,
729
+ });
730
+ } catch (error) {
731
+ await deactivateUnverifiedDestination(
732
+ plan.destination,
733
+ privateStagingRoot,
734
+ trustedAnchor,
735
+ recoveryPaths,
736
+ );
737
+ throw error;
738
+ }
739
+ return Object.freeze({
740
+ result: Object.freeze({ ...plan, changed: true, backup: null }),
741
+ undo: Object.freeze({ mode: "new", createdParents }),
742
+ });
743
+ }
744
+ await assertClientSkillPathChain(trustedAnchor, plan.destination);
745
+ if (await equalTrees(staging, plan.destination)) {
746
+ await removePrivateTree(staging);
747
+ await verifyInstalledClientSkill({
748
+ source: plan.source,
749
+ destination: plan.destination,
750
+ trustedAnchor,
751
+ allowLegacyActive: true,
752
+ });
753
+ return Object.freeze({
754
+ result: Object.freeze({ ...plan, changed: false, backup: null }),
755
+ undo: Object.freeze({ mode: "noop", createdParents }),
756
+ });
757
+ }
758
+ if (!replace) throw new Error(`client_skill_destination_differs:${plan.target}`);
759
+ await ensureDirectoryChain(trustedAnchor, plan.backupRoot);
760
+ backup = path.join(plan.backupRoot, `hellolynk-inbox.backup-${new Date().toISOString().replace(/[-:.TZ]/g, "")}-${crypto.randomUUID()}`);
761
+ previousDigest = await regularTreeDigest(plan.destination);
762
+ await fs.rename(plan.destination, backup);
763
+ recoveryPaths.add(backup);
764
+ await assertClientSkillPathChain(trustedAnchor, backup);
765
+ if (await regularTreeDigest(backup) !== previousDigest) {
766
+ throw new Error("client_skill_backup_digest_invalid");
767
+ }
768
+ let activated = false;
769
+ try {
770
+ await fs.rename(staging, plan.destination);
771
+ activated = true;
772
+ if (afterActivate) await afterActivate({ destination: plan.destination, target: plan.target });
773
+ await verifyInstalledClientSkill({
774
+ source: plan.source,
775
+ destination: plan.destination,
776
+ trustedAnchor,
777
+ allowLegacyActive: true,
778
+ });
779
+ } catch (error) {
780
+ try {
781
+ if (activated && await lstatMaybe(plan.destination)) {
782
+ await deactivateUnverifiedDestination(
783
+ plan.destination,
784
+ privateStagingRoot,
785
+ trustedAnchor,
786
+ recoveryPaths,
787
+ );
788
+ }
789
+ await assertClientSkillPathChain(trustedAnchor, backup);
790
+ if (await regularTreeDigest(backup) !== previousDigest) {
791
+ throw new Error("client_skill_restore_digest_invalid");
792
+ }
793
+ await fs.rename(backup, plan.destination);
794
+ recoveryPaths.delete(backup);
795
+ await assertClientSkillPathChain(trustedAnchor, plan.destination);
796
+ if (await regularTreeDigest(plan.destination) !== previousDigest) {
797
+ throw new Error("client_skill_restore_digest_invalid");
798
+ }
799
+ } catch (restoreError) {
800
+ const failure = new Error(`client_skill_activation_rollback_failed:${plan.target}`);
801
+ failure.cause = restoreError;
802
+ throw failure;
803
+ }
804
+ throw error;
805
+ }
806
+ return Object.freeze({
807
+ result: Object.freeze({ ...plan, changed: true, backup }),
808
+ undo: Object.freeze({ mode: "replace", backup, previousDigest, createdParents }),
809
+ });
810
+ } catch (error) {
811
+ try {
812
+ if (backup && await lstatMaybe(backup) && (await lstatMaybe(plan.destination)) === null) {
813
+ await assertClientSkillPathChain(trustedAnchor, backup);
814
+ if (await regularTreeDigest(backup) !== previousDigest) {
815
+ throw new Error("client_skill_restore_digest_invalid");
816
+ }
817
+ await fs.rename(backup, plan.destination);
818
+ recoveryPaths.delete(backup);
819
+ await assertClientSkillPathChain(trustedAnchor, plan.destination);
820
+ if (await regularTreeDigest(plan.destination) !== previousDigest) {
821
+ throw new Error("client_skill_restore_digest_invalid");
822
+ }
823
+ }
824
+ await removePrivateTree(staging).catch(() => {});
825
+ await removeCreatedDirectories(createdParents, trustedAnchor);
826
+ } catch (restoreError) {
827
+ const failure = new Error(`client_skill_activation_rollback_failed:${plan.target}`);
828
+ failure.cause = restoreError;
829
+ throw failure;
830
+ }
831
+ throw error;
832
+ }
833
+ }
834
+
835
+ async function rollbackCompletedInstall(item, trustedAnchor, recoveryPaths) {
836
+ if (item.undo.mode === "noop") return;
837
+ if (item.undo.mode === "cursor-new") return rollbackCursorSkill(item.undo, trustedAnchor);
838
+ if (item.undo.mode === "cursor-upgrade") return replaceCursorOwnedFile(item.undo.file.path, item.undo.file, item.undo.previous, trustedAnchor);
839
+ const privateStagingRoot = path.join(trustedAnchor, ".hellolynk", "skill-installs");
840
+ await deactivateUnverifiedDestination(
841
+ item.result.destination,
842
+ privateStagingRoot,
843
+ trustedAnchor,
844
+ recoveryPaths,
845
+ );
846
+ if (item.undo.mode === "replace") {
847
+ await assertClientSkillPathChain(trustedAnchor, item.undo.backup);
848
+ if (await regularTreeDigest(item.undo.backup) !== item.undo.previousDigest) {
849
+ throw new Error("client_skill_restore_digest_invalid");
850
+ }
851
+ await fs.rename(item.undo.backup, item.result.destination);
852
+ recoveryPaths.delete(item.undo.backup);
853
+ await assertClientSkillPathChain(trustedAnchor, item.result.destination);
854
+ if (await regularTreeDigest(item.result.destination) !== item.undo.previousDigest) {
855
+ await deactivateUnverifiedDestination(
856
+ item.result.destination,
857
+ privateStagingRoot,
858
+ trustedAnchor,
859
+ recoveryPaths,
860
+ ).catch(() => {});
861
+ throw new Error("client_skill_restore_digest_invalid");
862
+ }
863
+ }
864
+ await removeCreatedDirectories(item.undo.createdParents, trustedAnchor);
865
+ }
866
+
867
+ async function rollbackCompletedMigration(item, trustedAnchor, recoveryPaths) {
868
+ const failures = [];
869
+ try {
870
+ await rollbackCompletedInstall(item.install, trustedAnchor, recoveryPaths);
871
+ } catch (error) {
872
+ failures.push(error);
873
+ }
874
+ try {
875
+ if (item.chineseRetirement) await restoreRetiredLegacy(item.chineseRetirement.undo, trustedAnchor);
876
+ } catch (error) {
877
+ await recordRecoveryPath(recoveryPaths, item.chineseRetirement?.undo.backup);
878
+ failures.push(error);
879
+ }
880
+ try {
881
+ await restoreRetiredLegacy(item.retirement.undo, trustedAnchor);
882
+ } catch (error) {
883
+ await recordRecoveryPath(recoveryPaths, item.retirement.undo.backup);
884
+ failures.push(error);
885
+ }
886
+ if (failures.length > 0) {
887
+ const failure = new Error(`client_skill_migration_rollback_failed:${item.install.result.target}`);
888
+ failure.cause = failures[0];
889
+ if (failures.length > 1) failure.secondaryCause = failures[1];
890
+ throw failure;
891
+ }
892
+ }
893
+
894
+ async function recordRecoveryPath(recoveryPaths, backup) {
895
+ if (!recoveryPaths || !backup) return;
896
+ try { if (await lstatMaybe(backup)) recoveryPaths.add(backup); }
897
+ catch { /* Recovery diagnostics must not interrupt other rollback attempts. */ }
898
+ }
899
+
900
+ async function restoreRetiredLegacy(undo, trustedAnchor) {
901
+ if (undo.mode === "noop") return;
902
+ if (await lstatMaybe(undo.destination)) {
903
+ throw new Error(`client_skill_legacy_restore_conflict:${undo.target}`);
904
+ }
905
+ const backup = await inspectLegacyClientSkill({
906
+ destination: undo.backup,
907
+ trustedAnchor,
908
+ target: undo.target,
909
+ });
910
+ if (!backup.active || backup.digest !== undo.digest) {
911
+ throw new Error("client_skill_legacy_restore_digest_invalid");
912
+ }
913
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(undo.destination), { allowMissing: false });
914
+ await fs.rename(undo.backup, undo.destination);
915
+ const restored = await inspectLegacyClientSkill({
916
+ destination: undo.destination,
917
+ trustedAnchor,
918
+ target: undo.target,
919
+ });
920
+ if (!restored.active || restored.digest !== undo.digest) {
921
+ throw new Error("client_skill_legacy_restore_digest_invalid");
922
+ }
923
+ await removeCreatedDirectories(undo.createdParents, trustedAnchor);
924
+ }
925
+
926
+ async function deactivateUnverifiedDestination(destination, privateStagingRoot, trustedAnchor, recoveryPaths) {
927
+ await assertClientSkillPathChain(trustedAnchor, path.dirname(destination));
928
+ const state = await lstatMaybe(destination);
929
+ if (state === null) return null;
930
+ if (!state.isDirectory() || state.isSymbolicLink()) {
931
+ throw new Error("client_skill_unverified_destination_deactivation_failed");
932
+ }
933
+ const failed = path.join(privateStagingRoot, `hellolynk-inbox.failed-${crypto.randomUUID()}`);
934
+ if (await lstatMaybe(failed)) throw new Error("client_skill_failed_path_invalid");
935
+ try {
936
+ await fs.rename(destination, failed);
937
+ // Published trees may contain later user writes. Retain them outside the
938
+ // client's Skill scan roots, even when they currently match our source.
939
+ recoveryPaths.add(failed);
940
+ if (await lstatMaybe(destination)) throw new Error("client_skill_unverified_destination_active");
941
+ await assertClientSkillPathChain(trustedAnchor, failed);
942
+ return failed;
943
+ } catch (renameError) {
944
+ // A blocked move is a rollback failure, not permission to delete a live tree.
945
+ if (await lstatMaybe(destination)) recoveryPaths.add(destination);
946
+ const failure = new Error("client_skill_unverified_destination_deactivation_failed");
947
+ failure.cause = renameError;
948
+ throw failure;
949
+ }
950
+ }
951
+
952
+ async function removePrivateTree(target) {
953
+ if ((await lstatMaybe(target)) === null) return;
954
+ await fs.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
955
+ if (await lstatMaybe(target)) throw new Error("client_skill_private_cleanup_failed");
956
+ }
957
+
958
+ async function ensureDirectoryChain(trustedAnchor, destination) {
959
+ const anchor = path.resolve(trustedAnchor);
960
+ const target = path.resolve(destination);
961
+ const relative = path.relative(anchor, target);
962
+ if (relative === ".." || path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`)) {
963
+ throw new Error("client_skill_destination_path_invalid");
964
+ }
965
+ await assertClientSkillPathChain(anchor, anchor);
966
+ const created = [];
967
+ let cursor = anchor;
968
+ try {
969
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
970
+ cursor = path.join(cursor, segment);
971
+ const state = await lstatMaybe(cursor);
972
+ if (state === null) {
973
+ try { await fs.mkdir(cursor); }
974
+ catch (error) {
975
+ if (error?.code !== "EEXIST") throw error;
976
+ }
977
+ const createdState = await lstatMaybe(cursor);
978
+ if (!createdState?.isDirectory() || createdState.isSymbolicLink()) {
979
+ throw new Error("client_skill_destination_path_invalid");
980
+ }
981
+ created.push(cursor);
982
+ } else if (!state.isDirectory() || state.isSymbolicLink()) {
983
+ throw new Error("client_skill_destination_path_invalid");
984
+ }
985
+ await assertClientSkillPathChain(anchor, cursor);
986
+ }
987
+ return Object.freeze(created);
988
+ } catch (error) {
989
+ await removeCreatedDirectories(created, anchor);
990
+ throw error;
991
+ }
992
+ }
993
+
994
+ async function removeCreatedDirectories(created, trustedAnchor) {
995
+ for (const directory of [...created].reverse()) {
996
+ if ((await lstatMaybe(directory)) === null) continue;
997
+ await assertClientSkillPathChain(trustedAnchor, directory);
998
+ try { await fs.rmdir(directory); }
999
+ catch (error) {
1000
+ if (error?.code === "ENOTEMPTY" || error?.code === "EEXIST") continue;
1001
+ throw error;
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ async function lstatMaybe(target) {
1007
+ try { return await fs.lstat(target); }
1008
+ catch (error) {
1009
+ if (error?.code === "ENOENT") return null;
1010
+ throw error;
1011
+ }
1012
+ }
1013
+
1014
+ async function regularTreeDigest(root) {
1015
+ const hash = crypto.createHash("sha256");
1016
+ await hashRegularTree(root, "", hash);
1017
+ return hash.digest("hex");
1018
+ }
1019
+
1020
+ async function hashRegularTree(root, relative, hash) {
1021
+ const absolute = relative ? path.join(root, ...relative.split("/")) : root;
1022
+ const metadata = await fs.lstat(absolute);
1023
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("client_skill_tree_invalid");
1024
+ const entries = await fs.readdir(absolute, { withFileTypes: true });
1025
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1026
+ for (const entry of entries) {
1027
+ const itemRelative = relative ? `${relative}/${entry.name}` : entry.name;
1028
+ const item = path.join(root, ...itemRelative.split("/"));
1029
+ const itemMetadata = await fs.lstat(item);
1030
+ if (itemMetadata.isSymbolicLink()) throw new Error("client_skill_tree_invalid");
1031
+ if (itemMetadata.isDirectory()) {
1032
+ hash.update(`d\0${itemRelative}\0`);
1033
+ await hashRegularTree(root, itemRelative, hash);
1034
+ } else if (itemMetadata.isFile() && itemMetadata.nlink === 1) {
1035
+ const body = await fs.readFile(item);
1036
+ hash.update(`f\0${itemRelative}\0${body.length}\0`);
1037
+ hash.update(body);
1038
+ } else throw new Error("client_skill_tree_invalid");
1039
+ }
1040
+ }
1041
+
1042
+ async function copyRegularTree(source, destination) {
1043
+ await fs.mkdir(destination, { recursive: false });
1044
+ for (const entry of await fs.readdir(source, { withFileTypes: true })) {
1045
+ const from = path.join(source, entry.name);
1046
+ const to = path.join(destination, entry.name);
1047
+ const metadata = await fs.lstat(from);
1048
+ if (metadata.isSymbolicLink()) throw new Error("client_skill_source_link_invalid");
1049
+ if (metadata.isDirectory()) await copyRegularTree(from, to);
1050
+ else if (metadata.isFile() && metadata.nlink === 1) await fs.copyFile(from, to, fs.constants.COPYFILE_EXCL);
1051
+ else throw new Error("client_skill_source_file_invalid");
1052
+ }
1053
+ }
1054
+
1055
+ async function equalTrees(left, right) {
1056
+ const [leftRoot, rightRoot] = await Promise.all([fs.lstat(left), fs.lstat(right)]);
1057
+ if (!leftRoot.isDirectory() || leftRoot.isSymbolicLink()
1058
+ || !rightRoot.isDirectory() || rightRoot.isSymbolicLink()) throw new Error("client_skill_tree_invalid");
1059
+ const leftEntries = await fs.readdir(left, { withFileTypes: true });
1060
+ const rightEntries = await fs.readdir(right, { withFileTypes: true });
1061
+ if (leftEntries.length !== rightEntries.length) return false;
1062
+ const rightMap = new Map(rightEntries.map((entry) => [entry.name, entry]));
1063
+ for (const entry of leftEntries) {
1064
+ const peer = rightMap.get(entry.name);
1065
+ if (!peer || entry.isSymbolicLink() || peer.isSymbolicLink()) return false;
1066
+ const leftPath = path.join(left, entry.name);
1067
+ const rightPath = path.join(right, entry.name);
1068
+ const [leftMetadata, rightMetadata] = await Promise.all([fs.lstat(leftPath), fs.lstat(rightPath)]);
1069
+ if (leftMetadata.isSymbolicLink() || rightMetadata.isSymbolicLink()) throw new Error("client_skill_tree_invalid");
1070
+ if (leftMetadata.isDirectory() && rightMetadata.isDirectory()) {
1071
+ if (!(await equalTrees(leftPath, rightPath))) return false;
1072
+ } else if (leftMetadata.isFile() && rightMetadata.isFile()) {
1073
+ if (leftMetadata.nlink !== 1 || rightMetadata.nlink !== 1) throw new Error("client_skill_tree_invalid");
1074
+ if (leftMetadata.size !== rightMetadata.size
1075
+ || !(await fs.readFile(leftPath)).equals(await fs.readFile(rightPath))) return false;
1076
+ } else return false;
1077
+ }
1078
+ return true;
1079
+ }
1080
+
1081
+ async function verifyExactRegularTree(source, destination) {
1082
+ if (!(await equalTrees(source, destination))) throw new Error("client_skill_destination_differs");
1083
+ }
1084
+
1085
+ // Uninstall removes the installed Skill only while it matches one that an installed release
1086
+ // ships. It leaves the client's Skill folder before it is deleted, so nothing partial stays active.
1087
+ export async function removeInstalledClientSkill(options = {}) {
1088
+ const home = path.resolve(options.home || os.homedir());
1089
+ const appData = path.resolve(options.appData || process.env.APPDATA || path.join(home, "AppData", "Roaming"));
1090
+ const localAppData = path.resolve(options.localAppData || process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"));
1091
+ const target = String(options.target || "").toLowerCase();
1092
+ if (!["trae", "workbuddy"].includes(target)) throw new Error("client_skill_target_unsupported");
1093
+ const [name] = await resolveTargets(target, { home, appData, localAppData });
1094
+ const plan = await resolveProfileLocation(targetPlan(name, { sourceRoot: ROOT, home, appData }), home);
1095
+ if (await lstatMaybe(plan.destination) === null) return Object.freeze({ present: false, removed: false, destination: plan.destination });
1096
+ await assertClientSkillPathChain(plan.trustedAnchor, plan.destination);
1097
+ let shipped = false;
1098
+ for (const sourceRoot of options.sourceRoots || []) {
1099
+ const { source } = targetPlan(name, { sourceRoot: path.resolve(sourceRoot), home, appData });
1100
+ if (await lstatMaybe(source) && await equalTrees(source, plan.destination)) { shipped = true; break; }
1101
+ }
1102
+ if (!shipped) throw new Error("client_skill_destination_differs");
1103
+ if (options.apply !== true) return Object.freeze({ present: true, removed: false, destination: plan.destination });
1104
+ const privateStagingRoot = path.join(plan.trustedAnchor, ".hellolynk", "skill-installs");
1105
+ await ensureDirectoryChain(plan.trustedAnchor, privateStagingRoot);
1106
+ const removed = path.join(privateStagingRoot, `hellolynk-inbox.removed-${crypto.randomUUID()}`);
1107
+ await fs.rename(plan.destination, removed);
1108
+ await removePrivateTree(removed);
1109
+ return Object.freeze({ present: true, removed: true, destination: plan.destination });
1110
+ }
1111
+
1112
+ export async function verifyInstalledClientSkill({
1113
+ source,
1114
+ destination,
1115
+ trustedAnchor,
1116
+ allowLegacyActive = false,
1117
+ }) {
1118
+ await assertClientSkillPathChain(trustedAnchor, destination);
1119
+ await verifyExactRegularTree(source, destination);
1120
+ if (path.basename(destination) === "hellolynk-messaging") {
1121
+ return Object.freeze({ verified: true, legacyActive: false, legacyStatus: "not-applicable" });
1122
+ }
1123
+ const legacyDestination = path.join(path.dirname(destination), "lynk-inbox");
1124
+ const legacy = await inspectLegacyClientSkill({
1125
+ destination: legacyDestination,
1126
+ trustedAnchor,
1127
+ target: path.basename(path.dirname(path.dirname(destination))),
1128
+ });
1129
+ if (legacy.active && allowLegacyActive !== true) {
1130
+ throw new Error("client_skill_legacy_still_active");
1131
+ }
1132
+ return Object.freeze({ verified: true, legacyActive: legacy.active, legacyStatus: legacy.status });
1133
+ }
1134
+
1135
+ export async function assertClientSkillPathChain(trustedAnchor, destination, options = {}) {
1136
+ const anchor = path.resolve(String(trustedAnchor || ""));
1137
+ const target = path.resolve(String(destination || ""));
1138
+ const relative = path.relative(anchor, target);
1139
+ if (relative === ".." || path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`)) {
1140
+ throw new Error("client_skill_destination_path_invalid");
1141
+ }
1142
+ let anchorMetadata;
1143
+ try { anchorMetadata = await fs.lstat(anchor); }
1144
+ catch { throw new Error("client_skill_destination_path_invalid"); }
1145
+ if (!anchorMetadata.isDirectory() || anchorMetadata.isSymbolicLink()) {
1146
+ throw new Error("client_skill_destination_path_invalid");
1147
+ }
1148
+ const anchorCanonical = await fs.realpath(anchor);
1149
+ let cursor = anchor;
1150
+ const candidates = [anchor];
1151
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
1152
+ cursor = path.join(cursor, segment);
1153
+ candidates.push(cursor);
1154
+ }
1155
+ for (const candidate of candidates) {
1156
+ let metadata;
1157
+ try { metadata = await fs.lstat(candidate); }
1158
+ catch (error) {
1159
+ if (error?.code === "ENOENT" && options.allowMissing === true) break;
1160
+ throw new Error("client_skill_destination_path_invalid");
1161
+ }
1162
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1163
+ throw new Error("client_skill_destination_path_invalid");
1164
+ }
1165
+ const canonical = await fs.realpath(candidate);
1166
+ const expectedCanonical = path.resolve(anchorCanonical, path.relative(anchor, candidate));
1167
+ const actual = process.platform === "win32" ? canonical.toLowerCase() : canonical;
1168
+ const expected = process.platform === "win32" ? expectedCanonical.toLowerCase() : expectedCanonical;
1169
+ if (actual !== expected) throw new Error("client_skill_destination_path_invalid");
1170
+ }
1171
+ return Object.freeze({ verified: true });
1172
+ }
1173
+
1174
+ async function exists(target) {
1175
+ return (await lstatMaybe(target)) !== null;
1176
+ }
1177
+
1178
+ function parseArgs(argv) {
1179
+ const options = {};
1180
+ for (let index = 0; index < argv.length; index += 1) {
1181
+ const argument = argv[index];
1182
+ if (argument === "--apply") options.apply = true;
1183
+ else if (argument === "--replace") options.replace = true;
1184
+ else if (argument === "--json") options.json = true;
1185
+ else if (argument === "--target") options.target = take(argv, ++index, argument);
1186
+ else if (argument === "--source-root") options.sourceRoot = take(argv, ++index, argument);
1187
+ else if (argument === "--home") options.home = take(argv, ++index, argument);
1188
+ else if (argument === "--app-data") options.appData = take(argv, ++index, argument);
1189
+ else if (argument === "--local-app-data") options.localAppData = take(argv, ++index, argument);
1190
+ else if (argument === "--expected-plan-fingerprint") options.expectedPlanFingerprint = take(argv, ++index, argument);
1191
+ else throw new Error(`client_skill_arg_unknown:${argument}`);
1192
+ }
1193
+ return options;
1194
+ }
1195
+
1196
+ function take(argv, index, argument) {
1197
+ const value = argv[index];
1198
+ if (!value || value.startsWith("--")) throw new Error(`client_skill_arg_missing:${argument}`);
1199
+ return value;
1200
+ }
1201
+
1202
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1203
+ const options = parseArgs(process.argv.slice(2));
1204
+ installClientSkills(options).then(
1205
+ (result) => process.stdout.write(`${JSON.stringify(result, null, options.json ? 2 : 0)}\n`),
1206
+ (error) => {
1207
+ process.stderr.write(`${JSON.stringify({ ok: false, error: String(error?.message || error), ...(error?.recoveryPaths?.length ? { recoveryPaths: error.recoveryPaths } : {}) })}\n`);
1208
+ process.exitCode = 1;
1209
+ },
1210
+ );
1211
+ }