@lanes-sh/link 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/instructions/agents/lanes-link-scout.md +14 -3
- package/instructions/skills/lanes-link/SKILL.md +80 -3
- package/package.json +2 -2
- package/src/cli/argv.ts +7 -0
- package/src/cli/commands/connect/index.ts +9 -6
- package/src/cli/commands/connection.ts +298 -0
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/commands/operate/inspect.ts +37 -20
- package/src/cli/commands/operate/serve.ts +21 -0
- package/src/cli/commands/owner/assets.ts +132 -0
- package/src/cli/commands/owner/shared.ts +28 -4
- package/src/cli/commands/owner/tasks.ts +194 -0
- package/src/cli/commands/owner.ts +9 -4
- package/src/cli/config-edit.ts +33 -7
- package/src/cli/config-repair.ts +115 -11
- package/src/cli/dispatch-owner.ts +49 -8
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +26 -3
- package/src/cli/provider-marks.ts +1 -1
- package/src/cli/runtime/registry.ts +10 -2
- package/src/cli/selection.ts +14 -0
- package/src/cli/usage.ts +18 -2
- package/src/connectivity/mail/attachments.ts +5 -1
- package/src/connectivity/mail/index.ts +6 -1
- package/src/connectivity/manifest/provider.ts +15 -2
- package/src/deployments/deploy.ts +3 -2
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/servable.ts +1 -1
- package/src/deployments/upload.ts +0 -53
- package/src/profile/load.ts +46 -0
- package/src/providers/assets/provider.ts +337 -0
- package/src/providers/assets/store.ts +167 -0
- package/src/providers/bunq/hints.ts +3 -1
- package/src/providers/bunq/redact.ts +13 -2
- package/src/providers/bunq/specs/bunq.v1.json +20 -1
- package/src/providers/bunq/specs/vendor.ts +59 -1
- package/src/providers/google/index.ts +1 -1
- package/src/providers/google/tasks/index.ts +3 -3
- package/src/providers/google/tasks/redact.ts +21 -11
- package/src/providers/index.ts +3 -3
- package/src/providers/owner.ts +39 -19
- package/src/providers/setup/plan.ts +17 -1
- package/src/providers/shared/vendor-operations.ts +81 -0
- package/src/providers/tasks/provider.ts +370 -0
- package/src/providers/tasks/store.ts +248 -0
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +67 -8
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { guessContentType } from '#connectivity/mail';
|
|
3
|
+
import type { BlobStore } from '#connectivity';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How an asset is stored, and the only place that knows.
|
|
7
|
+
*
|
|
8
|
+
* **The key is the filename.** `invoice-2026-03.pdf` is stored at
|
|
9
|
+
* `assets/<connection>/invoice-2026-03.pdf`, and that is the whole of the
|
|
10
|
+
* layout — no id, no prefix, no sidecar, no index. `BlobStore.list()` already
|
|
11
|
+
* reports size and mtime, and the content type follows from the extension, so
|
|
12
|
+
* every fact a listing needs is either the key itself or something the store
|
|
13
|
+
* already had. Nothing is written twice and nothing can disagree.
|
|
14
|
+
*
|
|
15
|
+
* That is memory's design carried over rather than a new one: ADR-014 reversed
|
|
16
|
+
* an index-plus-body split for exactly this reason, and it applies harder here,
|
|
17
|
+
* because a sidecar holding an asset's name would be a second name for a file
|
|
18
|
+
* that already has one. What the owner sees under
|
|
19
|
+
* `~/.lanes-link/data/<profile>/assets/main/` is a directory of their files,
|
|
20
|
+
* with their names, openable by anything.
|
|
21
|
+
*
|
|
22
|
+
* The cost is that an asset carries no description, and that is deliberate:
|
|
23
|
+
* "the March invoice is in assets as invoice-2026-03.pdf" is a memory entry.
|
|
24
|
+
* Prose in a store with no way to search it would be worse than either.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What may be a name, and why it is narrower than a filename.
|
|
29
|
+
*
|
|
30
|
+
* No path separator, so the namespace stays flat: `scopeBlobStore` would happily
|
|
31
|
+
* accept `a/b` and create a directory, and a listing that nests is one where
|
|
32
|
+
* `list()`'s single prefix scan stops describing what is there. No leading dot,
|
|
33
|
+
* because a dotfile is invisible in exactly the directory the owner is meant to
|
|
34
|
+
* be able to read. No control characters, which are not names.
|
|
35
|
+
*
|
|
36
|
+
* Spaces and unicode are allowed. Refusing them would reject the names files
|
|
37
|
+
* actually have, and the name is percent-encoded at the one boundary that needs
|
|
38
|
+
* it — a resource URI — rather than being restricted to survive it.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_NAME = 200;
|
|
41
|
+
const CONTROL = /[\x00-\x1F\x7F]/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Suffixes a blob adapter treats as its own bookkeeping.
|
|
45
|
+
*
|
|
46
|
+
* The filesystem adapter writes `<key>.meta` beside a blob whose content type its
|
|
47
|
+
* extension cannot express, `<key>.tmp` while a write is in flight, and skips
|
|
48
|
+
* both in `list()`. So an asset called `report.meta` would be stored, would
|
|
49
|
+
* never appear in a listing, and would read back only if you already knew the
|
|
50
|
+
* name — silent, and shaped like data loss. Memory never met this because every
|
|
51
|
+
* key it writes ends `.md`; an asset's key is whatever the file was called.
|
|
52
|
+
*/
|
|
53
|
+
const ADAPTER_SUFFIXES = ['.meta', '.tmp'];
|
|
54
|
+
|
|
55
|
+
export function assertAssetName(name: string): void {
|
|
56
|
+
const why = nameProblem(name);
|
|
57
|
+
if (why) throw new Error(`Asset name ${JSON.stringify(name)} ${why}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nameProblem(name: string): string | null {
|
|
61
|
+
if (name.length === 0) return 'must not be empty.';
|
|
62
|
+
if (name.length > MAX_NAME) return `must be at most ${MAX_NAME} characters.`;
|
|
63
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
64
|
+
return 'must not contain a path separator — assets are a flat set of files, not a tree.';
|
|
65
|
+
}
|
|
66
|
+
if (name.startsWith('.')) return 'must not start with a dot.';
|
|
67
|
+
if (CONTROL.test(name)) return 'must not contain control characters.';
|
|
68
|
+
|
|
69
|
+
const reserved = ADAPTER_SUFFIXES.find((suffix) => name.endsWith(suffix));
|
|
70
|
+
if (reserved) {
|
|
71
|
+
return `must not end in "${reserved}" — a blob store keeps its own bookkeeping under that suffix and would hide the file.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether a listed key is an asset. Anything unnameable is skipped, not repaired. */
|
|
78
|
+
export function nameFromKey(key: string): string | null {
|
|
79
|
+
return nameProblem(key) === null ? key : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Asset {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly bytes: number;
|
|
85
|
+
readonly contentType: string;
|
|
86
|
+
readonly modifiedAt: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every asset, newest first.
|
|
91
|
+
*
|
|
92
|
+
* One `list()` and no reads at all, which is the payoff for the key being the
|
|
93
|
+
* filename: memory has to open every entry to list it, and this does not.
|
|
94
|
+
*/
|
|
95
|
+
export async function allAssets(storage: BlobStore): Promise<Asset[]> {
|
|
96
|
+
return (await storage.list())
|
|
97
|
+
.flatMap((blob) => {
|
|
98
|
+
const name = nameFromKey(blob.key);
|
|
99
|
+
return name === null
|
|
100
|
+
? []
|
|
101
|
+
: [
|
|
102
|
+
{
|
|
103
|
+
name,
|
|
104
|
+
bytes: blob.size,
|
|
105
|
+
contentType: blob.contentType ?? guessContentType(name),
|
|
106
|
+
modifiedAt: blob.modifiedAt.toISOString(),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
})
|
|
110
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function findAsset(storage: BlobStore, name: string): Promise<Asset | null> {
|
|
114
|
+
return (await allAssets(storage)).find((asset) => asset.name === name) ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function digestOf(bytes: Uint8Array): string {
|
|
118
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether these bytes can be handed to a model as text.
|
|
123
|
+
*
|
|
124
|
+
* Two questions, and both have to pass. The declared type has to be a text one
|
|
125
|
+
* — `text/*`, or a structured type that is text by construction — and the bytes
|
|
126
|
+
* have to contain no NUL, because a mislabelled binary is common and a content
|
|
127
|
+
* type is a claim rather than an observation. Failing either means the asset is
|
|
128
|
+
* described instead of returned, which is the honest answer: `ResourceContents`
|
|
129
|
+
* carries text and nothing else, and base64 into a conversation is the cost this
|
|
130
|
+
* whole mechanism exists to avoid.
|
|
131
|
+
*/
|
|
132
|
+
export function isTextual(contentType: string, bytes: Uint8Array): boolean {
|
|
133
|
+
const type = contentType.split(';')[0]!.trim().toLowerCase();
|
|
134
|
+
|
|
135
|
+
const declared =
|
|
136
|
+
type.startsWith('text/') ||
|
|
137
|
+
type.endsWith('+json') ||
|
|
138
|
+
type.endsWith('+xml') ||
|
|
139
|
+
['application/json', 'application/xml', 'application/yaml', 'application/x-yaml'].includes(
|
|
140
|
+
type,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return declared && !bytes.includes(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `invoice.pdf application/pdf 184 KB 2026-08-27` */
|
|
147
|
+
export function describeAsset(asset: Asset): string {
|
|
148
|
+
return `${asset.name} ${asset.contentType} ${humanBytes(asset.bytes)} ${asset.modifiedAt.slice(0, 10)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function humanBytes(bytes: number): string {
|
|
152
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
153
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
154
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The pieces `lanes link assets` needs to reach the same bytes the provider does. */
|
|
158
|
+
export const assetStorage = {
|
|
159
|
+
all: allAssets,
|
|
160
|
+
find: findAsset,
|
|
161
|
+
describe: describeAsset,
|
|
162
|
+
humanBytes,
|
|
163
|
+
digest: digestOf,
|
|
164
|
+
assertName: assertAssetName,
|
|
165
|
+
nameFromKey,
|
|
166
|
+
isTextual,
|
|
167
|
+
};
|
|
@@ -29,7 +29,9 @@ export const BUNQ_HINTS: Record<string, string> = {
|
|
|
29
29
|
UPDATE_DraftPayment_for_User_MonetaryAccount:
|
|
30
30
|
'Changes a draft that is still pending — status ACCEPTED sends it, REJECTED cancels it. ' +
|
|
31
31
|
'previous_updated_timestamp is required and comes from reading the draft first; it is what stops two ' +
|
|
32
|
-
'callers acting on the same draft.'
|
|
32
|
+
'callers acting on the same draft. Those two fields are the entire call — bunq refuses entries and ' +
|
|
33
|
+
'number_of_required_accepts here as superfluous — so changing what a draft pays means rejecting it and ' +
|
|
34
|
+
'creating another.',
|
|
33
35
|
|
|
34
36
|
CREATE_PaymentBatch_for_User_MonetaryAccount:
|
|
35
37
|
'Up to 350 payments in one call. Executes immediately, like a direct payment, and is all-or-nothing: bunq rejects ' +
|
|
@@ -51,14 +51,25 @@ export const BUNQ_REDACT: Record<string, string[]> = {
|
|
|
51
51
|
'status',
|
|
52
52
|
'schedule',
|
|
53
53
|
],
|
|
54
|
+
// Five, not seven: `entries` and `number_of_required_accepts` are no longer
|
|
55
|
+
// arguments at all. This is the one write here whose event cannot name an
|
|
56
|
+
// amount or a counterparty, because the call does not carry them — it says
|
|
57
|
+
// which draft was accepted and against which version of it, and that is
|
|
58
|
+
// everything there is to keep.
|
|
59
|
+
//
|
|
60
|
+
// Not everything a reader wants, though, and worth being honest that the gap
|
|
61
|
+
// is real rather than closed. The entries are on the event that *created* the
|
|
62
|
+
// draft, and nothing joins the two: an `AuditEvent` records arguments only,
|
|
63
|
+
// and bunq returns the draft id in the create's response. Reconstructing what
|
|
64
|
+
// an ACCEPTED draft paid means matching by hand. `context.audit.annotate`,
|
|
65
|
+
// which `gmail.send_message` uses to record resolved facts, is the shape of a
|
|
66
|
+
// fix and is a change to dispatch rather than to this list.
|
|
54
67
|
UPDATE_DraftPayment_for_User_MonetaryAccount: [
|
|
55
68
|
'userID',
|
|
56
69
|
'monetary-accountID',
|
|
57
70
|
'itemId',
|
|
58
71
|
'status',
|
|
59
|
-
'entries',
|
|
60
72
|
'previous_updated_timestamp',
|
|
61
|
-
'number_of_required_accepts',
|
|
62
73
|
],
|
|
63
74
|
CREATE_PaymentBatch_for_User_MonetaryAccount: ['userID', 'monetary-accountID', 'payments'],
|
|
64
75
|
};
|
|
@@ -144,7 +144,26 @@
|
|
|
144
144
|
"content": {
|
|
145
145
|
"application/json": {
|
|
146
146
|
"schema": {
|
|
147
|
-
"
|
|
147
|
+
"type": "object",
|
|
148
|
+
"description": "The whole of what this call takes. bunq refuses any other field here as superfluous — the rest of the schema it shares belongs to the call that creates one.",
|
|
149
|
+
"properties": {
|
|
150
|
+
"status": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"description": "The status of the DraftPayment.",
|
|
153
|
+
"readOnly": false,
|
|
154
|
+
"writeOnly": false
|
|
155
|
+
},
|
|
156
|
+
"previous_updated_timestamp": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"description": "The last updated_timestamp that you received for this DraftPayment. This needs to be provided to prevent race conditions.",
|
|
159
|
+
"readOnly": false,
|
|
160
|
+
"writeOnly": true
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"required": [
|
|
164
|
+
"status",
|
|
165
|
+
"previous_updated_timestamp"
|
|
166
|
+
]
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
169
|
}
|
|
@@ -22,6 +22,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
|
|
24
24
|
import { cutCycles, referenced, type Spec } from '../../shared/openapi.ts';
|
|
25
|
+
import { projectRequestBody } from '../../shared/vendor-operations.ts';
|
|
25
26
|
|
|
26
27
|
const SOURCE = 'https://raw.githubusercontent.com/bunq/doc/master/swagger.json';
|
|
27
28
|
const OUT = 'bunq.v1.json';
|
|
@@ -187,6 +188,34 @@ function dropReadOnly(node: unknown): number {
|
|
|
187
188
|
return dropped;
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The operations bunq describes with the schema of a *different* operation.
|
|
193
|
+
*
|
|
194
|
+
* `PUT .../draft-payment/{itemId}` points at `DraftPayment`, the same schema as
|
|
195
|
+
* the `POST` that creates one, which requires `entries` and
|
|
196
|
+
* `number_of_required_accepts`. For a create those two *are* the payment. For an
|
|
197
|
+
* update bunq refuses both as superfluous — its own generated SDK sends only
|
|
198
|
+
* `status`, `previous_updated_timestamp` and `schedule` here — so the tool asked
|
|
199
|
+
* for exactly what the bank rejects and every accept failed. Worse than the
|
|
200
|
+
* error: made to send `entries` for a draft that has them, a model reaches for
|
|
201
|
+
* the array echoed back or for `[]`, and both ask bunq to rewrite what the draft
|
|
202
|
+
* pays on the way to approving it. A hint cannot fix a required argument, which
|
|
203
|
+
* is the difference between this and the `payments`-is-really-an-array note
|
|
204
|
+
* above: nothing stops an agent sending an array, and nothing lets it omit a
|
|
205
|
+
* required field. `required` is asserted here rather than projected because
|
|
206
|
+
* bunq's own is the one written for the create.
|
|
207
|
+
*
|
|
208
|
+
* `schedule` is deliberately not projected: it carries `recurrence_unit` and
|
|
209
|
+
* `recurrence_size`, so approving a one-off draft would be a place to acquire a
|
|
210
|
+
* standing order — the risk `OPERATIONS` gives for leaving the scheduling
|
|
211
|
+
* *endpoints* out. Not the whole of that risk, though: `CREATE_DraftPayment`
|
|
212
|
+
* still offers the same field, because it comes with bunq's create schema.
|
|
213
|
+
* Closing that changes what the provider can do rather than whether a call
|
|
214
|
+
* works, and is not done here.
|
|
215
|
+
*/
|
|
216
|
+
const UPDATE_BODIES: Record<string, readonly string[]> = {
|
|
217
|
+
UPDATE_DraftPayment_for_User_MonetaryAccount: ['status', 'previous_updated_timestamp'],
|
|
218
|
+
};
|
|
190
219
|
|
|
191
220
|
async function vendor(): Promise<void> {
|
|
192
221
|
const response = await fetch(SOURCE);
|
|
@@ -246,6 +275,34 @@ async function vendor(): Promise<void> {
|
|
|
246
275
|
}
|
|
247
276
|
|
|
248
277
|
const schemas = spec.components?.schemas ?? {};
|
|
278
|
+
|
|
279
|
+
const projected = new Set<string>();
|
|
280
|
+
for (const item of Object.values(paths)) {
|
|
281
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
282
|
+
if (!METHODS.includes(method)) continue;
|
|
283
|
+
const fields = operation.operationId ? UPDATE_BODIES[operation.operationId] : undefined;
|
|
284
|
+
if (!fields || !operation.operationId) continue;
|
|
285
|
+
|
|
286
|
+
projectRequestBody(
|
|
287
|
+
operation as unknown as Record<string, unknown>,
|
|
288
|
+
operation.operationId,
|
|
289
|
+
schemas,
|
|
290
|
+
fields,
|
|
291
|
+
'The whole of what this call takes. bunq refuses any other field here as superfluous — the rest ' +
|
|
292
|
+
'of the schema it shares belongs to the call that creates one.',
|
|
293
|
+
);
|
|
294
|
+
projected.add(operation.operationId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The same refusal as `missing` above, and for a sharper reason: an entry that
|
|
299
|
+
// matches nothing narrows nothing, and what is left is the wide body this
|
|
300
|
+
// exists to remove — printed as a success.
|
|
301
|
+
const unprojected = Object.keys(UPDATE_BODIES).filter((id) => !projected.has(id));
|
|
302
|
+
if (unprojected.length > 0) {
|
|
303
|
+
throw new Error(`UPDATE_BODIES names operations the spec does not have — ${unprojected.join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
249
306
|
const keep = referenced(paths, schemas);
|
|
250
307
|
const trimmedSchemas = Object.fromEntries(
|
|
251
308
|
Object.entries(schemas).filter(([name]) => keep.has(name)),
|
|
@@ -295,7 +352,8 @@ async function vendor(): Promise<void> {
|
|
|
295
352
|
` bunq ${String(Object.keys(paths).length).padStart(2)} paths, ` +
|
|
296
353
|
`${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
|
|
297
354
|
`${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${headers} protocol params dropped, ` +
|
|
298
|
-
`${readOnly} read-only fields dropped, ${size}
|
|
355
|
+
`${readOnly} read-only fields dropped, ${projected.size} update ` +
|
|
356
|
+
`bod${projected.size === 1 ? 'y' : 'ies'} projected, ${size}KB`,
|
|
299
357
|
);
|
|
300
358
|
|
|
301
359
|
await report(trimmed);
|
|
@@ -7,5 +7,5 @@ export { driveMcp } from './drive-mcp/index.ts';
|
|
|
7
7
|
export { gmail, GMAIL_SCOPES } from './gmail/index.ts';
|
|
8
8
|
export { gmailImap } from './gmail-imap/index.ts';
|
|
9
9
|
export { gmailMcp } from './gmail-mcp/index.ts';
|
|
10
|
+
export { googleTasks } from './tasks/index.ts';
|
|
10
11
|
export { sheets } from './sheets/index.ts';
|
|
11
|
-
export { tasks } from './tasks/index.ts';
|
|
@@ -17,7 +17,7 @@ import { TASKS_REDACT } from './redact.ts';
|
|
|
17
17
|
* What bounds it is what bounds the others. The token can delete a whole list;
|
|
18
18
|
* the tool surface cannot, because `tasklists.delete` is not vendored — Tasks
|
|
19
19
|
* has no trash, so destroying a list destroys every task in it. `lanes link
|
|
20
|
-
* policy deny
|
|
20
|
+
* policy deny google_tasks.*` narrows it further.
|
|
21
21
|
*
|
|
22
22
|
* No `identity` block, and that is a decision rather than an omission. Nothing
|
|
23
23
|
* reachable under `auth/tasks` returns an address: `tasklists.list` answers
|
|
@@ -32,8 +32,8 @@ import { TASKS_REDACT } from './redact.ts';
|
|
|
32
32
|
*/
|
|
33
33
|
const TASKS_SCOPES = ['https://www.googleapis.com/auth/tasks'];
|
|
34
34
|
|
|
35
|
-
export const
|
|
36
|
-
id: '
|
|
35
|
+
export const googleTasks = defineProvider({
|
|
36
|
+
id: 'google_tasks',
|
|
37
37
|
name: 'Google Tasks',
|
|
38
38
|
description:
|
|
39
39
|
'Read and write task lists and tasks — create, edit, complete, reorder, and delete — via the Tasks REST API.',
|
|
@@ -6,18 +6,28 @@
|
|
|
6
6
|
* them. `due` and `status` are kept, because "marked the thing due Friday done"
|
|
7
7
|
* is the shape of the change and neither argument says what the thing was.
|
|
8
8
|
*
|
|
9
|
+
* **The keys carry Google's whole operationId**, `tasks.tasks.patch` rather than
|
|
10
|
+
* `tasks.patch`. `shortenName` strips the provider id from a discovered tool
|
|
11
|
+
* name, and this provider's id is `google_tasks` while the API namespaces its
|
|
12
|
+
* operations under `tasks` — so nothing is stripped and the capability is
|
|
13
|
+
* `google_tasks.tasks.patch`. `contacts` has read this way since it shipped: its
|
|
14
|
+
* id is `contacts` and the People API says `people.people.searchContacts`. The
|
|
15
|
+
* keys were the short form while the id was `tasks` and the prefix happened to
|
|
16
|
+
* match; renaming the provider for the built-in task list (ADR-051) ended the
|
|
17
|
+
* coincidence.
|
|
18
|
+
*
|
|
9
19
|
* Two argument names are not what they look like, and a wrong key here fails
|
|
10
20
|
* silently — the lookup misses, every value is withheld, and it reads exactly
|
|
11
21
|
* like working redaction. The generator disambiguates a collision between a
|
|
12
22
|
* path or query parameter and a body field by prefixing the location, so
|
|
13
|
-
* `tasks.insert` takes `queryParent` (the query one) beside the body's
|
|
14
|
-
* `parent`. `tasks.move` has no such collision and takes a plain `parent`.
|
|
23
|
+
* `tasks.tasks.insert` takes `queryParent` (the query one) beside the body's
|
|
24
|
+
* `parent`. `tasks.tasks.move` has no such collision and takes a plain `parent`.
|
|
15
25
|
*/
|
|
16
26
|
export const TASKS_REDACT: Record<string, string[]> = {
|
|
17
|
-
'tasklists.list': ['maxResults'],
|
|
18
|
-
'tasklists.insert': [],
|
|
19
|
-
'tasklists.patch': ['tasklist'],
|
|
20
|
-
'tasks.list': [
|
|
27
|
+
'tasks.tasklists.list': ['maxResults'],
|
|
28
|
+
'tasks.tasklists.insert': [],
|
|
29
|
+
'tasks.tasklists.patch': ['tasklist'],
|
|
30
|
+
'tasks.tasks.list': [
|
|
21
31
|
'tasklist',
|
|
22
32
|
'showCompleted',
|
|
23
33
|
'showDeleted',
|
|
@@ -26,9 +36,9 @@ export const TASKS_REDACT: Record<string, string[]> = {
|
|
|
26
36
|
'dueMax',
|
|
27
37
|
'maxResults',
|
|
28
38
|
],
|
|
29
|
-
'tasks.get': ['tasklist', 'task'],
|
|
30
|
-
'tasks.insert': ['tasklist', 'queryParent', 'previous', 'due', 'status'],
|
|
31
|
-
'tasks.patch': ['tasklist', 'task', 'due', 'status'],
|
|
32
|
-
'tasks.delete': ['tasklist', 'task'],
|
|
33
|
-
'tasks.move': ['tasklist', 'task', 'parent', 'previous'],
|
|
39
|
+
'tasks.tasks.get': ['tasklist', 'task'],
|
|
40
|
+
'tasks.tasks.insert': ['tasklist', 'queryParent', 'previous', 'due', 'status'],
|
|
41
|
+
'tasks.tasks.patch': ['tasklist', 'task', 'due', 'status'],
|
|
42
|
+
'tasks.tasks.delete': ['tasklist', 'task'],
|
|
43
|
+
'tasks.tasks.move': ['tasklist', 'task', 'parent', 'previous'],
|
|
34
44
|
};
|
package/src/providers/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { gmail } from './google/gmail/index.ts';
|
|
|
11
11
|
import { gmailImap } from './google/gmail-imap/index.ts';
|
|
12
12
|
import { gmailMcp } from './google/gmail-mcp/index.ts';
|
|
13
13
|
import { sheets } from './google/sheets/index.ts';
|
|
14
|
-
import {
|
|
14
|
+
import { googleTasks } from './google/tasks/index.ts';
|
|
15
15
|
import { icloudCalendar } from './icloud/calendar/index.ts';
|
|
16
16
|
import { icloudContacts } from './icloud/contacts/index.ts';
|
|
17
17
|
import { icloudDrive } from './icloud/drive/index.ts';
|
|
@@ -63,7 +63,7 @@ export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
|
|
|
63
63
|
sheets,
|
|
64
64
|
docs,
|
|
65
65
|
calendar,
|
|
66
|
-
|
|
66
|
+
googleTasks,
|
|
67
67
|
contacts,
|
|
68
68
|
gmailImap,
|
|
69
69
|
gmailMcp,
|
|
@@ -97,8 +97,8 @@ export {
|
|
|
97
97
|
gmail,
|
|
98
98
|
gmailImap,
|
|
99
99
|
gmailMcp,
|
|
100
|
+
googleTasks,
|
|
100
101
|
sheets,
|
|
101
|
-
tasks,
|
|
102
102
|
} from './google/index.ts';
|
|
103
103
|
export { icloudCalendar, icloudContacts, icloudDrive, icloudMail } from './icloud/index.ts';
|
|
104
104
|
export { bunq } from './bunq/index.ts';
|
package/src/providers/owner.ts
CHANGED
|
@@ -1,38 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The owner layer — memory, skills, vault, setup, identity.
|
|
2
|
+
* The owner layer — memory, tasks, assets, skills, vault, setup, identity.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Seven providers that hold no third-party account: no OAuth, no vendor API, no
|
|
5
5
|
* rate limit anyone else imposes. They are ordinary `defineLocalProvider`
|
|
6
|
-
* registrations,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* registrations, scoped by the same profiles and gated by the same policy
|
|
7
|
+
* evaluation as everything else — which was the claim `docs/detailed/init.md`
|
|
8
|
+
* made and called the real test of the architecture. Since ADR-050 they are also
|
|
9
|
+
* the ones a fresh profile arrives with already granted, because what they reach
|
|
10
|
+
* is the owner's own material and there is no account behind them to protect.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Each lives in its own folder beside `google/` and `icloud/`, because that
|
|
13
|
+
* claim is only true if they are providers in the layout as well as in the
|
|
14
|
+
* prose. This file is the one thing they share: a barrel, because several are
|
|
15
|
+
* *constructed* with a store rather than declared as data, so the registry
|
|
16
|
+
* builder needs them together.
|
|
16
17
|
*
|
|
17
|
-
* `
|
|
18
|
-
* and
|
|
19
|
-
*
|
|
18
|
+
* `memory`, `tasks` and `assets` are the three that hold what the owner keeps,
|
|
19
|
+
* and they divide by what a thing *is* rather than by size: memory is what is
|
|
20
|
+
* true, tasks is what is to be done, assets is a file. That split is the whole
|
|
21
|
+
* of ADR-051, and the routing rule an agent needs is stated in
|
|
22
|
+
* `#server/mcp`'s instructions and in the bundled skill.
|
|
23
|
+
*
|
|
24
|
+
* `setup` is the same shape and holds no account either, but it describes the
|
|
25
|
+
* others rather than holding anything of the owner's. It is read-only by
|
|
26
|
+
* construction — see ADR-019 for why describing setup is not one
|
|
20
27
|
* of ADR-007's control-plane exclusions.
|
|
21
28
|
*
|
|
22
|
-
* `identity`
|
|
29
|
+
* `identity` holds no account either. It says who the owner is
|
|
23
30
|
* — the names and addresses to write as them — and is read-only for the reason
|
|
24
31
|
* `setup` is: what it reports is configuration, and configuration is changed in
|
|
25
32
|
* the CLI. It is a provider of its own rather than a section of `setup` so that
|
|
26
33
|
* naming the owner and describing what is connected are two policy decisions
|
|
27
34
|
* instead of one.
|
|
28
35
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* All seven ids are reserved (`RESERVED_PROVIDER_IDS`) and still refused by
|
|
37
|
+
* default — the registry has to be built with `allowReserved` to hold them, so a
|
|
38
|
+
* third-party provider cannot claim a namespace whose policy rules would then
|
|
39
|
+
* mean something else. `tasks` cost something to reserve: Google Tasks held that
|
|
40
|
+
* id and was renamed `google_tasks`, because the plain noun belongs to the
|
|
41
|
+
* owner's own list and a manifest already registered under it would have thrown
|
|
42
|
+
* on the second registration rather than shadowing anything.
|
|
33
43
|
*/
|
|
34
44
|
|
|
35
45
|
export { memoryProvider, memoryStorage, assertEntryId, type MemoryEntry } from './memory/provider.ts';
|
|
46
|
+
export { tasksProvider } from './tasks/provider.ts';
|
|
47
|
+
export {
|
|
48
|
+
ACTIVE_STATUSES,
|
|
49
|
+
TASK_STATUSES,
|
|
50
|
+
taskStorage,
|
|
51
|
+
type Task,
|
|
52
|
+
type TaskStatus,
|
|
53
|
+
} from './tasks/store.ts';
|
|
54
|
+
export { assetsProvider } from './assets/provider.ts';
|
|
55
|
+
export { assetStorage, type Asset } from './assets/store.ts';
|
|
36
56
|
export { createSkillsProvider, type SkillsProviderOptions } from './skills/provider.ts';
|
|
37
57
|
export { createVaultProvider, type VaultProviderOptions } from './vault/provider.ts';
|
|
38
58
|
export { createSetupProvider, type SetupProviderOptions } from './setup/provider.ts';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProviderManifest } from '#connectivity';
|
|
2
|
-
import { hasOwnClientPath, setupRequirements, type SetupRequirement } from '#connectivity';
|
|
2
|
+
import { hasOwnClientPath, RESERVED_PROVIDER_IDS, setupRequirements, type SetupRequirement } from '#connectivity';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What connecting a provider involves, assembled from its manifest.
|
|
@@ -19,6 +19,21 @@ export interface ProviderPlan {
|
|
|
19
19
|
readonly id: string;
|
|
20
20
|
readonly name: string;
|
|
21
21
|
readonly description: string;
|
|
22
|
+
/**
|
|
23
|
+
* This provider is part of what a profile *is*, not an account it holds.
|
|
24
|
+
*
|
|
25
|
+
* The owner layer — `memory`, `skills`, `vault`, `setup`, `identity` — keyed off
|
|
26
|
+
* `RESERVED_PROVIDER_IDS`, which is the same list the registry uses to stop a
|
|
27
|
+
* third-party manifest claiming one of those ids.
|
|
28
|
+
*
|
|
29
|
+
* Reported because it is the fact a surface needs and cannot derive.
|
|
30
|
+
* `multiAccount` is the nearest thing and it is not it: that is a credential
|
|
31
|
+
* test, and `icloud_drive` is `auth: none` without being owner-layer at all. A
|
|
32
|
+
* client wanting to group these, or to withhold a disconnect that would leave a
|
|
33
|
+
* dangling policy grant, was left hardcoding the list — which then goes stale
|
|
34
|
+
* the next time one is added here. This travels with the release instead.
|
|
35
|
+
*/
|
|
36
|
+
readonly reserved: boolean;
|
|
22
37
|
/** Connection keys of this provider already configured, e.g. `gmail.main`. */
|
|
23
38
|
readonly connected: readonly string[];
|
|
24
39
|
/**
|
|
@@ -122,6 +137,7 @@ export function planFor(
|
|
|
122
137
|
id: manifest.id,
|
|
123
138
|
name: manifest.name,
|
|
124
139
|
description: manifest.description,
|
|
140
|
+
reserved: RESERVED_PROVIDER_IDS.includes(manifest.id),
|
|
125
141
|
connected,
|
|
126
142
|
multiAccount: manifest.auth.kind !== 'none',
|
|
127
143
|
browser: manifest.auth.kind === 'oauth',
|
|
@@ -96,3 +96,84 @@ export function narrowRequestBody(
|
|
|
96
96
|
body.content = Object.fromEntries(kept.map((type) => [type, body.content![type]]));
|
|
97
97
|
return before.length - kept.length;
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Replace a request body with a projection of the schema it points at.
|
|
102
|
+
*
|
|
103
|
+
* The third surgery on this axis, and the first about the body being *wrong*
|
|
104
|
+
* rather than too wide. A document that describes two operations with one schema
|
|
105
|
+
* describes at least one of them wrongly, and `required` is where it bites: the
|
|
106
|
+
* generated tool asks for arguments the vendor refuses on the call they are
|
|
107
|
+
* attached to, so there is no correct call to make. It fails at the vendor, on
|
|
108
|
+
* every attempt, and nothing before the request can see it.
|
|
109
|
+
*
|
|
110
|
+
* Projecting rather than hand-writing is what keeps this tied to the document.
|
|
111
|
+
* The named fields are copied out of the vendor's own schema with their types
|
|
112
|
+
* and descriptions, so the tool still says what the vendor says. Everything this
|
|
113
|
+
* cannot verify it refuses instead: a field that is gone, a field the document
|
|
114
|
+
* marks read-only, a body offering a content type this does not rewrite, a
|
|
115
|
+
* `$ref` that does not point into `components.schemas`. A vendor refresh should
|
|
116
|
+
* fail loudly here rather than quietly restore the body it was called to fix.
|
|
117
|
+
*
|
|
118
|
+
* `required` is the one thing NOT projected — it is the caller's assertion, and
|
|
119
|
+
* necessarily so, since the whole disease is a `required` written for the other
|
|
120
|
+
* operation. Say why in the caller.
|
|
121
|
+
*
|
|
122
|
+
* `note` becomes the body schema's description. It lands in the committed
|
|
123
|
+
* document for whoever reads it there; it does **not** reach the agent, because
|
|
124
|
+
* the generator flattens body properties to the top level and drops the body
|
|
125
|
+
* schema's own description. The sentence an agent needs goes in `hints`.
|
|
126
|
+
*
|
|
127
|
+
* Apply before reachability, so a schema the projection no longer reaches leaves
|
|
128
|
+
* the document rather than lingering unused.
|
|
129
|
+
*/
|
|
130
|
+
export function projectRequestBody(
|
|
131
|
+
operation: Record<string, unknown>,
|
|
132
|
+
operationId: string,
|
|
133
|
+
schemas: Record<string, unknown>,
|
|
134
|
+
fields: readonly string[],
|
|
135
|
+
note: string,
|
|
136
|
+
): void {
|
|
137
|
+
const content = (operation['requestBody'] as { content?: Record<string, { schema?: { $ref?: string } }> })
|
|
138
|
+
?.content;
|
|
139
|
+
const types = Object.keys(content ?? {});
|
|
140
|
+
|
|
141
|
+
const other = types.filter((type) => type !== 'application/json');
|
|
142
|
+
if (!content || types.length === 0 || other.length > 0) {
|
|
143
|
+
// A body left pointing at the wide schema on a second content type is the
|
|
144
|
+
// bug still present on whichever branch the generator happens to prefer.
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${operationId}: expected a lone application/json request body to project, found ${types.join(', ') || 'none'}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const json = content['application/json'];
|
|
151
|
+
const reference = json?.schema?.$ref;
|
|
152
|
+
if (!json || typeof reference !== 'string' || !reference.startsWith('#/components/schemas/')) {
|
|
153
|
+
throw new Error(`${operationId}: request body is ${reference ?? 'not a $ref'}, not a schema reference`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const name = reference.slice('#/components/schemas/'.length);
|
|
157
|
+
const source = (schemas[name] as { properties?: Record<string, unknown> } | undefined)?.properties ?? {};
|
|
158
|
+
|
|
159
|
+
const properties: Record<string, unknown> = {};
|
|
160
|
+
for (const field of fields) {
|
|
161
|
+
const property = source[field] as { readOnly?: boolean } | undefined;
|
|
162
|
+
if (!property) throw new Error(`${operationId}: ${name} no longer describes "${field}"`);
|
|
163
|
+
if (property.readOnly === true) {
|
|
164
|
+
// Projected fields are inlined into the path, where the read-only strip
|
|
165
|
+
// does not reach — so a field the vendor computes would survive here and
|
|
166
|
+
// be demanded as an argument it ignores.
|
|
167
|
+
throw new Error(`${operationId}: ${name}.${field} is read-only and cannot be a request field`);
|
|
168
|
+
}
|
|
169
|
+
properties[field] = property;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
json.schema = {
|
|
173
|
+
type: 'object',
|
|
174
|
+
description: note,
|
|
175
|
+
properties,
|
|
176
|
+
required: [...fields],
|
|
177
|
+
} as never;
|
|
178
|
+
}
|
|
179
|
+
|