@lanes-sh/link 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,258 @@
1
+ import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import type { ToolResult } from '#connectivity';
5
+ import { ALWAYS_EXCLUDED } from './operations.ts';
6
+ import { confine, isPlaceholder, placeholderFor, rootOf } from './paths.ts';
7
+ import { error, json } from './result.ts';
8
+ import type { FsConnectorOptions } from './index.ts';
9
+
10
+ /** The nine operations, as free functions over the connector's options. */
11
+
12
+ export async function listFiles(
13
+ options: FsConnectorOptions,
14
+ excluded: ReadonlySet<string>,
15
+ args: Readonly<Record<string, unknown>>,
16
+ ): Promise<ToolResult> {
17
+ const { absolute, relative: shown, root } = await confine(options, excluded, args['path']);
18
+ const limit = Math.min(Number(args['limit'] ?? 200) || 200, 500);
19
+ const recursive = args['recursive'] === true;
20
+
21
+ const entries: unknown[] = [];
22
+
23
+ const walk = async (directory: string, depth: number): Promise<void> => {
24
+ if (entries.length >= limit) return;
25
+
26
+ const found = await readdir(directory, { withFileTypes: true });
27
+ for (const entry of found.sort((a, b) => a.name.localeCompare(b.name))) {
28
+ if (entries.length >= limit) return;
29
+ if (excluded.has(entry.name)) continue;
30
+
31
+ // A placeholder is an implementation detail of eviction, reported on the
32
+ // file it stands for rather than listed as a file of its own.
33
+ if (isPlaceholder(entry.name, options.placeholder?.suffix)) continue;
34
+
35
+ const full = join(directory, entry.name);
36
+ const info = await stat(full).catch(() => null);
37
+
38
+ entries.push({
39
+ path: relative(root, full),
40
+ kind: entry.isDirectory() ? 'folder' : 'file',
41
+ ...(entry.isDirectory()
42
+ ? {}
43
+ : {
44
+ bytes: info?.size ?? null,
45
+ downloaded: (await placeholderFor(full, options.placeholder?.suffix)) === null,
46
+ }),
47
+ modified: info?.mtime.toISOString() ?? null,
48
+ });
49
+
50
+ if (recursive && entry.isDirectory() && depth < 8) await walk(full, depth + 1);
51
+ }
52
+ };
53
+
54
+ await walk(absolute, 0);
55
+
56
+ return json({
57
+ path: shown,
58
+ truncated: entries.length >= limit,
59
+ entries,
60
+ });
61
+ }
62
+
63
+ export async function searchFiles(
64
+ options: FsConnectorOptions,
65
+ excluded: ReadonlySet<string>,
66
+ args: Readonly<Record<string, unknown>>,
67
+ ): Promise<ToolResult> {
68
+ const { absolute, root } = await confine(options, excluded, args['path']);
69
+ const query = String(args['query'] ?? '').toLowerCase();
70
+ if (!query) return error('query is required.');
71
+
72
+ const contains = args['contains'] ? String(args['contains']).toLowerCase() : null;
73
+ const limit = Math.min(Number(args['limit'] ?? 50) || 50, 200);
74
+ const matches: unknown[] = [];
75
+
76
+ const walk = async (directory: string, depth: number): Promise<void> => {
77
+ if (matches.length >= limit || depth > 8) return;
78
+
79
+ const found = await readdir(directory, { withFileTypes: true }).catch(() => []);
80
+ for (const entry of found) {
81
+ if (matches.length >= limit) return;
82
+ if (excluded.has(entry.name)) continue;
83
+ if (isPlaceholder(entry.name, options.placeholder?.suffix)) continue;
84
+
85
+ const full = join(directory, entry.name);
86
+ if (entry.isDirectory()) {
87
+ await walk(full, depth + 1);
88
+ continue;
89
+ }
90
+
91
+ if (!entry.name.toLowerCase().includes(query)) continue;
92
+
93
+ if (contains) {
94
+ const info = await stat(full).catch(() => null);
95
+ // Not worth reading a large file into memory to grep it, and a binary
96
+ // has nothing to find.
97
+ if (!info || info.size > options.maxFileBytes) continue;
98
+ const text = await readFile(full, 'utf8').catch(() => null);
99
+ if (!text || !text.toLowerCase().includes(contains)) continue;
100
+ }
101
+
102
+ matches.push({ path: relative(root, full) });
103
+ }
104
+ };
105
+
106
+ await walk(absolute, 0);
107
+
108
+ return json({ query, ...(contains ? { contains: args['contains'] } : {}), matches });
109
+ }
110
+
111
+ export async function readTextFile(
112
+ options: FsConnectorOptions,
113
+ excluded: ReadonlySet<string>,
114
+ args: Readonly<Record<string, unknown>>,
115
+ ): Promise<ToolResult> {
116
+ const { absolute, relative: shown } = await confine(options, excluded, args['path']);
117
+
118
+ const placeholder = await placeholderFor(absolute, options.placeholder?.suffix);
119
+ if (placeholder) {
120
+ return error(
121
+ `"${shown}" is not downloaded to this machine — whatever syncs this folder is ` +
122
+ `holding the contents remotely, so there is nothing to read yet.` +
123
+ (options.placeholder?.hint ? `\n${options.placeholder.hint} ${absolute}` : ''),
124
+ );
125
+ }
126
+
127
+ const info = await stat(absolute).catch(() => null);
128
+ if (!info) return error(`"${shown}" is not there.`);
129
+ if (info.isDirectory()) return error(`"${shown}" is a folder. Use list_files.`);
130
+ if (info.size > options.maxFileBytes) {
131
+ return error(
132
+ `"${shown}" is ${Math.round(info.size / 1024)}KB, over the ${Math.round(options.maxFileBytes / 1024)}KB limit for one read.`,
133
+ );
134
+ }
135
+
136
+ const bytes = await readFile(absolute);
137
+ if (looksBinary(bytes)) {
138
+ return json({
139
+ path: shown,
140
+ bytes: info.size,
141
+ binary: true,
142
+ note: 'Binary file. Its contents are not returned as text.',
143
+ });
144
+ }
145
+
146
+ return json({ path: shown, bytes: info.size, content: new TextDecoder().decode(bytes) });
147
+ }
148
+
149
+ export async function fileInfo(
150
+ options: FsConnectorOptions,
151
+ excluded: ReadonlySet<string>,
152
+ args: Readonly<Record<string, unknown>>,
153
+ ): Promise<ToolResult> {
154
+ const { absolute, relative: shown } = await confine(options, excluded, args['path']);
155
+ const info = await stat(absolute).catch(() => null);
156
+
157
+ if (!info) {
158
+ const placeholder = await placeholderFor(absolute, options.placeholder?.suffix);
159
+ if (placeholder) return json({ path: shown, downloaded: false, kind: 'file' });
160
+ return error(`"${shown}" is not there.`);
161
+ }
162
+
163
+ return json({
164
+ path: shown,
165
+ kind: info.isDirectory() ? 'folder' : 'file',
166
+ bytes: info.size,
167
+ modified: info.mtime.toISOString(),
168
+ created: info.birthtime.toISOString(),
169
+ downloaded: info.isDirectory() ? true : (await placeholderFor(absolute, options.placeholder?.suffix)) === null,
170
+ });
171
+ }
172
+
173
+ export async function writeTextFile(
174
+ options: FsConnectorOptions,
175
+ excluded: ReadonlySet<string>,
176
+ args: Readonly<Record<string, unknown>>,
177
+ ): Promise<ToolResult> {
178
+ const { absolute, relative: shown } = await confine(options, excluded, args['path']);
179
+ const content = String(args['content'] ?? '');
180
+
181
+ const existing = await stat(absolute).catch(() => null);
182
+ if (existing?.isDirectory()) return error(`"${shown}" is a folder.`);
183
+ if (existing && args['overwrite'] !== true) {
184
+ // Replacing a file is a different act from creating one, and an agent that
185
+ // meant to create should not silently destroy.
186
+ return error(`"${shown}" already exists. Pass overwrite: true to replace it.`);
187
+ }
188
+
189
+ await mkdir(dirname(absolute), { recursive: true });
190
+ await writeFile(absolute, content, 'utf8');
191
+
192
+ return json({ written: true, path: shown, bytes: Buffer.byteLength(content, 'utf8') });
193
+ }
194
+
195
+ export async function moveFile(
196
+ options: FsConnectorOptions,
197
+ excluded: ReadonlySet<string>,
198
+ args: Readonly<Record<string, unknown>>,
199
+ ): Promise<ToolResult> {
200
+ const from = await confine(options, excluded, args['from']);
201
+ const to = await confine(options, excluded, args['to']);
202
+
203
+ if (!(await stat(from.absolute).catch(() => null))) return error(`"${from.relative}" is not there.`);
204
+ if (await stat(to.absolute).catch(() => null)) return error(`"${to.relative}" already exists.`);
205
+
206
+ await mkdir(dirname(to.absolute), { recursive: true });
207
+ await rename(from.absolute, to.absolute);
208
+
209
+ return json({ moved: true, from: from.relative, to: to.relative });
210
+ }
211
+
212
+ export async function createFolder(
213
+ options: FsConnectorOptions,
214
+ excluded: ReadonlySet<string>,
215
+ args: Readonly<Record<string, unknown>>,
216
+ ): Promise<ToolResult> {
217
+ const { absolute, relative: shown } = await confine(options, excluded, args['path']);
218
+ await mkdir(absolute, { recursive: true });
219
+ return json({ created: true, path: shown });
220
+ }
221
+
222
+ /**
223
+ * Move to the system Trash rather than unlinking.
224
+ *
225
+ * Same reasoning as mail: an agent that can permanently destroy a file is a
226
+ * different risk class from one that can tidy up, and the Finder's Trash is a
227
+ * recovery path everyone already knows. If the Trash is on another volume the
228
+ * rename fails, and refusing beats falling back to a real delete.
229
+ */
230
+ export async function trashFile(
231
+ options: FsConnectorOptions,
232
+ excluded: ReadonlySet<string>,
233
+ args: Readonly<Record<string, unknown>>,
234
+ ): Promise<ToolResult> {
235
+ const { absolute, relative: shown } = await confine(options, excluded, args['path']);
236
+ if (!(await stat(absolute).catch(() => null))) return error(`"${shown}" is not there.`);
237
+
238
+ const trash = join(homedir(), '.Trash');
239
+ let target = join(trash, basename(absolute));
240
+ for (let attempt = 1; await stat(target).catch(() => null); attempt++) {
241
+ target = join(trash, `${basename(absolute)} ${attempt}`);
242
+ }
243
+
244
+ try {
245
+ await rename(absolute, target);
246
+ } catch {
247
+ return error(
248
+ `"${shown}" could not be moved to the Trash, and nothing here deletes permanently. Move it somewhere else instead.`,
249
+ );
250
+ }
251
+
252
+ return json({ trashed: true, path: shown, recoverable_from: '~/.Trash' });
253
+ }
254
+
255
+ /** A NUL byte in the first block is the practical test for "not text". */
256
+ export function looksBinary(bytes: Uint8Array): boolean {
257
+ return bytes.subarray(0, 4096).includes(0);
258
+ }
@@ -0,0 +1,121 @@
1
+ import { mkdir, readdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import {
5
+ READ_BUNDLE,
6
+ WRITE_BUNDLE,
7
+ type Connector,
8
+ type DiscoveredCapability,
9
+ type ToolResult,
10
+ } from '#connectivity';
11
+
12
+ /**
13
+ * The `fs` connector — a directory on this machine.
14
+ *
15
+ * Vendor-neutral, like the rest: it is a folder. iCloud Drive is a manifest
16
+ * pointing at `~/Library/Mobile Documents/com~apple~CloudDocs`, and the same
17
+ * connector serves a Dropbox folder, a Syncthing share, or a project directory.
18
+ *
19
+ * **It only works where the files are.** There is no credential here and nothing
20
+ * to export: access to iCloud Drive is a TCC grant bound to a binary on one Mac,
21
+ * not a token. That is why a cloud instance cannot serve this kind and must
22
+ * instead reach a local one — see `docs/detailed/adr/011-local-filesystem.md`.
23
+ *
24
+ * ## The guard that matters
25
+ *
26
+ * Everything else in this file is ordinary. `confine()` is not: it is the only
27
+ * thing standing between an agent and the rest of the disk, and on a Mac with
28
+ * "Desktop & Documents" syncing, the configured root contains almost everything
29
+ * a person owns. It resolves the *real* path — following symlinks — before
30
+ * comparing, because a symlink inside the root pointing at `~/.ssh` is otherwise
31
+ * a perfectly ordinary-looking file.
32
+ */
33
+
34
+ export interface FsConnectorOptions {
35
+ readonly root: string;
36
+ readonly maxFileBytes: number;
37
+ /** Names never listed, read, or written. Matched against each path segment. */
38
+ readonly exclude: readonly string[];
39
+ /**
40
+ * How this folder's sync client marks an undownloaded file, if it uses one,
41
+ * and what to tell someone who hits it. Declared by the provider — this
42
+ * transport must not know which client it is.
43
+ */
44
+ readonly placeholder?: { readonly suffix: string; readonly hint?: string | undefined } | undefined;
45
+ }
46
+
47
+
48
+ import { fsCapabilities } from './capabilities.ts';
49
+ import { ALWAYS_EXCLUDED, OPERATIONS } from './operations.ts';
50
+ import { rootOf } from './paths.ts';
51
+ import { error } from './result.ts';
52
+ import {
53
+ createFolder,
54
+ fileInfo,
55
+ listFiles,
56
+ moveFile,
57
+ readTextFile,
58
+ searchFiles,
59
+ trashFile,
60
+ writeTextFile,
61
+ } from './commands.ts';
62
+
63
+ export function createFsConnector(options: FsConnectorOptions): Connector {
64
+ const excluded = new Set([...ALWAYS_EXCLUDED, ...options.exclude]);
65
+
66
+ return {
67
+ kind: 'fs',
68
+
69
+ async discover(): Promise<DiscoveredCapability[]> {
70
+ // The root has to exist and be readable. On a machine that is not the one
71
+ // holding the files this fails here, at `connect`, rather than as a
72
+ // puzzling empty listing later.
73
+ await rootOf(options);
74
+
75
+ return fsCapabilities();
76
+ },
77
+
78
+ /**
79
+ * Which folder this is.
80
+ *
81
+ * There is no account here, so the identity is the location — which is also
82
+ * the only thing that distinguishes two folder connections from each other.
83
+ * Abbreviated to `~` because the absolute form is long and says less.
84
+ */
85
+ async identify(): Promise<string | null> {
86
+ const resolved = await rootOf(options);
87
+ const home = homedir();
88
+ return resolved.startsWith(home + sep) ? `~${resolved.slice(home.length)}` : resolved;
89
+ },
90
+
91
+ async invoke(capability, args): Promise<ToolResult> {
92
+ const operation = String(capability.target?.['operation'] ?? capability.name);
93
+
94
+ try {
95
+ switch (operation) {
96
+ case OPERATIONS.listFiles:
97
+ return await listFiles(options, excluded, args);
98
+ case OPERATIONS.searchFiles:
99
+ return await searchFiles(options, excluded, args);
100
+ case OPERATIONS.readFile:
101
+ return await readTextFile(options, excluded, args);
102
+ case OPERATIONS.fileInfo:
103
+ return await fileInfo(options, excluded, args);
104
+ case OPERATIONS.writeFile:
105
+ return await writeTextFile(options, excluded, args);
106
+ case OPERATIONS.moveFile:
107
+ return await moveFile(options, excluded, args);
108
+ case OPERATIONS.createFolder:
109
+ return await createFolder(options, excluded, args);
110
+ case OPERATIONS.trashFile:
111
+ return await trashFile(options, excluded, args);
112
+ default:
113
+ return error(`Unknown operation "${operation}".`);
114
+ }
115
+ } catch (failure) {
116
+ return error((failure as Error).message);
117
+ }
118
+ },
119
+ };
120
+ }
121
+
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The operation names an `fs` connection exposes, plus the exclusions and
3
+ * argument shapes they share.
4
+ *
5
+ * `ALWAYS_EXCLUDED` is a security control rather than a tidiness one: the
6
+ * configured root on a Mac with Desktop & Documents syncing contains almost
7
+ * everything a person owns, and these are the names that must never be listed
8
+ * or read whatever the manifest says.
9
+ */
10
+
11
+ export const OPERATIONS = {
12
+ listFiles: 'list_files',
13
+ readFile: 'read_file',
14
+ searchFiles: 'search_files',
15
+ fileInfo: 'file_info',
16
+ writeFile: 'write_file',
17
+ moveFile: 'move_file',
18
+ createFolder: 'create_folder',
19
+ trashFile: 'trash_file',
20
+ } as const;
21
+
22
+ /**
23
+ * Never traversed, whatever the manifest says.
24
+ *
25
+ * `.git` is here because a repository in a synced folder holds credentials in
26
+ * its config and the entire history of everything else; the rest are metadata
27
+ * nobody means to read.
28
+ */
29
+ export const ALWAYS_EXCLUDED = ['.git', '.ssh', '.gnupg', 'node_modules', '.DS_Store', '.Trash'];
30
+
31
+ export const object = (
32
+ properties: Record<string, unknown>,
33
+ required: string[] = [],
34
+ ): Record<string, unknown> => ({
35
+ type: 'object',
36
+ properties,
37
+ ...(required.length > 0 ? { required } : {}),
38
+ additionalProperties: false,
39
+ });
40
+
41
+ export const pathArgument = {
42
+ type: 'string',
43
+ description: 'Path relative to the folder root. "" or "." is the root itself.',
44
+ };
45
+
@@ -0,0 +1,120 @@
1
+ import { readdir, realpath, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, relative, resolve, sep, basename, dirname } from 'node:path';
4
+ import { ALWAYS_EXCLUDED } from './operations.ts';
5
+ import type { FsConnectorOptions } from './index.ts';
6
+
7
+ /**
8
+ * Resolving a path, and refusing the ones that leave the root.
9
+ *
10
+ * `confine()` is the only thing standing between an agent and the rest of the
11
+ * disk. It resolves the *real* path — following symlinks — before comparing,
12
+ * because a symlink inside the root pointing at `~/.ssh` is otherwise a
13
+ * perfectly ordinary-looking file. Its own file so that the guard is one thing
14
+ * to read and one thing to review, rather than forty lines in the middle of ten
15
+ * operations.
16
+ */
17
+
18
+ export async function rootOf(options: FsConnectorOptions): Promise<string> {
19
+ const expanded = options.root.startsWith('~')
20
+ ? join(homedir(), options.root.slice(1))
21
+ : options.root;
22
+
23
+ try {
24
+ return await realpath(resolve(expanded));
25
+ } catch {
26
+ throw new Error(
27
+ `The folder ${expanded} is not there. This connector reads files on *this* machine, so it only works where they are.`,
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Resolve a caller-supplied path, and refuse anything outside the root.
34
+ *
35
+ * The real path is resolved first, so a symlink inside the root pointing at
36
+ * `~/.ssh` is caught rather than followed. A path that does not exist yet is
37
+ * checked against its nearest existing ancestor — a write must be confined too,
38
+ * and `realpath` cannot resolve a file that is not there.
39
+ */
40
+ export async function confine(
41
+ options: FsConnectorOptions,
42
+ excluded: ReadonlySet<string>,
43
+ candidate: unknown,
44
+ ): Promise<{ absolute: string; relative: string; root: string }> {
45
+ const root = await rootOf(options);
46
+ const requested = typeof candidate === 'string' ? candidate : '';
47
+
48
+ if (requested.startsWith('~') || resolve(requested) === requested) {
49
+ // An absolute path or a home-relative one is never what a caller inside a
50
+ // rooted folder means, and accepting it would make the root advisory.
51
+ if (requested !== '') {
52
+ throw new Error(`Give a path relative to the folder root, not "${requested}".`);
53
+ }
54
+ }
55
+
56
+ const absolute = resolve(root, requested);
57
+
58
+ for (const segment of relative(root, absolute).split(sep)) {
59
+ if (segment && excluded.has(segment)) {
60
+ throw new Error(`"${segment}" is excluded from this folder.`);
61
+ }
62
+ }
63
+
64
+ // Walk up to the nearest path that exists, so a not-yet-created file is still
65
+ // confined by where it *would* go.
66
+ let existing = absolute;
67
+ for (;;) {
68
+ try {
69
+ existing = await realpath(existing);
70
+ break;
71
+ } catch {
72
+ const parent = dirname(existing);
73
+ if (parent === existing) throw new Error(`Cannot resolve "${requested}".`);
74
+ existing = parent;
75
+ }
76
+ }
77
+
78
+ const within = existing === root || existing.startsWith(root + sep);
79
+ if (!within) {
80
+ throw new Error(
81
+ `"${requested}" resolves outside the folder root, so it was refused. ` +
82
+ `This usually means a symlink pointing elsewhere.`,
83
+ );
84
+ }
85
+
86
+ return { absolute, relative: relative(root, absolute) || '.', root };
87
+ }
88
+
89
+ /**
90
+ * Whether the sync client has evicted the file's contents.
91
+ *
92
+ * With "Optimise Mac Storage" on, a file that has not been opened recently is
93
+ * replaced by a hidden `.name<suffix>` placeholder holding a plist, and the real
94
+ * bytes live only in the cloud. Reading the placeholder returns a few hundred
95
+ * bytes of XML — which looks like a corrupt file rather than a missing one, so
96
+ * it is worth naming.
97
+ */
98
+ export async function placeholderFor(
99
+ absolute: string,
100
+ suffix: string | undefined,
101
+ ): Promise<string | null> {
102
+ if (!suffix) return null;
103
+ const candidate = join(dirname(absolute), `.${basename(absolute)}${suffix}`);
104
+ try {
105
+ await stat(candidate);
106
+ return candidate;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Whether a directory entry is a sync client's placeholder rather than a file.
114
+ *
115
+ * The suffix is declared by the provider (`placeholder_suffix`), so this
116
+ * transport never learns which sync client it is looking at.
117
+ */
118
+ export function isPlaceholder(name: string, suffix: string | undefined): boolean {
119
+ return suffix !== undefined && name.startsWith('.') && name.endsWith(suffix);
120
+ }
@@ -0,0 +1,12 @@
1
+ import type { ToolResult } from '#connectivity';
2
+
3
+ /** The two result shapes every operation returns. */
4
+
5
+ export function error(message: string): ToolResult {
6
+ return { content: [{ type: 'text', text: message }], isError: true };
7
+ }
8
+
9
+ export function json(value: unknown): ToolResult {
10
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
11
+ }
12
+