@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,283 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ loadProfileSkills,
4
+ readSkill,
5
+ removeSkill,
6
+ renderSkill,
7
+ writeSkill,
8
+ type LoadedSkill,
9
+ } from './store.ts';
10
+ import {
11
+ defineLocalProvider,
12
+ keepKeys,
13
+ type BlobStore,
14
+ type Capability,
15
+ type ProviderDefinition,
16
+ } from '#connectivity';
17
+
18
+ /**
19
+ * `skills` — reusable procedures, on the MCP prompts primitive.
20
+ *
21
+ * **Skills are invoked, not read** (ADR-012 §1). The discriminator is whether
22
+ * the answer depends on arguments: a resource is a function of its URI alone,
23
+ * and "review this diff" is a function of the diff. Against a tool: a tool
24
+ * result returns to the model as data it reasons about, while a prompt returns
25
+ * as messages that *become* the conversation — and a procedure wants the
26
+ * second. This is the case ADR-006 reserved the primitive for. That part is
27
+ * unchanged.
28
+ *
29
+ * **What changed is that a skill can now be written (ADR-014).** ADR-012 §1
30
+ * refused the write path outright: a skill is instructions, so an agent able to
31
+ * author one could persist its own future behaviour. The argument was right and
32
+ * the conclusion was too strong. It is the same risk `memory.write` carries —
33
+ * text an agent authors, stored once, re-served to every later session — and
34
+ * memory answered it by making writing a separate capability in a non-default
35
+ * bundle rather than by having no write path at all. Skills now answer it the
36
+ * same way, and the asymmetry between the two goes away.
37
+ *
38
+ * Two things preserve the narrower half of ADR-012 §1:
39
+ *
40
+ * - **Authoring is not in the default bundle.** `lanes link connect skills` grants
41
+ * `skills.*`, so narrowing it is a `deny: [skills.manage.*]` line — the
42
+ * same one-line narrowing memory documents, and stated as plainly.
43
+ * - **`skills.manage.get` is in the write bundle, not the read one.** A
44
+ * read-only agent still cannot read a skill body and so cannot select its
45
+ * own instructions from one; the prompt primitive's user-selected framing
46
+ * survives for every agent that has not been granted authoring.
47
+ *
48
+ * The management tools live under `manage.` rather than at the top level
49
+ * because a skill named `write` would otherwise be the capability
50
+ * `skills.write` twice over. Skill names cannot contain a dot, so
51
+ * `skills.manage.*` can only ever mean these four.
52
+ */
53
+
54
+ export interface SkillsProviderOptions {
55
+ /**
56
+ * The skills currently in the store. Read by the caller because loading them
57
+ * is asynchronous and building a provider is not, and because the runtime is
58
+ * the right place to decide when the store is touched.
59
+ */
60
+ readonly skills: readonly LoadedSkill[];
61
+ /**
62
+ * Where skills live. Omitted for a read-only provider — one built without a
63
+ * store has no authoring capabilities at all, rather than four that fail.
64
+ */
65
+ readonly store?: BlobStore;
66
+ /**
67
+ * Called after a write or a removal, so the registry can pick the change up.
68
+ *
69
+ * Each skill is its own capability, so a newly written one is invisible until
70
+ * the provider is rebuilt. The provider cannot rebuild itself and should not
71
+ * know how; it says that something changed, and the runtime decides what that
72
+ * means.
73
+ */
74
+ readonly onChange?: () => Promise<void>;
75
+ }
76
+
77
+ export function createSkillsProvider(options: SkillsProviderOptions): ProviderDefinition {
78
+ const seen = new Set<string>();
79
+ const capabilities: Capability[] = [];
80
+
81
+ for (const skill of options.skills) {
82
+ if (seen.has(skill.name)) {
83
+ throw new Error(
84
+ `Two skills are both named "${skill.name}" (${skill.path}). A skill name becomes the capability id "skills.${skill.name}", which must be unique.`,
85
+ );
86
+ }
87
+ seen.add(skill.name);
88
+
89
+ capabilities.push({
90
+ kind: 'prompt',
91
+ name: skill.name,
92
+ ...(skill.title ? { title: skill.title } : {}),
93
+ description: skill.description,
94
+ arguments: skill.arguments.map((argument) => ({
95
+ name: argument.name,
96
+ description: argument.description,
97
+ ...(argument.required ? { required: true } : {}),
98
+ })),
99
+ // The arguments are the procedure's parameters and are recorded by name
100
+ // and type only — the default. A skill argument routinely carries the very
101
+ // material the skill is about.
102
+ async render(args) {
103
+ return {
104
+ messages: [{ role: 'user', text: renderSkill(skill.body, args) }],
105
+ };
106
+ },
107
+ });
108
+ }
109
+
110
+ const manage = options.store ? managementCapabilities(options.store, options.onChange) : [];
111
+
112
+ return defineLocalProvider({
113
+ id: 'skills',
114
+ name: 'Skills',
115
+ version: '1.0.0',
116
+ description:
117
+ "Reusable procedures the owner has written, offered as MCP prompts. Authoring them is a separate capability from invoking them, and is not granted by default.",
118
+
119
+ configSchema: z.object({}),
120
+ connectionSchema: z.object({}),
121
+
122
+ bundles: [
123
+ {
124
+ name: 'read',
125
+ description: 'Invoke the owner-authored skills.',
126
+ oauth_scopes: [],
127
+ // Every skill, and nothing that reads or writes a skill's source.
128
+ // Per-skill control is a policy line — `deny: [skills.review_diff]` —
129
+ // rather than a bundle.
130
+ capabilities: [...seen],
131
+ default: true,
132
+ },
133
+ ...(manage.length > 0
134
+ ? [
135
+ {
136
+ // Not in the default bundle, for the reason in the docstring: a
137
+ // skill an agent writes is instructions it will be handed later.
138
+ name: 'author',
139
+ description: 'Read, create, and delete the skills themselves.',
140
+ oauth_scopes: [],
141
+ capabilities: manage.map((capability) => capability.name),
142
+ },
143
+ ]
144
+ : []),
145
+ ],
146
+
147
+ capabilities: [...capabilities, ...manage],
148
+ });
149
+ }
150
+
151
+ function managementCapabilities(
152
+ store: BlobStore,
153
+ onChange: (() => Promise<void>) | undefined,
154
+ ): Capability[] {
155
+ const changed = async (): Promise<void> => {
156
+ await onChange?.();
157
+ };
158
+
159
+ return [
160
+ {
161
+ kind: 'tool',
162
+ name: 'manage.list',
163
+ title: 'List skills',
164
+ description:
165
+ 'Every skill that exists, with its description and arguments. The prompt list shows only the ones policy permits; this shows what is stored.',
166
+ inputSchema: z.object({}),
167
+ async handler(_input, context) {
168
+ const skills = await loadProfileSkills(store);
169
+
170
+ if (skills.length === 0) {
171
+ return { content: [{ type: 'text', text: `No skills on ${context.connection.key}.` }] };
172
+ }
173
+
174
+ return {
175
+ content: [
176
+ {
177
+ type: 'text',
178
+ text: skills
179
+ .map((skill) => {
180
+ const args = skill.arguments
181
+ .map((argument) => (argument.required ? argument.name : `${argument.name}?`))
182
+ .join(', ');
183
+ return `${skill.name}${args ? `(${args})` : ''} — ${skill.description}`;
184
+ })
185
+ .join('\n'),
186
+ },
187
+ ],
188
+ };
189
+ },
190
+ },
191
+
192
+ {
193
+ kind: 'tool',
194
+ name: 'manage.get',
195
+ title: 'Read a skill',
196
+ description:
197
+ 'Return a skill exactly as stored, frontmatter included — what to edit before writing it back.',
198
+ inputSchema: z.object({
199
+ name: z.string().min(1).describe('Skill name'),
200
+ }),
201
+ redact: keepKeys('name'),
202
+ async handler({ name }, context) {
203
+ const skill = await readSkill(store, name);
204
+
205
+ if (skill === null) {
206
+ return {
207
+ content: [{ type: 'text', text: `No skill "${name}" on ${context.connection.key}.` }],
208
+ isError: true,
209
+ };
210
+ }
211
+
212
+ // The stored document, not the parsed body: an edit-then-write round
213
+ // trip has to carry the frontmatter with it.
214
+ const bytes = await store.get(skill.path);
215
+ return {
216
+ content: [
217
+ { type: 'text', text: bytes ? new TextDecoder().decode(bytes) : skill.body },
218
+ ],
219
+ };
220
+ },
221
+ },
222
+
223
+ {
224
+ kind: 'tool',
225
+ name: 'manage.write',
226
+ title: 'Write a skill',
227
+ description:
228
+ 'Create or replace a skill. The text is a whole Markdown document: YAML frontmatter carrying "description" and optional "arguments", then the procedure body, where {{argument}} is substituted at invocation. A skill written here is instructions an agent is later handed as its own turn — this is deliberately a separate capability from invoking one.',
229
+ inputSchema: z.object({
230
+ name: z
231
+ .string()
232
+ .min(1)
233
+ .describe('Skill name: lowercase letters, digits, "_" or "-". Becomes skills.<name>.'),
234
+ text: z.string().min(1).describe('The whole document, frontmatter included'),
235
+ }),
236
+ // The name is an address and is worth recording; the text is the
237
+ // instructions themselves, which are the content rather than the subject.
238
+ redact: keepKeys('name'),
239
+ async handler({ name, text }, context) {
240
+ const skill = await writeSkill(store, name, text);
241
+ await changed();
242
+
243
+ context.audit.annotate({ skill: name, bytes: new TextEncoder().encode(text).byteLength });
244
+
245
+ return {
246
+ content: [
247
+ {
248
+ type: 'text',
249
+ text: `Stored skill "${skill.name}" on ${context.connection.key}. It is available as the prompt "skills_${skill.name}" where policy allows it.`,
250
+ },
251
+ ],
252
+ };
253
+ },
254
+ },
255
+
256
+ {
257
+ kind: 'tool',
258
+ name: 'manage.remove',
259
+ title: 'Delete a skill',
260
+ description: 'Remove a skill and the prompt it provided.',
261
+ inputSchema: z.object({
262
+ name: z.string().min(1).describe('Skill name'),
263
+ }),
264
+ redact: keepKeys('name'),
265
+ async handler({ name }, context) {
266
+ const removed = await removeSkill(store, name);
267
+ if (removed) await changed();
268
+
269
+ return {
270
+ content: [
271
+ {
272
+ type: 'text',
273
+ text: removed
274
+ ? `Removed skill "${name}" from ${context.connection.key}.`
275
+ : `No skill "${name}" on ${context.connection.key}.`,
276
+ },
277
+ ],
278
+ ...(removed ? {} : { isError: true }),
279
+ };
280
+ },
281
+ },
282
+ ];
283
+ }
@@ -0,0 +1,252 @@
1
+ import type { BlobStore } from '#stores/blobs';
2
+ import { splitFrontmatter } from '#providers/shared/frontmatter.ts';
3
+ import { ConfigError } from '#profile';
4
+
5
+ /**
6
+ * Skills the owner has written.
7
+ *
8
+ * A skill is a **document in the owner layer's store**, which locally is a file
9
+ * in `<workspace>/data/<profile>/skills.d/`, in the same format it has always
10
+ * had. Going through `BlobStore` rather than `node:fs` is what lets a deployed
11
+ * instance have skills at all: a filesystem path is baked into a container
12
+ * image at build time, and an S3 key is not.
13
+ *
14
+ * **One profile's skills, not the workspace's.** Nothing in this file knows
15
+ * that — the store it is handed is already rooted at one profile's directory,
16
+ * which is why moving skills under the profile (ADR-030) changed where the
17
+ * store is opened and nothing about how it is read.
18
+ *
19
+ * Two layouts, because both are conventional and neither is worth refusing:
20
+ *
21
+ * skills.d/review-diff.md
22
+ * skills.d/review-diff/SKILL.md
23
+ *
24
+ * Frontmatter is YAML between `---` fences, matching every other tool that
25
+ * reads a skill file. `description` is the only required key; the body after
26
+ * the fence is the prompt.
27
+ *
28
+ * **A skill can now be written, which ADR-012 §1 refused and ADR-014 reverses.**
29
+ * The reversal is about the *write path existing*, not about who may use it:
30
+ * authoring stays out of the default bundle, so an agent reaches it only where
31
+ * policy says so, and the control plane reaches it always.
32
+ */
33
+
34
+ /** The nested layout's filename, kept so a rewrite lands on the file it read. */
35
+ const NESTED = 'SKILL.md';
36
+
37
+ export interface SkillArgument {
38
+ readonly name: string;
39
+ readonly description: string;
40
+ readonly required?: boolean;
41
+ }
42
+
43
+ export interface LoadedSkill {
44
+ /** Becomes the capability name, so `skills.<name>` is what policy grants. */
45
+ readonly name: string;
46
+ readonly title?: string;
47
+ readonly description: string;
48
+ readonly arguments: readonly SkillArgument[];
49
+ readonly body: string;
50
+ /** The store key it was read from — `review-diff.md` or `review-diff/SKILL.md`. */
51
+ readonly path: string;
52
+ }
53
+
54
+ /**
55
+ * Capability names reach policy rules and MCP wire names, and both are stricter
56
+ * than a filename. Refused rather than slugified: a skill silently renamed is a
57
+ * policy rule that silently stops matching.
58
+ */
59
+ const SKILL_NAME = /^[a-z0-9][a-z0-9_-]*$/;
60
+
61
+ export function assertSkillName(name: string, source = 'skill'): void {
62
+ if (!SKILL_NAME.test(name)) {
63
+ throw new ConfigError(
64
+ `${source}: skill name ${JSON.stringify(name)} must be lowercase letters, digits, "_" or "-" — ` +
65
+ 'it becomes the capability id "skills.<name>", which policy rules and MCP names both constrain.',
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Every skill in the store.
72
+ *
73
+ * A key that is not a skill is skipped rather than refused — a skill directory
74
+ * legitimately holds references and scripts beside its `SKILL.md`, and one
75
+ * unparseable file must not hide every other skill behind it. A file that *is*
76
+ * a skill and is malformed still throws, because that one is a mistake the
77
+ * owner wants to hear about.
78
+ */
79
+ export async function loadProfileSkills(store: BlobStore): Promise<LoadedSkill[]> {
80
+ const keys = (await store.list()).map((blob) => blob.key).sort();
81
+ const loaded: LoadedSkill[] = [];
82
+
83
+ for (const key of keys) {
84
+ const name = skillNameFor(key);
85
+ if (name === null) continue;
86
+
87
+ const bytes = await store.get(key);
88
+ if (bytes === null) continue; // Listed then deleted; not worth failing over.
89
+
90
+ loaded.push(parseSkill(new TextDecoder().decode(bytes), key, name));
91
+ }
92
+
93
+ return loaded;
94
+ }
95
+
96
+ /** Read one skill by name, or null when there is none. */
97
+ export async function readSkill(store: BlobStore, name: string): Promise<LoadedSkill | null> {
98
+ const key = await skillKey(store, name);
99
+ if (key === null) return null;
100
+
101
+ const bytes = await store.get(key);
102
+ if (bytes === null) return null;
103
+
104
+ return parseSkill(new TextDecoder().decode(bytes), key, name);
105
+ }
106
+
107
+ /**
108
+ * Create or replace a skill, validating it before it is stored.
109
+ *
110
+ * Parsed first so a malformed document is refused rather than persisted: a
111
+ * skill that fails to load is invisible until the next start, at which point
112
+ * the reason it broke is a long way from the write that broke it.
113
+ *
114
+ * An existing skill is rewritten **in the layout it already has**, so editing a
115
+ * `review-diff/SKILL.md` does not silently leave a second `review-diff.md`
116
+ * beside it — two files claiming one capability id, which the provider refuses
117
+ * to build at all.
118
+ *
119
+ * A *new* one is written as `<name>/SKILL.md`, which is the shape `~/.claude`
120
+ * uses and the shape the skill bundled with this repository already has. The
121
+ * flat `<name>.md` still loads and is still rewritten in place; the directory is
122
+ * the better default because a skill that grows a reference or a script has
123
+ * somewhere to put it without moving first.
124
+ */
125
+ export async function writeSkill(
126
+ store: BlobStore,
127
+ name: string,
128
+ text: string,
129
+ ): Promise<LoadedSkill> {
130
+ assertSkillName(name);
131
+
132
+ const key = (await skillKey(store, name)) ?? `${name}/${NESTED}`;
133
+ const skill = parseSkill(text, key, name);
134
+
135
+ if (skill.name !== name) {
136
+ throw new ConfigError(
137
+ `${key}: the frontmatter names this skill "${skill.name}", but it is being written as "${name}". ` +
138
+ 'Remove the "name" key to take it from the filename, or write it under the name it declares.',
139
+ );
140
+ }
141
+
142
+ // No contentType: on the filesystem adapter that writes a `<key>.meta`
143
+ // sidecar, and `skills.d/` is a directory the owner opens and edits by hand.
144
+ // Nothing reads a skill's stored content type.
145
+ await store.put(key, new TextEncoder().encode(text));
146
+ return skill;
147
+ }
148
+
149
+ /** Delete a skill in whichever layout holds it. Returns false when absent. */
150
+ export async function removeSkill(store: BlobStore, name: string): Promise<boolean> {
151
+ const key = await skillKey(store, name);
152
+ if (key === null) return false;
153
+
154
+ await store.delete(key);
155
+ return true;
156
+ }
157
+
158
+ /** Where a skill of this name lives today, in either layout. */
159
+ async function skillKey(store: BlobStore, name: string): Promise<string | null> {
160
+ assertSkillName(name);
161
+
162
+ const flat = `${name}.md`;
163
+ if (await store.has(flat)) return flat;
164
+
165
+ const nested = `${name}/${NESTED}`;
166
+ return (await store.has(nested)) ? nested : null;
167
+ }
168
+
169
+ /**
170
+ * The skill name a store key implies, or null when the key is not a skill.
171
+ *
172
+ * `review-diff.md` and `review-diff/SKILL.md` are skills. A file nested any
173
+ * deeper, or named anything else inside a skill's own directory, belongs to
174
+ * that skill and is not one itself.
175
+ */
176
+ function skillNameFor(key: string): string | null {
177
+ const segments = key.split('/');
178
+
179
+ if (segments.length === 1) {
180
+ const [file] = segments as [string];
181
+ return file.endsWith('.md') ? file.slice(0, -'.md'.length) : null;
182
+ }
183
+
184
+ if (segments.length === 2 && segments[1] === NESTED) return segments[0]!;
185
+ return null;
186
+ }
187
+
188
+ export function parseSkill(text: string, source: string, fallbackName: string): LoadedSkill {
189
+ const { frontmatter, body } = splitFrontmatter(text, source);
190
+
191
+ const name = typeof frontmatter['name'] === 'string' ? frontmatter['name'] : fallbackName;
192
+ assertSkillName(name, source);
193
+
194
+ const description = frontmatter['description'];
195
+ if (typeof description !== 'string' || description.trim().length === 0) {
196
+ throw new ConfigError(
197
+ `${source}: a skill needs a "description" in its frontmatter. It is the only thing an agent ` +
198
+ 'sees when deciding whether this skill applies.',
199
+ );
200
+ }
201
+
202
+ if (body.trim().length === 0) {
203
+ throw new ConfigError(`${source}: the skill has no body, so there is no procedure to render.`);
204
+ }
205
+
206
+ return {
207
+ name,
208
+ ...(typeof frontmatter['title'] === 'string' ? { title: frontmatter['title'] } : {}),
209
+ description,
210
+ arguments: parseArguments(frontmatter['arguments'], source),
211
+ body,
212
+ path: source,
213
+ };
214
+ }
215
+
216
+ function parseArguments(raw: unknown, source: string): SkillArgument[] {
217
+ if (raw === undefined || raw === null) return [];
218
+ if (!Array.isArray(raw)) {
219
+ throw new ConfigError(`${source}: "arguments" must be a list.`);
220
+ }
221
+
222
+ return raw.map((entry, index) => {
223
+ const record = (entry ?? {}) as Record<string, unknown>;
224
+ const name = record['name'];
225
+
226
+ if (typeof name !== 'string' || !SKILL_NAME.test(name)) {
227
+ throw new ConfigError(
228
+ `${source}: arguments[${index}] needs a lowercase "name" — it becomes a prompt argument.`,
229
+ );
230
+ }
231
+
232
+ return {
233
+ name,
234
+ description: typeof record['description'] === 'string' ? record['description'] : name,
235
+ ...(record['required'] === true ? { required: true } : {}),
236
+ };
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Substitute `{{argument}}` in a skill body.
242
+ *
243
+ * An argument the caller omitted leaves its placeholder in place rather than
244
+ * becoming an empty string: a procedure that reads "review the diff below" with
245
+ * nothing below it is a worse failure than one that visibly names what is
246
+ * missing.
247
+ */
248
+ export function renderSkill(body: string, args: Readonly<Record<string, string>>): string {
249
+ return body.replace(/\{\{\s*([a-z0-9_-]+)\s*\}\}/gi, (placeholder, name: string) =>
250
+ typeof args[name] === 'string' ? args[name]! : placeholder,
251
+ );
252
+ }