@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,412 @@
1
+ /**
2
+ * iCalendar (RFC 5545) and vCard (RFC 6350), to the depth this needs.
3
+ *
4
+ * Small on purpose. The genuinely hard part of RFC 5545 is recurrence — RRULE
5
+ * with EXDATE, RECURRENCE-ID and VTIMEZONE — and **CalDAV lets the server do
6
+ * it**: a `calendar-query` carrying `<C:expand>` returns instances already
7
+ * expanded. What is left is mechanical and precisely specified: unfold
8
+ * continuation lines, split `NAME;PARAM=value:VALUE`, unescape, and understand
9
+ * three forms of date.
10
+ *
11
+ * That is why there is no `ical.js` here. It is 200 KB whose centrepiece is the
12
+ * recurrence engine the protocol just did for us, under MPL-2.0 — a licence
13
+ * nothing else in this tree carries.
14
+ *
15
+ * Where a server ignores `<C:expand>`, the caller reports `expanded: false` and
16
+ * hands back the RRULE verbatim. Degrading honestly beats expanding wrongly.
17
+ */
18
+
19
+ export interface ContentLine {
20
+ readonly name: string;
21
+ readonly params: Readonly<Record<string, string>>;
22
+ readonly value: string;
23
+ }
24
+
25
+ /**
26
+ * Unfold and split a whole document into lines.
27
+ *
28
+ * Unfolding first, and it must be first: a line beginning with a space or tab is
29
+ * a continuation of the previous one, and a long `SUMMARY` is routinely folded
30
+ * mid-word at 75 octets.
31
+ */
32
+ export function parseLines(text: string): ContentLine[] {
33
+ const unfolded = text.replace(/\r?\n[ \t]/g, '');
34
+
35
+ return unfolded
36
+ .split(/\r?\n/)
37
+ .filter((line) => line.trim() !== '')
38
+ .map(parseLine)
39
+ .filter((line): line is ContentLine => line !== null);
40
+ }
41
+
42
+ function parseLine(line: string): ContentLine | null {
43
+ // The value starts after the first colon that is not inside a quoted
44
+ // parameter — `ATTENDEE;CN="Smith, J:r":mailto:j@x` is legal and rare enough
45
+ // that a naive split on the first colon truncates it silently.
46
+ let inQuotes = false;
47
+ let colon = -1;
48
+
49
+ for (let index = 0; index < line.length; index++) {
50
+ const character = line[index];
51
+ if (character === '"') inQuotes = !inQuotes;
52
+ else if (character === ':' && !inQuotes) {
53
+ colon = index;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (colon === -1) return null;
59
+
60
+ const head = line.slice(0, colon);
61
+ const value = unescapeValue(line.slice(colon + 1));
62
+
63
+ const [name, ...rest] = head.split(';');
64
+ const params: Record<string, string> = {};
65
+
66
+ for (const parameter of rest) {
67
+ const equals = parameter.indexOf('=');
68
+ if (equals === -1) continue;
69
+ params[parameter.slice(0, equals).toUpperCase()] = parameter
70
+ .slice(equals + 1)
71
+ .replace(/^"|"$/g, '');
72
+ }
73
+
74
+ return { name: (name ?? '').toUpperCase(), params, value };
75
+ }
76
+
77
+ function unescapeValue(value: string): string {
78
+ return value
79
+ .replace(/\\n/gi, '\n')
80
+ .replace(/\\,/g, ',')
81
+ .replace(/\\;/g, ';')
82
+ .replace(/\\\\/g, '\\');
83
+ }
84
+
85
+ function escapeValue(value: string): string {
86
+ return value
87
+ .replace(/\\/g, '\\\\')
88
+ .replace(/;/g, '\\;')
89
+ .replace(/,/g, '\\,')
90
+ .replace(/\n/g, '\\n');
91
+ }
92
+
93
+ export interface CalendarEvent {
94
+ readonly uid: string | null;
95
+ readonly summary: string | null;
96
+ readonly start: string | null;
97
+ readonly end: string | null;
98
+ readonly all_day: boolean;
99
+ readonly location: string | null;
100
+ readonly description: string | null;
101
+ readonly organizer: string | null;
102
+ readonly attendees: readonly string[];
103
+ readonly status: string | null;
104
+ /** Present only when the server declined to expand a repeating event. */
105
+ readonly recurrence_rule?: string;
106
+ }
107
+
108
+ /** The VEVENTs in one iCalendar document. */
109
+ export function parseEvents(text: string): CalendarEvent[] {
110
+ const events: CalendarEvent[] = [];
111
+ let current: ContentLine[] | null = null;
112
+
113
+ for (const line of parseLines(text)) {
114
+ if (line.name === 'BEGIN' && line.value === 'VEVENT') {
115
+ current = [];
116
+ continue;
117
+ }
118
+ if (line.name === 'END' && line.value === 'VEVENT') {
119
+ if (current) events.push(toEvent(current));
120
+ current = null;
121
+ continue;
122
+ }
123
+ current?.push(line);
124
+ }
125
+
126
+ return events;
127
+ }
128
+
129
+ function toEvent(lines: readonly ContentLine[]): CalendarEvent {
130
+ const first = (name: string): ContentLine | undefined => lines.find((l) => l.name === name);
131
+ const start = first('DTSTART');
132
+ const rrule = first('RRULE');
133
+
134
+ return {
135
+ uid: first('UID')?.value ?? null,
136
+ summary: first('SUMMARY')?.value ?? null,
137
+ start: start ? normaliseDate(start) : null,
138
+ end: (() => {
139
+ const end = first('DTEND');
140
+ return end ? normaliseDate(end) : null;
141
+ })(),
142
+ // `VALUE=DATE` is the marker for an all-day event; the absence of a time is
143
+ // not incidental formatting.
144
+ all_day: start?.params['VALUE'] === 'DATE',
145
+ location: first('LOCATION')?.value ?? null,
146
+ description: first('DESCRIPTION')?.value ?? null,
147
+ organizer: first('ORGANIZER')?.value.replace(/^mailto:/i, '') ?? null,
148
+ attendees: lines
149
+ .filter((line) => line.name === 'ATTENDEE')
150
+ .map((line) => line.value.replace(/^mailto:/i, '')),
151
+ status: first('STATUS')?.value ?? null,
152
+ ...(rrule ? { recurrence_rule: rrule.value } : {}),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * The three forms a DTSTART takes, as ISO 8601.
158
+ *
159
+ * `VALUE=DATE:20260810`, `TZID=Europe/London:20260810T090000`, and
160
+ * `20260810T080000Z`. The middle one keeps its zone as a suffix rather than
161
+ * being converted: converting needs a tz database, and guessing is worse than
162
+ * saying which zone the server meant.
163
+ */
164
+ export function normaliseDate(line: ContentLine): string {
165
+ const value = line.value.trim();
166
+ const match = value.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
167
+ if (!match) return value;
168
+
169
+ const [, year, month, day, hour, minute, second, zulu] = match;
170
+ if (!hour) return `${year}-${month}-${day}`;
171
+
172
+ const stamp = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
173
+ if (zulu) return `${stamp}Z`;
174
+
175
+ const zone = line.params['TZID'];
176
+ return zone ? `${stamp} (${zone})` : stamp;
177
+ }
178
+
179
+ /** ISO 8601 in, iCalendar's compact form out. */
180
+ function toIcalDate(value: string, allDay: boolean): string {
181
+ const digits = value.replace(/[-:]/g, '');
182
+ if (allDay) return digits.slice(0, 8);
183
+ if (digits.endsWith('Z')) return digits.replace(/\.\d+Z$/, 'Z').slice(0, 16) + 'Z';
184
+ return `${digits.slice(0, 15)}`;
185
+ }
186
+
187
+ export interface NewEvent {
188
+ readonly uid: string;
189
+ readonly summary: string;
190
+ readonly start: string;
191
+ readonly end: string;
192
+ readonly allDay?: boolean | undefined;
193
+ readonly location?: string | undefined;
194
+ readonly description?: string | undefined;
195
+ readonly timestamp: string;
196
+ }
197
+
198
+ /** Build a minimal, valid VCALENDAR carrying one VEVENT. */
199
+ export function buildEvent(event: NewEvent): string {
200
+ const allDay = event.allDay === true;
201
+ const dateParam = allDay ? ';VALUE=DATE' : '';
202
+
203
+ const lines = [
204
+ 'BEGIN:VCALENDAR',
205
+ 'VERSION:2.0',
206
+ 'PRODID:-//Lanes Link//EN',
207
+ 'BEGIN:VEVENT',
208
+ `UID:${event.uid}`,
209
+ `DTSTAMP:${toIcalDate(event.timestamp, false)}`,
210
+ `DTSTART${dateParam}:${toIcalDate(event.start, allDay)}`,
211
+ `DTEND${dateParam}:${toIcalDate(event.end, allDay)}`,
212
+ `SUMMARY:${escapeValue(event.summary)}`,
213
+ ...(event.location ? [`LOCATION:${escapeValue(event.location)}`] : []),
214
+ ...(event.description ? [`DESCRIPTION:${escapeValue(event.description)}`] : []),
215
+ 'END:VEVENT',
216
+ 'END:VCALENDAR',
217
+ ];
218
+
219
+ // CRLF, which RFC 5545 requires and some servers enforce.
220
+ return `${lines.map(fold).join('\r\n')}\r\n`;
221
+ }
222
+
223
+ /** Fold at 75 octets, as the spec requires for long values. */
224
+ function fold(line: string): string {
225
+ if (line.length <= 75) return line;
226
+ const parts = [line.slice(0, 75)];
227
+ let rest = line.slice(75);
228
+ while (rest.length > 74) {
229
+ parts.push(` ${rest.slice(0, 74)}`);
230
+ rest = rest.slice(74);
231
+ }
232
+ parts.push(` ${rest}`);
233
+ return parts.join('\r\n');
234
+ }
235
+
236
+ export interface EventPatch {
237
+ readonly summary?: string | undefined;
238
+ readonly start?: string | undefined;
239
+ readonly end?: string | undefined;
240
+ readonly allDay?: boolean | undefined;
241
+ readonly location?: string | undefined;
242
+ readonly description?: string | undefined;
243
+ readonly timestamp: string;
244
+ }
245
+
246
+ /**
247
+ * Edit an existing VEVENT in place, by patching its text.
248
+ *
249
+ * Rebuilding the event from the fields we model would silently discard
250
+ * everything we do not — attendees and their reply status, alarms, the RRULE, a
251
+ * conferencing URL, `X-` properties another client depends on. Changing the time
252
+ * of a meeting must not un-invite everyone, so the untouched lines are carried
253
+ * through verbatim and only the named properties are replaced.
254
+ *
255
+ * `SEQUENCE` is bumped and `DTSTAMP` refreshed because that is how every other
256
+ * calendar client learns something changed; without it the edit lands on the
257
+ * server and no device notices.
258
+ */
259
+ export function patchEvent(ical: string, patch: EventPatch): string {
260
+ const lines = ical.replace(/\r?\n[ \t]/g, '').split(/\r?\n/);
261
+
262
+ const start = lines.findIndex((line) => line.trim() === 'BEGIN:VEVENT');
263
+ const end = lines.findIndex((line) => line.trim() === 'END:VEVENT');
264
+ if (start === -1 || end === -1) throw new Error('That entry does not contain an event to edit.');
265
+
266
+ const nameOf = (line: string): string => (line.split(/[;:]/)[0] ?? '').toUpperCase();
267
+ const body = lines.slice(start + 1, end);
268
+
269
+ const allDay =
270
+ patch.allDay ?? body.some((line) => nameOf(line) === 'DTSTART' && /VALUE=DATE[:;]/.test(line));
271
+ const dateParam = allDay ? ';VALUE=DATE' : '';
272
+
273
+ const replacements = new Map<string, string | null>();
274
+ if (patch.summary !== undefined) replacements.set('SUMMARY', `SUMMARY:${escapeValue(patch.summary)}`);
275
+ if (patch.location !== undefined) {
276
+ replacements.set('LOCATION', patch.location ? `LOCATION:${escapeValue(patch.location)}` : null);
277
+ }
278
+ if (patch.description !== undefined) {
279
+ replacements.set(
280
+ 'DESCRIPTION',
281
+ patch.description ? `DESCRIPTION:${escapeValue(patch.description)}` : null,
282
+ );
283
+ }
284
+ // Replacing the whole line drops a stale `TZID` along with it, which matters:
285
+ // keeping the old zone against a new UTC time silently moves the meeting.
286
+ if (patch.start !== undefined) {
287
+ replacements.set('DTSTART', `DTSTART${dateParam}:${toIcalDate(patch.start, allDay)}`);
288
+ }
289
+ if (patch.end !== undefined) {
290
+ replacements.set('DTEND', `DTEND${dateParam}:${toIcalDate(patch.end, allDay)}`);
291
+ }
292
+
293
+ replacements.set('DTSTAMP', `DTSTAMP:${toIcalDate(patch.timestamp, false)}`);
294
+
295
+ const current = body.find((line) => nameOf(line) === 'SEQUENCE');
296
+ const sequence = current ? Number(current.split(':')[1] ?? 0) || 0 : 0;
297
+ replacements.set('SEQUENCE', `SEQUENCE:${sequence + 1}`);
298
+
299
+ const seen = new Set<string>();
300
+ const patched: string[] = [];
301
+
302
+ for (const line of body) {
303
+ const name = nameOf(line);
304
+ if (!replacements.has(name)) {
305
+ patched.push(line);
306
+ continue;
307
+ }
308
+ seen.add(name);
309
+ const replacement = replacements.get(name);
310
+ if (replacement !== null && replacement !== undefined) patched.push(replacement);
311
+ }
312
+
313
+ // A property being set for the first time has nothing to replace.
314
+ for (const [name, replacement] of replacements) {
315
+ if (!seen.has(name) && replacement) patched.push(replacement);
316
+ }
317
+
318
+ const rebuilt = [...lines.slice(0, start + 1), ...patched, ...lines.slice(end)];
319
+ return `${rebuilt.filter((line) => line !== '').map(fold).join('\r\n')}\r\n`;
320
+ }
321
+
322
+ export interface NewContact {
323
+ readonly uid: string;
324
+ readonly fullName: string;
325
+ readonly emails?: readonly string[] | undefined;
326
+ readonly phones?: readonly string[] | undefined;
327
+ readonly organization?: string | undefined;
328
+ readonly note?: string | undefined;
329
+ }
330
+
331
+ /**
332
+ * Build a vCard 3.0.
333
+ *
334
+ * 3.0 rather than 4.0 because it is what Apple writes and what every client
335
+ * reads; 4.0 support is uneven enough that the safe choice is the older one.
336
+ */
337
+ export function buildContact(contact: NewContact): string {
338
+ // `N` is structured as family;given;middle;prefix;suffix. Splitting a display
339
+ // name on the last space is a guess, but an absent `N` breaks sorting in
340
+ // Contacts, and a wrong guess is visible and fixable where a missing field is
341
+ // neither.
342
+ const parts = contact.fullName.trim().split(/\s+/);
343
+ const family = parts.length > 1 ? parts[parts.length - 1]! : '';
344
+ const given = parts.length > 1 ? parts.slice(0, -1).join(' ') : contact.fullName;
345
+
346
+ const lines = [
347
+ 'BEGIN:VCARD',
348
+ 'VERSION:3.0',
349
+ `UID:${contact.uid}`,
350
+ `FN:${escapeValue(contact.fullName)}`,
351
+ `N:${escapeValue(family)};${escapeValue(given)};;;`,
352
+ ...(contact.emails ?? []).map((email) => `EMAIL;TYPE=INTERNET:${escapeValue(email)}`),
353
+ ...(contact.phones ?? []).map((phone) => `TEL;TYPE=CELL:${escapeValue(phone)}`),
354
+ ...(contact.organization ? [`ORG:${escapeValue(contact.organization)}`] : []),
355
+ ...(contact.note ? [`NOTE:${escapeValue(contact.note)}`] : []),
356
+ 'END:VCARD',
357
+ ];
358
+
359
+ return `${lines.map(fold).join('\r\n')}\r\n`;
360
+ }
361
+
362
+ export interface Contact {
363
+ readonly uid: string | null;
364
+ readonly full_name: string | null;
365
+ readonly emails: readonly string[];
366
+ readonly phones: readonly string[];
367
+ readonly organization: string | null;
368
+ readonly note: string | null;
369
+ }
370
+
371
+ /** The VCARDs in one document. vCard 3.0 and 4.0 share this grammar. */
372
+ export function parseContacts(text: string): Contact[] {
373
+ const contacts: Contact[] = [];
374
+ let current: ContentLine[] | null = null;
375
+
376
+ for (const line of parseLines(text)) {
377
+ if (line.name === 'BEGIN' && line.value === 'VCARD') {
378
+ current = [];
379
+ continue;
380
+ }
381
+ if (line.name === 'END' && line.value === 'VCARD') {
382
+ if (current) contacts.push(toContact(current));
383
+ current = null;
384
+ continue;
385
+ }
386
+ current?.push(line);
387
+ }
388
+
389
+ return contacts;
390
+ }
391
+
392
+ function toContact(lines: readonly ContentLine[]): Contact {
393
+ const first = (name: string): string | null =>
394
+ lines.find((line) => line.name === name)?.value ?? null;
395
+
396
+ return {
397
+ uid: first('UID'),
398
+ // FN is the display name; N is the structured one and is not always present.
399
+ full_name: first('FN') ?? first('N')?.split(';').filter(Boolean).reverse().join(' ') ?? null,
400
+ emails: lines.filter((line) => line.name === 'EMAIL').map((line) => line.value),
401
+ phones: lines.filter((line) => line.name === 'TEL').map((line) => line.value),
402
+ organization: first('ORG')?.split(';')[0] ?? null,
403
+ note: first('NOTE'),
404
+ };
405
+ }
406
+
407
+ export /** iCalendar's compact UTC stamp, which time-range filters require. */
408
+ function compact(value: string): string {
409
+ const date = new Date(value);
410
+ if (Number.isNaN(date.getTime())) return value;
411
+ return `${date.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`;
412
+ }
@@ -0,0 +1,143 @@
1
+ import type { Connector, ToolResult } from '#connectivity';
2
+ import { davCapabilities } from './capabilities.ts';
3
+ import { createContact, searchContacts } from './contacts.ts';
4
+ import {
5
+ createEvent,
6
+ deleteEvent,
7
+ getEvent,
8
+ listEvents,
9
+ updateEvent,
10
+ } from './calendar.ts';
11
+ import { DavClient } from './client.ts';
12
+ import { OPERATIONS } from './operations.ts';
13
+ import { error, validate } from './request.ts';
14
+
15
+ /**
16
+ * The `dav` connector — CalDAV and CardDAV.
17
+ *
18
+ * One kind for both: they share the discovery dance and the `REPORT` machinery
19
+ * and differ only in a namespace and a payload format. Vendor-neutral — iCloud,
20
+ * Fastmail, Nextcloud and a Radicale on a Raspberry Pi are the same two manifest
21
+ * fields.
22
+ *
23
+ * It is HTTPS, so `context.authorize` attaches Basic auth and this file never
24
+ * sees a credential. That is the payoff for keeping auth orthogonal to
25
+ * connectivity: a whole new protocol cost nothing on the credential side.
26
+ *
27
+ * **Where the account-specific routing lives.** Discovery resolves a *per
28
+ * account* home URL — iCloud answers on a numbered partition host like
29
+ * `p42-caldav.icloud.com` — and that must not go into
30
+ * `DiscoveredCapability.target`, because the discovery cache is keyed by
31
+ * provider rather than by connection. Two Apple accounts would share one entry,
32
+ * and the second would be pointed at the first one's calendars. It goes in
33
+ * per-connection state instead, which is already namespaced
34
+ * `<provider>/<connection>` and lives in the state store, so a cold instance still serves
35
+ * without re-discovering.
36
+ *
37
+ * The file itself is the wiring and nothing more: what is exposed is
38
+ * `capabilities.ts`, how a request is made is `request.ts`, the session is
39
+ * `client.ts`, and the operations are `calendar.ts` and `contacts.ts`.
40
+ */
41
+
42
+ export interface DavConnectorOptions {
43
+ readonly baseUrl: string;
44
+ readonly service: 'caldav' | 'carddav';
45
+ /**
46
+ * Longest `list_events` window this server will answer, declared by the
47
+ * provider. A server limit is the vendor's fact, not this transport's.
48
+ */
49
+ readonly maxRangeDays?: number | undefined;
50
+ /**
51
+ * One line to append when the server refuses the credential, in the
52
+ * provider's own words. A transport can say "refused"; only the provider
53
+ * knows why that usually happens for its vendor.
54
+ */
55
+ readonly troubleshooting?: string | undefined;
56
+ /**
57
+ * Used only to prove the credential works at `connect` time and to report
58
+ * whose account it is. Every *operation* goes through `context.authorize`, so
59
+ * this connector still has no idea where credentials are kept.
60
+ */
61
+ readonly credential?: (() => Promise<{ username: string; password: string }>) | undefined;
62
+ readonly fetch?: typeof globalThis.fetch | undefined;
63
+ }
64
+
65
+ export interface Collection {
66
+ readonly name: string;
67
+ readonly href: string;
68
+ }
69
+
70
+ export function createDavConnector(options: DavConnectorOptions): Connector {
71
+ const doFetch = options.fetch ?? globalThis.fetch;
72
+
73
+ return {
74
+ kind: 'dav',
75
+
76
+ async discover() {
77
+ const capabilities = davCapabilities(options.service);
78
+
79
+ // Prove the credential before declaring the connection good. Without this
80
+ // a wrong app-specific password surfaces as a failed tool call days later
81
+ // rather than as a failed `connect` now.
82
+ await validate(options, doFetch);
83
+
84
+ return capabilities;
85
+ },
86
+
87
+ /**
88
+ * Whose account this is: the name the *server accepted*.
89
+ *
90
+ * Same answer as `imap` gives, for the same reason — there is no endpoint
91
+ * returning an email address, and the username is the identity. Returning it
92
+ * only after a successful PROPFIND is what makes it a claim rather than an
93
+ * echo of what was typed.
94
+ */
95
+ async identify(): Promise<string | null> {
96
+ return validate(options, doFetch);
97
+ },
98
+
99
+ async invoke(capability, args, context): Promise<ToolResult> {
100
+ const operation = String(capability.target?.['operation'] ?? capability.name);
101
+ const dav = new DavClient(options, doFetch, context);
102
+
103
+ try {
104
+ switch (operation) {
105
+ case OPERATIONS.listCalendars:
106
+ case OPERATIONS.listAddressbooks:
107
+ return {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: JSON.stringify(
112
+ options.service === 'caldav'
113
+ ? { calendars: await dav.collections() }
114
+ : { addressbooks: await dav.collections() },
115
+ null,
116
+ 2,
117
+ ),
118
+ },
119
+ ],
120
+ };
121
+ case OPERATIONS.listEvents:
122
+ return await listEvents(dav, args);
123
+ case OPERATIONS.getEvent:
124
+ return await getEvent(dav, args);
125
+ case OPERATIONS.createEvent:
126
+ return await createEvent(dav, args);
127
+ case OPERATIONS.updateEvent:
128
+ return await updateEvent(dav, args);
129
+ case OPERATIONS.deleteEvent:
130
+ return await deleteEvent(dav, args);
131
+ case OPERATIONS.searchContacts:
132
+ return await searchContacts(dav, args);
133
+ case OPERATIONS.createContact:
134
+ return await createContact(dav, args);
135
+ default:
136
+ return error(`Unknown operation "${operation}".`);
137
+ }
138
+ } catch (failure) {
139
+ return error((failure as Error).message);
140
+ }
141
+ },
142
+ };
143
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The operation names a DAV connection exposes, and the two constants that
3
+ * shape them.
4
+ *
5
+ * Their own file because `capabilities.ts` declares them and `index.ts`
6
+ * dispatches on them: a name spelled differently in the two places is a tool
7
+ * that lists and cannot be called, which is exactly the failure a shared
8
+ * constant prevents.
9
+ */
10
+
11
+ export const OPERATIONS = {
12
+ listCalendars: 'list_calendars',
13
+ listEvents: 'list_events',
14
+ getEvent: 'get_event',
15
+ createEvent: 'create_event',
16
+ updateEvent: 'update_event',
17
+ deleteEvent: 'delete_event',
18
+ listAddressbooks: 'list_addressbooks',
19
+ searchContacts: 'search_contacts',
20
+ createContact: 'create_contact',
21
+ } as const;
22
+
23
+ /** Where the resolved, account-specific collection home is remembered. */
24
+ export const HOME_KEY = 'dav:home';
25
+
26
+ /**
27
+ * Default `list_events` window, when a provider declares none.
28
+ *
29
+ * Every DAV server slows down over a year of expanded recurrences, so there is
30
+ * a bound rather than none. Which bound is a *server* fact, so a provider that
31
+ * knows its own says so — see `max_range_days` on the connector schema.
32
+ */
33
+ export const DEFAULT_MAX_RANGE_DAYS = 366;
34
+
35
+ export const object = (
36
+ properties: Record<string, unknown>,
37
+ required: string[] = [],
38
+ ): Record<string, unknown> => ({
39
+ type: 'object',
40
+ properties,
41
+ ...(required.length > 0 ? { required } : {}),
42
+ additionalProperties: false,
43
+ });