@mccaskie-labs/workbench-config 0.1.35 → 0.1.37

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.37
4
+
5
+ ### Patch Changes
6
+
7
+ - Every tool reaches a client's Claude Code (docs/capabilities-manifest.md §9.6). Manifests gain `delivery`: whether a tool is built in or optional, one sentence that introduces it to the owner, and when it applies if it has no questions yet. `workbench-config skills` now writes a skill for every component with a `delivery`, not only those with an `offer`, with the component's guide under "How to use it", plus `workbench-start`, which introduces the tools and records the owner's choices of optional ones in `workbench/tools.json`. New: `workbench-config status`, which tells Claude Code at the start of a session what is undecided, unfinished or out of date, and `toolsState`. A project must run `npx workbench-config skills` after this update.
8
+
9
+ ## 0.1.36
10
+
11
+ ### Patch Changes
12
+
13
+ - A manifest may say `styles: { prefix }` for a component that draws on a project's pages. Its generated skill then has a "Restyling" section: every class and variable is listed in `public/styles/workbench.css`, and the project restyles them in `public/styles/project.css`. The forms manifest has `styles: { prefix: "wb-form-" }`.
14
+
3
15
  ## 0.1.35
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -34,4 +34,8 @@ const { WORKBENCH_GATEWAY_KEY } = config.parse(env)
34
34
 
35
35
  `npx workbench-config check [dir]` compares the variable names in `.dev.vars` with `.dev.vars.example` and prints names only.
36
36
 
37
+ `npx workbench-config skills [--check] [dir]` writes a project's `.claude/skills`: one skill for each Workbench tool a client's Claude Code works with, and `workbench-start`, which introduces them to the owner. `--check` fails when a file differs from what the installed version writes.
38
+
39
+ `npx workbench-config status [dir]` prints what Claude Code needs to know at the start of a session: optional tools the owner hasn't decided on (`workbench/tools.json`), answers files still a draft, and skills that are out of date. It prints nothing when all is settled and always exits 0, so it can run from a `SessionStart` hook.
40
+
37
41
  Runs on Node 24 and the Cloudflare Workers runtime. `zod` 4 is a peer dependency.
@@ -5,6 +5,11 @@
5
5
  "title": "Claude through the Gateway",
6
6
  "summary": "Text, structured output and streaming from Claude, metered against the project's budget. Apps never hold an Anthropic key.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "Writing, summarising and answering questions with Claude, with a monthly spending cap.",
11
+ "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use or as something made once for the site."
12
+ },
8
13
  "surfaces": {
9
14
  "gateway": ["POST /anthropic/v1/messages", "POST /anthropic/v1/messages/count_tokens"],
10
15
  "package": ["createAi", "Ai.text", "Ai.structured", "Ai.stream", "Ai.countTokens", "Ai.budget", "createFakeAi", "checkWriting", "writingStops", "writingNotes", "writingFixNote"]
@@ -5,6 +5,11 @@
5
5
  "title": "Page views and product events",
6
6
  "summary": "Cloudflare Web Analytics counts page views through a beacon on every page; product events go into the app's own database through workbench-analytics. Apps hold no Cloudflare token: Claude reads page views through Control.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "optional",
10
+ "intro": "Counting page views and the things visitors do on the site.",
11
+ "when": "The client wants to know how many people visit, which pages they open, or to count an action such as a sign-up or an order."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "beaconScript",
@@ -5,6 +5,11 @@
5
5
  "title": "Country packs and the one formatter",
6
6
  "summary": "workbench-country writes dates, times, numbers and money the way readers in the Philippines, Australia, the UK and the US expect, from one pack per country, and gives each pack's spelling for the Gateway. It runs in the app itself, with no Gateway call and no cost.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "optional",
10
+ "intro": "Dates, times, numbers and money written the way readers in your country expect.",
11
+ "when": "The app shows a date, a time, a number, a phone number or an amount of money to its readers, or the client says where their readers are."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "COUNTRY_PACKS",
@@ -5,6 +5,11 @@
5
5
  "title": "The app's own Supabase database",
6
6
  "summary": "Each project has its own Supabase projects, <id>-staging and <id>-production. Apps use them natively through workbench-supabase; Control creates them, writes their URL and keys onto the Workers, and applies migrations read from the repo.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "The app's own database, one for testing and one for the live site.",
11
+ "when": "The app needs to store or read data, or the client asks for a change to what is stored: a new table, column, rule or migration."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "supabaseConfig",
@@ -5,6 +5,11 @@
5
5
  "title": "Inbound email to the project's own Worker",
6
6
  "summary": "Email Routing receives mail on a domain in the project's namespace and runs one routing rule per address, each sending the mail to one of the project's own Workers. The Worker's email() handler parses it with postal-mime and the app stores or rejects it. Mail is never forwarded outside the project.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "optional",
10
+ "intro": "Receiving email at the project's own addresses, for the app to act on.",
11
+ "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles."
12
+ },
8
13
  "surfaces": {
9
14
  "tools": [
10
15
  "cf_email_routing_enable",
@@ -5,6 +5,11 @@
5
5
  "title": "Transactional email through the Gateway",
6
6
  "summary": "Apps send transactional mail with workbench-email, or POST /v1/email/send directly. The Gateway checks the sender and recipients, keeps idempotency keys for 24 hours, meters every send, and hands the message to Cloudflare Email Sending. Apps hold no email credential.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes.",
11
+ "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code."
12
+ },
8
13
  "surfaces": {
9
14
  "gateway": ["POST /v1/email/send"],
10
15
  "package": ["createMailer", "selectTransport", "Mailer.send", "captureSink", "WorkbenchEmailError"]
@@ -5,6 +5,10 @@
5
5
  "title": "Form validation",
6
6
  "summary": "workbench-forms checks what people type into a form with one validator that runs the same on the page and at the endpoint. It returns every problem at once as permanent codes, the tidied value to show back in each field and the value to store, with limits from the app's own register and country shapes from workbench-country. It runs in the app itself, with no Gateway call and no cost.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "Forms people fill in, checked the same way on the page and when they are sent."
11
+ },
8
12
  "surfaces": {
9
13
  "package": [
10
14
  "defineForm",
@@ -327,5 +331,8 @@
327
331
  "why": "The client's own words for an answered limit, otherwise \"Workbench default, not yet confirmed\"."
328
332
  }
329
333
  ]
334
+ },
335
+ "styles": {
336
+ "prefix": "wb-form-"
330
337
  }
331
338
  }
@@ -5,6 +5,11 @@
5
5
  "title": "Image search through the Gateway",
6
6
  "summary": "Web image search through Serper, normalised to one result shape and metered against the project's budget. Apps never hold a Serper key.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "Finding existing pictures on the web.",
11
+ "when": "The client wants to find existing pictures on the web, for the site itself or as a search visitors use."
12
+ },
8
13
  "surfaces": {
9
14
  "gateway": [
10
15
  "POST /v1/images/search"
@@ -5,6 +5,11 @@
5
5
  "title": "Image generation through the Gateway",
6
6
  "summary": "One image per call from Gemini or OpenAI, chosen by a Workbench alias and metered against the project's budget. Apps never hold a Google or OpenAI key, and the Gateway never falls back to another provider.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "Pictures made by AI from a description.",
11
+ "when": "The client wants a picture made by AI, such as an illustration, a hero image or a product mock-up, as a feature visitors use or once for the site."
12
+ },
8
13
  "surfaces": {
9
14
  "gateway": [
10
15
  "POST /v1/images/generate"
@@ -5,6 +5,11 @@
5
5
  "title": "Branded PDFs",
6
6
  "summary": "workbench-pdf prints an app's content as a PDF with its brand.json logo, colours and fonts, in the app's own Worker through its Browser Rendering binding (BROWSER). Nothing goes through the Gateway and the app holds no Cloudflare token. The standard layout takes plain-text blocks; html() takes the app's own markup.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "optional",
10
+ "intro": "PDFs such as invoices and receipts, printed with the project's logo and colours.",
11
+ "when": "The client wants a PDF: an invoice, receipt, quote, certificate, report or anything else to download or print."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "createPdfMaker",
@@ -685,6 +685,45 @@
685
685
  "no_offer": {
686
686
  "type": "string",
687
687
  "minLength": 1
688
+ },
689
+ "styles": {
690
+ "type": "object",
691
+ "properties": {
692
+ "prefix": {
693
+ "type": "string",
694
+ "pattern": "^wb-[a-z]+-$"
695
+ }
696
+ },
697
+ "required": [
698
+ "prefix"
699
+ ],
700
+ "additionalProperties": false
701
+ },
702
+ "delivery": {
703
+ "type": "object",
704
+ "properties": {
705
+ "choice": {
706
+ "type": "string",
707
+ "enum": [
708
+ "built_in",
709
+ "optional"
710
+ ]
711
+ },
712
+ "intro": {
713
+ "type": "string",
714
+ "minLength": 1,
715
+ "maxLength": 160
716
+ },
717
+ "when": {
718
+ "type": "string",
719
+ "minLength": 1
720
+ }
721
+ },
722
+ "required": [
723
+ "choice",
724
+ "intro"
725
+ ],
726
+ "additionalProperties": false
688
727
  }
689
728
  },
690
729
  "required": [
@@ -5,6 +5,11 @@
5
5
  "title": "Words on screens and in emails",
6
6
  "summary": "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "built_in",
10
+ "intro": "The words on the app's screens and in its emails, kept in one place for each language.",
11
+ "when": "The client wants to add or change wording on a page or in an email, or asks about showing the app in another language."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "defineStrings",
@@ -5,6 +5,11 @@
5
5
  "title": "One look for every page",
6
6
  "summary": "workbench-ui gives every Workbench page the same dark look: the page shell, a top bar with the environment tag, cards that fold, jump pills, outcome pills, and one way to write times, money and durations. It returns HTML text in the app's own Worker, with no Gateway call and no cost.",
7
7
  "status": "built",
8
+ "delivery": {
9
+ "choice": "optional",
10
+ "intro": "A ready-made look for the pages only you and your admins see.",
11
+ "when": "The client wants a page only the owner or the app's admins see: a dashboard, a list, a settings page or a status page."
12
+ },
8
13
  "surfaces": {
9
14
  "package": [
10
15
  "shell",
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "d5366f2127fb";
1
+ export declare const MANIFEST_VERSION: "8e65a2fda0b3";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -194,6 +194,11 @@ export declare const capabilities: {
194
194
  readonly expect: "pass";
195
195
  readonly cost_hint_usd: 0.008;
196
196
  }];
197
+ readonly delivery: {
198
+ readonly choice: "built_in";
199
+ readonly intro: "Writing, summarising and answering questions with Claude, with a monthly spending cap.";
200
+ readonly when: "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use or as something made once for the site.";
201
+ };
197
202
  };
198
203
  readonly analytics: {
199
204
  readonly $schema: "./schema.json";
@@ -266,6 +271,11 @@ export declare const capabilities: {
266
271
  readonly cost_hint_usd: 0;
267
272
  readonly note: "Create the site, drive page views, query them back (cf_analytics_query, later)";
268
273
  }];
274
+ readonly delivery: {
275
+ readonly choice: "optional";
276
+ readonly intro: "Counting page views and the things visitors do on the site.";
277
+ readonly when: "The client wants to know how many people visit, which pages they open, or to count an action such as a sign-up or an order.";
278
+ };
269
279
  };
270
280
  readonly config: {
271
281
  readonly $schema: "./schema.json";
@@ -413,6 +423,11 @@ export declare const capabilities: {
413
423
  readonly cost_hint_usd: 0;
414
424
  readonly note: "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call";
415
425
  }];
426
+ readonly delivery: {
427
+ readonly choice: "optional";
428
+ readonly intro: "Dates, times, numbers and money written the way readers in your country expect.";
429
+ readonly when: "The app shows a date, a time, a number, a phone number or an amount of money to its readers, or the client says where their readers are.";
430
+ };
416
431
  };
417
432
  readonly database: {
418
433
  readonly $schema: "./schema.json";
@@ -634,6 +649,11 @@ export declare const capabilities: {
634
649
  readonly cost_hint_usd: 0;
635
650
  readonly note: "On a project's staging: cf_turnstile_widget_create and the turnstile_sitekey and turnstile_secret sources; the sign-in page shows the check, /auth/code without a passed token sends no code, and with one it does";
636
651
  }];
652
+ readonly delivery: {
653
+ readonly choice: "built_in";
654
+ readonly intro: "The app's own database, one for testing and one for the live site.";
655
+ readonly when: "The app needs to store or read data, or the client asks for a change to what is stored: a new table, column, rule or migration.";
656
+ };
637
657
  };
638
658
  readonly email: {
639
659
  readonly $schema: "./schema.json";
@@ -877,6 +897,11 @@ export declare const capabilities: {
877
897
  readonly cost_hint_usd: 0;
878
898
  readonly note: "GET brand.json's logo.url from outside with no sign-in answers 200 with an image content type";
879
899
  }];
900
+ readonly delivery: {
901
+ readonly choice: "built_in";
902
+ readonly intro: "The emails the app sends, such as confirmations, receipts and sign-in codes.";
903
+ readonly when: "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code.";
904
+ };
880
905
  };
881
906
  readonly "email-inbound": {
882
907
  readonly $schema: "./schema.json";
@@ -978,6 +1003,11 @@ export declare const capabilities: {
978
1003
  readonly two_step: true;
979
1004
  readonly note: "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves";
980
1005
  }];
1006
+ readonly delivery: {
1007
+ readonly choice: "optional";
1008
+ readonly intro: "Receiving email at the project's own addresses, for the app to act on.";
1009
+ readonly when: "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles.";
1010
+ };
981
1011
  };
982
1012
  readonly "email-sending": {
983
1013
  readonly $schema: "./schema.json";
@@ -1316,6 +1346,13 @@ export declare const capabilities: {
1316
1346
  readonly why: "The client's own words for an answered limit, otherwise \"Workbench default, not yet confirmed\".";
1317
1347
  }];
1318
1348
  };
1349
+ readonly styles: {
1350
+ readonly prefix: "wb-form-";
1351
+ };
1352
+ readonly delivery: {
1353
+ readonly choice: "built_in";
1354
+ readonly intro: "Forms people fill in, checked the same way on the page and when they are sent.";
1355
+ };
1319
1356
  };
1320
1357
  readonly gateway: {
1321
1358
  readonly $schema: "./schema.json";
@@ -1649,6 +1686,11 @@ export declare const capabilities: {
1649
1686
  readonly smoke: true;
1650
1687
  readonly cost_hint_usd: 0.001;
1651
1688
  }];
1689
+ readonly delivery: {
1690
+ readonly choice: "built_in";
1691
+ readonly intro: "Finding existing pictures on the web.";
1692
+ readonly when: "The client wants to find existing pictures on the web, for the site itself or as a search visitors use.";
1693
+ };
1652
1694
  };
1653
1695
  readonly images: {
1654
1696
  readonly $schema: "./schema.json";
@@ -1801,6 +1843,11 @@ export declare const capabilities: {
1801
1843
  readonly refusal: "invalid_request";
1802
1844
  readonly cost_hint_usd: 0;
1803
1845
  }];
1846
+ readonly delivery: {
1847
+ readonly choice: "built_in";
1848
+ readonly intro: "Pictures made by AI from a description.";
1849
+ readonly when: "The client wants a picture made by AI, such as an illustration, a hero image or a product mock-up, as a feature visitors use or once for the site.";
1850
+ };
1804
1851
  };
1805
1852
  readonly pdf: {
1806
1853
  readonly $schema: "./schema.json";
@@ -1877,6 +1924,11 @@ export declare const capabilities: {
1877
1924
  readonly cost_hint_usd: 0;
1878
1925
  readonly note: "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked";
1879
1926
  }];
1927
+ readonly delivery: {
1928
+ readonly choice: "optional";
1929
+ readonly intro: "PDFs such as invoices and receipts, printed with the project's logo and colours.";
1930
+ readonly when: "The client wants a PDF: an invoice, receipt, quote, certificate, report or anything else to download or print.";
1931
+ };
1880
1932
  };
1881
1933
  readonly strings: {
1882
1934
  readonly $schema: "./schema.json";
@@ -1984,6 +2036,11 @@ export declare const capabilities: {
1984
2036
  readonly cost_hint_usd: 0;
1985
2037
  readonly note: "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI";
1986
2038
  }];
2039
+ readonly delivery: {
2040
+ readonly choice: "built_in";
2041
+ readonly intro: "The words on the app's screens and in its emails, kept in one place for each language.";
2042
+ readonly when: "The client wants to add or change wording on a page or in an email, or asks about showing the app in another language.";
2043
+ };
1987
2044
  };
1988
2045
  readonly ui: {
1989
2046
  readonly $schema: "./schema.json";
@@ -2048,6 +2105,11 @@ export declare const capabilities: {
2048
2105
  readonly cost_hint_usd: 0;
2049
2106
  readonly note: "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it";
2050
2107
  }];
2108
+ readonly delivery: {
2109
+ readonly choice: "optional";
2110
+ readonly intro: "A ready-made look for the pages only you and your admins see.";
2111
+ readonly when: "The client wants a page only the owner or the app's admins see: a dashboard, a list, a settings page or a status page.";
2112
+ };
2051
2113
  };
2052
2114
  };
2053
2115
  export type Alias = (typeof capabilities)["ai"]["allowed"]["aliases"][number]["alias"];
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "d5366f2127fb";
3
+ export const MANIFEST_VERSION = "8e65a2fda0b3";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -293,7 +293,12 @@ export const capabilities = {
293
293
  "expect": "pass",
294
294
  "cost_hint_usd": 0.008
295
295
  }
296
- ]
296
+ ],
297
+ "delivery": {
298
+ "choice": "built_in",
299
+ "intro": "Writing, summarising and answering questions with Claude, with a monthly spending cap.",
300
+ "when": "The client wants the app to write, summarise, sort, answer questions or pull details out of text with AI, as a feature visitors use or as something made once for the site."
301
+ }
297
302
  },
298
303
  "analytics": {
299
304
  "$schema": "./schema.json",
@@ -391,7 +396,12 @@ export const capabilities = {
391
396
  "cost_hint_usd": 0,
392
397
  "note": "Create the site, drive page views, query them back (cf_analytics_query, later)"
393
398
  }
394
- ]
399
+ ],
400
+ "delivery": {
401
+ "choice": "optional",
402
+ "intro": "Counting page views and the things visitors do on the site.",
403
+ "when": "The client wants to know how many people visit, which pages they open, or to count an action such as a sign-up or an order."
404
+ }
395
405
  },
396
406
  "config": {
397
407
  "$schema": "./schema.json",
@@ -605,7 +615,12 @@ export const capabilities = {
605
615
  "cost_hint_usd": 0,
606
616
  "note": "Every pack's spelling is one the Gateway accepts (en-PH, en-AU, en-GB, en-US), checked against the ai manifest's spellings; no Gateway call"
607
617
  }
608
- ]
618
+ ],
619
+ "delivery": {
620
+ "choice": "optional",
621
+ "intro": "Dates, times, numbers and money written the way readers in your country expect.",
622
+ "when": "The app shows a date, a time, a number, a phone number or an amount of money to its readers, or the client says where their readers are."
623
+ }
609
624
  },
610
625
  "database": {
611
626
  "$schema": "./schema.json",
@@ -910,7 +925,12 @@ export const capabilities = {
910
925
  "cost_hint_usd": 0,
911
926
  "note": "On a project's staging: cf_turnstile_widget_create and the turnstile_sitekey and turnstile_secret sources; the sign-in page shows the check, /auth/code without a passed token sends no code, and with one it does"
912
927
  }
913
- ]
928
+ ],
929
+ "delivery": {
930
+ "choice": "built_in",
931
+ "intro": "The app's own database, one for testing and one for the live site.",
932
+ "when": "The app needs to store or read data, or the client asks for a change to what is stored: a new table, column, rule or migration."
933
+ }
914
934
  },
915
935
  "email": {
916
936
  "$schema": "./schema.json",
@@ -1213,7 +1233,12 @@ export const capabilities = {
1213
1233
  "cost_hint_usd": 0,
1214
1234
  "note": "GET brand.json's logo.url from outside with no sign-in answers 200 with an image content type"
1215
1235
  }
1216
- ]
1236
+ ],
1237
+ "delivery": {
1238
+ "choice": "built_in",
1239
+ "intro": "The emails the app sends, such as confirmations, receipts and sign-in codes.",
1240
+ "when": "The app needs to send an email: a confirmation, receipt, notification, invitation or sign-in code."
1241
+ }
1217
1242
  },
1218
1243
  "email-inbound": {
1219
1244
  "$schema": "./schema.json",
@@ -1341,7 +1366,12 @@ export const capabilities = {
1341
1366
  "two_step": true,
1342
1367
  "note": "cf_email_routing_rule_delete returns a confirmation and changes nothing until Robert approves"
1343
1368
  }
1344
- ]
1369
+ ],
1370
+ "delivery": {
1371
+ "choice": "optional",
1372
+ "intro": "Receiving email at the project's own addresses, for the app to act on.",
1373
+ "when": "The client wants the app to receive email: replies to its messages, or an address such as hello@ that the app handles."
1374
+ }
1345
1375
  },
1346
1376
  "email-sending": {
1347
1377
  "$schema": "./schema.json",
@@ -1806,6 +1836,13 @@ export const capabilities = {
1806
1836
  "why": "The client's own words for an answered limit, otherwise \"Workbench default, not yet confirmed\"."
1807
1837
  }
1808
1838
  ]
1839
+ },
1840
+ "styles": {
1841
+ "prefix": "wb-form-"
1842
+ },
1843
+ "delivery": {
1844
+ "choice": "built_in",
1845
+ "intro": "Forms people fill in, checked the same way on the page and when they are sent."
1809
1846
  }
1810
1847
  },
1811
1848
  "gateway": {
@@ -2250,7 +2287,12 @@ export const capabilities = {
2250
2287
  "smoke": true,
2251
2288
  "cost_hint_usd": 0.001
2252
2289
  }
2253
- ]
2290
+ ],
2291
+ "delivery": {
2292
+ "choice": "built_in",
2293
+ "intro": "Finding existing pictures on the web.",
2294
+ "when": "The client wants to find existing pictures on the web, for the site itself or as a search visitors use."
2295
+ }
2254
2296
  },
2255
2297
  "images": {
2256
2298
  "$schema": "./schema.json",
@@ -2458,7 +2500,12 @@ export const capabilities = {
2458
2500
  "refusal": "invalid_request",
2459
2501
  "cost_hint_usd": 0
2460
2502
  }
2461
- ]
2503
+ ],
2504
+ "delivery": {
2505
+ "choice": "built_in",
2506
+ "intro": "Pictures made by AI from a description.",
2507
+ "when": "The client wants a picture made by AI, such as an illustration, a hero image or a product mock-up, as a feature visitors use or once for the site."
2508
+ }
2462
2509
  },
2463
2510
  "pdf": {
2464
2511
  "$schema": "./schema.json",
@@ -2551,7 +2598,12 @@ export const capabilities = {
2551
2598
  "cost_hint_usd": 0,
2552
2599
  "note": "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked"
2553
2600
  }
2554
- ]
2601
+ ],
2602
+ "delivery": {
2603
+ "choice": "optional",
2604
+ "intro": "PDFs such as invoices and receipts, printed with the project's logo and colours.",
2605
+ "when": "The client wants a PDF: an invoice, receipt, quote, certificate, report or anything else to download or print."
2606
+ }
2555
2607
  },
2556
2608
  "strings": {
2557
2609
  "$schema": "./schema.json",
@@ -2682,7 +2734,12 @@ export const capabilities = {
2682
2734
  "cost_hint_usd": 0,
2683
2735
  "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
2684
2736
  }
2685
- ]
2737
+ ],
2738
+ "delivery": {
2739
+ "choice": "built_in",
2740
+ "intro": "The words on the app's screens and in its emails, kept in one place for each language.",
2741
+ "when": "The client wants to add or change wording on a page or in an email, or asks about showing the app in another language."
2742
+ }
2686
2743
  },
2687
2744
  "ui": {
2688
2745
  "$schema": "./schema.json",
@@ -2773,6 +2830,11 @@ export const capabilities = {
2773
2830
  "cost_hint_usd": 0,
2774
2831
  "note": "In the Worker itself: compares money (US$0.0042, US$12.50, and an en dash for nothing), duration (640 ms, 2.35 s, 3 min 5 s) and timeLeft against a fixed now (42 minutes left) with the expected text, and checks when and shortWhen write one fixed moment as its Manila day and hour with UTC beside it"
2775
2832
  }
2776
- ]
2833
+ ],
2834
+ "delivery": {
2835
+ "choice": "optional",
2836
+ "intro": "A ready-made look for the pages only you and your admins see.",
2837
+ "when": "The client wants a page only the owner or the app's admins see: a dashboard, a list, a settings page or a status page."
2838
+ }
2777
2839
  }
2778
2840
  };
@@ -356,6 +356,17 @@ export declare const capabilitySchema: z.ZodObject<{
356
356
  needs: z.ZodOptional<z.ZodArray<z.ZodString>>;
357
357
  }, z.core.$strict>>;
358
358
  no_offer: z.ZodOptional<z.ZodString>;
359
+ styles: z.ZodOptional<z.ZodObject<{
360
+ prefix: z.ZodString;
361
+ }, z.core.$strict>>;
362
+ delivery: z.ZodOptional<z.ZodObject<{
363
+ choice: z.ZodEnum<{
364
+ built_in: "built_in";
365
+ optional: "optional";
366
+ }>;
367
+ intro: z.ZodString;
368
+ when: z.ZodOptional<z.ZodString>;
369
+ }, z.core.$strict>>;
359
370
  }, z.core.$strict>;
360
371
  export type Capability = z.infer<typeof capabilitySchema>;
361
372
  export type Offer = z.infer<typeof offer>;
@@ -192,6 +192,19 @@ const offer = z.strictObject({
192
192
  /** Project setup answers that must exist first: their components' answers files. */
193
193
  needs: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/)).min(1).optional(),
194
194
  });
195
+ /**
196
+ * How the tool reaches a client's project (docs/capabilities-manifest.md §9.6).
197
+ * Every component a client's Claude Code works with has one; plumbing
198
+ * (`no_offer`) has none.
199
+ */
200
+ const delivery = z.strictObject({
201
+ /** `built_in`: stated to the owner, never asked. `optional`: asked once and recorded in workbench/tools.json. */
202
+ choice: z.enum(["built_in", "optional"]),
203
+ /** One plain sentence for the owner, shown when Claude introduces the tools. */
204
+ intro: z.string().min(1).max(160),
205
+ /** When the tool applies, in the client's words. Only for a component with no `offer`, whose `offer.when` says it. */
206
+ when: z.string().min(1).optional(),
207
+ });
195
208
  const baseShape = {
196
209
  $schema: z.literal("./schema.json"),
197
210
  format: z.literal(1),
@@ -217,6 +230,13 @@ const baseShape = {
217
230
  offer: offer.optional(),
218
231
  /** Why a client has nothing to answer for this component (plumbing). A manifest has `offer` or `no_offer`. */
219
232
  no_offer: z.string().min(1).optional(),
233
+ /**
234
+ * For a component that draws on a project's pages: the prefix of its classes
235
+ * and variables (docs/capabilities-manifest.md §10). Its skill then says how to
236
+ * restyle them through public/styles/project.css.
237
+ */
238
+ styles: z.strictObject({ prefix: z.string().regex(/^wb-[a-z]+-$/) }).optional(),
239
+ delivery: delivery.optional(),
220
240
  };
221
241
  /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
222
242
  export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) => {
@@ -358,6 +378,15 @@ export function checkManifests(files) {
358
378
  }
359
379
  if ((m.offer || m.no_offer) && pending)
360
380
  problems.push({ file, check: "schema", message: `remove ${m.component} from OFFER_PENDING` });
381
+ // §9.6: every component a client's Claude Code works with says how it reaches the project.
382
+ if (m.no_offer && m.delivery)
383
+ problems.push({ file, check: "schema", message: "has both no_offer and delivery: plumbing gets no skill of its own" });
384
+ if (!m.no_offer && !m.delivery)
385
+ problems.push({ file, check: "schema", message: "add delivery: whether the tool is built in or optional, and one sentence that introduces it to the owner" });
386
+ if (m.delivery && m.offer && m.delivery.when)
387
+ problems.push({ file, check: "schema", message: "delivery.when repeats offer.when: remove it" });
388
+ if (m.delivery && !m.offer && !m.delivery.when)
389
+ problems.push({ file, check: "schema", message: "delivery needs when, because there is no offer to say when the tool applies" });
361
390
  }
362
391
  return { manifests, problems };
363
392
  }
package/dist/cli.js CHANGED
@@ -4,12 +4,15 @@
4
4
  // `npx workbench-config skills [--check] [dir]`: writes (or, with --check,
5
5
  // compares) the project skills generated from the manifests' offer sections
6
6
  // (docs/capabilities-manifest.md §9).
7
+ // `npx workbench-config status [dir]`: tells Claude Code, at the start of a
8
+ // session, which tools the owner hasn't decided on, which answers are
9
+ // unfinished and whether the skills are current (§9.6). Always exits 0.
7
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
11
  import { dirname, join } from "node:path";
9
12
  import { fileURLToPath } from "node:url";
10
13
  import { compareDevVars } from "./dev-vars.js";
11
- import { planSkills } from "./skills-plan.js";
12
- const USAGE = "Usage: workbench-config check [dir]\n workbench-config skills [--check] [dir]";
14
+ import { planSkills, statusLines } from "./skills-plan.js";
15
+ const USAGE = "Usage: workbench-config check [dir]\n workbench-config skills [--check] [dir]\n workbench-config status [dir]";
13
16
  function checkDevVars(dir) {
14
17
  const examplePath = join(dir, ".dev.vars.example");
15
18
  const devVarsPath = join(dir, ".dev.vars");
@@ -69,12 +72,28 @@ function skills(args) {
69
72
  }
70
73
  return 0;
71
74
  }
75
+ function status(dir) {
76
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), "..");
77
+ const version = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).version;
78
+ // Runs from a SessionStart hook, so it never fails the session: a problem is one more line.
79
+ try {
80
+ const lines = statusLines(join(pkg, "capabilities"), dir);
81
+ if (lines.length)
82
+ console.log([`Workbench status (workbench-config ${version}):`, ...lines].join("\n"));
83
+ }
84
+ catch (e) {
85
+ console.log(`Workbench status (workbench-config ${version}) couldn't be read: ${e instanceof Error ? e.message : String(e)}. Run \`npx workbench-config skills --check\` to see why.`);
86
+ }
87
+ return 0;
88
+ }
72
89
  function main(argv) {
73
90
  const [command, ...rest] = argv;
74
91
  if (command === "check")
75
92
  return checkDevVars(rest[0] ?? ".");
76
93
  if (command === "skills")
77
94
  return skills(rest);
95
+ if (command === "status")
96
+ return status(rest[0] ?? ".");
78
97
  console.error(USAGE);
79
98
  return 2;
80
99
  }
package/dist/index.d.ts CHANGED
@@ -3,7 +3,9 @@ export { defineConfig, type Config } from "./define-config.ts";
3
3
  export { sharedSecret, workerSecret, redact, redactAll, type SecretsStoreSecretLike } from "./secrets.ts";
4
4
  export { WorkbenchConfigError, type ConfigErrorCode, type ConfigProblem } from "./errors.ts";
5
5
  export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, type Offer, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.ts";
6
- export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.ts";
6
+ export { renderSkill, renderSkills, renderStart, skillName, SKILLS_DIR, START_SKILL, TOOLS_FILE } from "./skills.ts";
7
+ export { toolsState } from "./tools.ts";
8
+ export type { ToolChoices, ToolsState } from "./tools.ts";
7
9
  export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
8
10
  export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
9
11
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ export { defineConfig } from "./define-config.js";
3
3
  export { sharedSecret, workerSecret, redact, redactAll } from "./secrets.js";
4
4
  export { WorkbenchConfigError } from "./errors.js";
5
5
  export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, OFFER_PATTERNS, OFFER_PENDING, } from "./capability-schema.js";
6
- export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.js";
6
+ export { renderSkill, renderSkills, renderStart, skillName, SKILLS_DIR, START_SKILL, TOOLS_FILE } from "./skills.js";
7
+ export { toolsState } from "./tools.js";
7
8
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
8
9
  export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
9
10
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
@@ -1,7 +1,15 @@
1
+ import { type Capability } from "./capability-schema.ts";
1
2
  export declare const GENERATED_MARK = "Generated by `npx workbench-config skills`";
2
3
  /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
3
4
  export declare function planSkills(manifestDir: string, projectDir: string): {
4
5
  files: Record<string, string>;
5
6
  stale: string[];
7
+ manifests: Capability[];
6
8
  problems: string[];
7
9
  };
10
+ /**
11
+ * What Claude Code is told at the start of a session (§9.6): tools the owner
12
+ * hasn't decided on, unfinished answers and skills out of date. No lines when
13
+ * everything is settled.
14
+ */
15
+ export declare function statusLines(manifestDir: string, projectDir: string): string[];
@@ -3,7 +3,8 @@
3
3
  import { existsSync, readdirSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { checkManifests } from "./capability-schema.js";
6
- import { renderSkills, SKILLS_DIR } from "./skills.js";
6
+ import { renderSkills, SKILLS_DIR, START_SKILL, TOOLS_FILE } from "./skills.js";
7
+ import { toolsState } from "./tools.js";
7
8
  export const GENERATED_MARK = "Generated by `npx workbench-config skills`";
8
9
  /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
9
10
  export function planSkills(manifestDir, projectDir) {
@@ -11,8 +12,11 @@ export function planSkills(manifestDir, projectDir) {
11
12
  for (const f of readdirSync(manifestDir).filter((f) => f.endsWith(".json") && f !== "schema.json")) {
12
13
  json[f] = JSON.parse(readFileSync(join(manifestDir, f), "utf8"));
13
14
  }
15
+ const guides = {};
16
+ for (const f of readdirSync(manifestDir).filter((f) => f.endsWith(".md")))
17
+ guides[f.slice(0, -3)] = readFileSync(join(manifestDir, f), "utf8");
14
18
  const { manifests, problems } = checkManifests(json);
15
- const files = renderSkills(manifests);
19
+ const files = renderSkills(manifests, guides);
16
20
  const stale = [];
17
21
  const skillsDir = join(projectDir, SKILLS_DIR);
18
22
  if (existsSync(skillsDir)) {
@@ -22,5 +26,58 @@ export function planSkills(manifestDir, projectDir) {
22
26
  stale.push(path);
23
27
  }
24
28
  }
25
- return { files, stale, problems: problems.map((p) => `${p.file}: [${p.check}] ${p.message}`) };
29
+ return { files, stale, manifests, problems: problems.map((p) => `${p.file}: [${p.check}] ${p.message}`) };
30
+ }
31
+ /** Answers files under `workbench/` still marked `"status": "draft"`, as project paths. */
32
+ function drafts(projectDir, dir = "workbench") {
33
+ const found = [];
34
+ if (!existsSync(join(projectDir, dir)))
35
+ return found;
36
+ for (const entry of readdirSync(join(projectDir, dir), { withFileTypes: true })) {
37
+ const path = `${dir}/${entry.name}`;
38
+ if (entry.isDirectory())
39
+ found.push(...drafts(projectDir, path));
40
+ else if (entry.name.endsWith(".json") && path !== TOOLS_FILE) {
41
+ try {
42
+ if (JSON.parse(readFileSync(join(projectDir, path), "utf8")).status === "draft")
43
+ found.push(path);
44
+ }
45
+ catch {
46
+ // Not JSON: the tool that owns the file reports it.
47
+ }
48
+ }
49
+ }
50
+ return found.sort();
51
+ }
52
+ /**
53
+ * What Claude Code is told at the start of a session (§9.6): tools the owner
54
+ * hasn't decided on, unfinished answers and skills out of date. No lines when
55
+ * everything is settled.
56
+ */
57
+ export function statusLines(manifestDir, projectDir) {
58
+ const { files, stale, manifests, problems } = planSkills(manifestDir, projectDir);
59
+ if (problems.length)
60
+ return ["The installed Workbench manifests don't check out, so nothing else was looked at:", ...problems.map((p) => `- ${p}`)];
61
+ const lines = [];
62
+ const toolsPath = join(projectDir, TOOLS_FILE);
63
+ const state = toolsState(manifests, existsSync(toolsPath) ? readFileSync(toolsPath, "utf8") : undefined);
64
+ const list = state.undecided.map((t) => `${t.component} (${t.title})`).join(", ");
65
+ if (state.problems.length) {
66
+ lines.push(`- \`${TOOLS_FILE}\` can't be used: ${state.problems.join("; ")}. Fix it with the owner before relying on it.`);
67
+ }
68
+ else if (!state.exists) {
69
+ lines.push(`- The owner hasn't been introduced to the Workbench tools yet (no \`${TOOLS_FILE}\`). Before other work, use the \`${START_SKILL}\` skill: introduce the tools and ask about the optional ones: ${list}.`);
70
+ }
71
+ else if (state.undecided.length) {
72
+ lines.push(`- New since the owner last chose, and not yet decided: ${list}. Use the \`${START_SKILL}\` skill to introduce and ask about ${state.undecided.length === 1 ? "it" : "them"}.`);
73
+ }
74
+ if (state.declined.length)
75
+ lines.push(`- The owner turned down: ${state.declined.join(", ")}. Don't use ${state.declined.length === 1 ? "it" : "them"} without asking.`);
76
+ const unfinished = drafts(projectDir);
77
+ if (unfinished.length)
78
+ lines.push(`- Unfinished answers: ${unfinished.map((p) => `\`${p}\``).join(", ")}. Show the owner what each holds and offer to carry on or start again, as its tool's skill says.`);
79
+ const wrong = Object.entries(files).filter(([path, text]) => !existsSync(join(projectDir, path)) || readFileSync(join(projectDir, path), "utf8") !== text);
80
+ if (wrong.length || stale.length)
81
+ lines.push("- The skills in `.claude/skills` don't match the installed workbench-config. Run `npx workbench-config skills` and commit the result before relying on them.");
82
+ return lines;
26
83
  }
package/dist/skills.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { Capability } from "./capability-schema.ts";
2
2
  export declare const SKILLS_DIR = ".claude/skills";
3
3
  export declare const skillName: (component: string) => string;
4
- /** The SKILL.md for one manifest, or undefined when it has no offer. */
5
- export declare function renderSkill(m: Capability): string | undefined;
6
- /** Every skill the manifests produce, keyed by its path in a project. */
7
- export declare function renderSkills(manifests: readonly Capability[]): Record<string, string>;
4
+ /** The owner's choices: which optional tools this project uses (§9.6). */
5
+ export declare const TOOLS_FILE = "workbench/tools.json";
6
+ export declare const START_SKILL = "workbench-start";
7
+ /** The SKILL.md for one manifest, or undefined when it has no `delivery` (plumbing). `guide` is its `capabilities/<component>.md`, when there is one. */
8
+ export declare function renderSkill(m: Capability, guide?: string): string | undefined;
9
+ /** The `workbench-start` skill: every tool introduced to the owner, and how their choices are recorded (§9.6). */
10
+ export declare function renderStart(manifests: readonly Capability[]): string;
11
+ /** Every skill the manifests produce, keyed by its path in a project. `guides` holds each `capabilities/<component>.md` by component. */
12
+ export declare function renderSkills(manifests: readonly Capability[], guides?: Readonly<Record<string, string>>): Record<string, string>;
package/dist/skills.js CHANGED
@@ -1,10 +1,15 @@
1
- // Project skills generated from the manifests' `offer` sections
2
- // (docs/capabilities-manifest.md §9). `workbench-config skills` writes one
3
- // `.claude/skills/workbench-<component>/SKILL.md` per offer into a project,
4
- // and `--check` fails when a file differs, so the questions Claude Code asks
5
- // a client never drift from what the package accepts.
1
+ // Project skills generated from the manifests (docs/capabilities-manifest.md
2
+ // §9, §9.6). `workbench-config skills` writes one
3
+ // `.claude/skills/workbench-<component>/SKILL.md` into a project for every
4
+ // component with a `delivery`, plus `workbench-start`, which introduces them
5
+ // to the owner. `--check` fails when a file differs, so what Claude Code
6
+ // knows and asks never drifts from what the installed packages accept.
6
7
  export const SKILLS_DIR = ".claude/skills";
7
8
  export const skillName = (component) => `workbench-${component}`;
9
+ /** The owner's choices: which optional tools this project uses (§9.6). */
10
+ export const TOOLS_FILE = "workbench/tools.json";
11
+ export const START_SKILL = "workbench-start";
12
+ const GENERATED = (from) => `<!-- Generated by \`npx workbench-config skills\` from @mccaskie-labs/workbench-config, ${from}. Don't edit: \`npx workbench-config skills --check\` fails when this file differs. -->`;
8
13
  const show = (v) => (v === null ? "none" : `\`${JSON.stringify(v)}\``);
9
14
  const cell = (s) => s.replaceAll("|", "\\|").replaceAll("\n", " ");
10
15
  const sets = (q) => (typeof q.sets === "string" ? [q.sets] : q.sets).map((s) => `\`${s}\``).join(", ");
@@ -72,19 +77,59 @@ function draftsBlock(d) {
72
77
  "Never write drafts yourself and never call a vendor directly. If the command says there is no development key, say so and offer the standard version instead; never ask for a key in chat. Record the picked draft's request id, model and cost in the answers file.",
73
78
  ].join("\n");
74
79
  }
75
- /** The SKILL.md for one manifest, or undefined when it has no offer. */
76
- export function renderSkill(m) {
80
+ /** A guide's text as a section of a skill: its title dropped, its headings one level down. */
81
+ function guideSection(guide) {
82
+ let fenced = false;
83
+ const lines = guide.trim().split("\n").map((line) => {
84
+ if (line.startsWith("```"))
85
+ fenced = !fenced;
86
+ return !fenced && line.startsWith("#") ? `#${line}` : line;
87
+ });
88
+ if (lines[0]?.startsWith("## "))
89
+ lines.shift();
90
+ return lines.join("\n").trim();
91
+ }
92
+ function beforeBlock(m) {
93
+ if (m.delivery?.choice === "optional") {
94
+ return [
95
+ "## Before you use it",
96
+ "",
97
+ `This tool is the owner's choice. Read \`${TOOLS_FILE}\` first and find \`${m.component}\` under \`tools\`:`,
98
+ "",
99
+ "- `\"use\": true`: carry on.",
100
+ "- `\"use\": false`: the owner turned it down. Say so, with the date, and ask whether to use it this time before doing anything with it. If they say yes, update the file.",
101
+ `- Not there, or no file: ask now, with the AskUserQuestion tool: "Use Workbench ${m.title} for this? (Recommended)" or "No, do it another way". Record the answer the way the \`${START_SKILL}\` skill says, then carry on.`,
102
+ ];
103
+ }
104
+ return [
105
+ "## Before you use it",
106
+ "",
107
+ "This tool is built in to every Workbench project. Use it whenever this applies and say that you are. Don't ask whether to use Workbench or something of the client's own. The client chooses whether the app has the feature, not how it is built.",
108
+ ];
109
+ }
110
+ function useBlock(m, guide) {
111
+ const manifest = `\`node_modules/@mccaskie-labs/workbench-config/capabilities/${m.component}.json\``;
112
+ const out = ["## How to use it", ""];
113
+ if (guide)
114
+ out.push(guideSection(guide), "");
115
+ out.push(`Its limits, rules and error codes are in ${manifest}. Where the project already wires it in is in the project's \`CLAUDE.md\`, and each package's API is in its \`README.md\` under \`node_modules/@mccaskie-labs/\`.`);
116
+ return out;
117
+ }
118
+ /** The SKILL.md for one manifest, or undefined when it has no `delivery` (plumbing). `guide` is its `capabilities/<component>.md`, when there is one. */
119
+ export function renderSkill(m, guide) {
77
120
  const o = m.offer;
78
- if (!o)
121
+ const d = m.delivery;
122
+ if (!d)
79
123
  return undefined;
80
- const description = `${o.when} Offers Workbench ${m.title} and asks the client for its settings.`.replaceAll("\n", " ");
124
+ const when = o?.when ?? d.when ?? m.summary;
125
+ const description = (o ? `${when} Offers Workbench ${m.title} and asks the client for its settings.` : `${when} Use Workbench ${m.title} for it.`).replaceAll("\n", " ");
81
126
  const out = [
82
127
  "---",
83
128
  `name: ${skillName(m.component)}`,
84
129
  `description: ${JSON.stringify(description)}`,
85
130
  "---",
86
131
  "",
87
- `<!-- Generated by \`npx workbench-config skills\` from @mccaskie-labs/workbench-config, capabilities/${m.component}.json. Don't edit: \`npx workbench-config skills --check\` fails when this file differs. -->`,
132
+ GENERATED(`capabilities/${m.component}.json`),
88
133
  "",
89
134
  `# ${m.title}`,
90
135
  "",
@@ -92,40 +137,102 @@ export function renderSkill(m) {
92
137
  "",
93
138
  "## When this applies",
94
139
  "",
95
- o.when,
96
- "",
97
- "## What to say",
98
- "",
99
- `Say: "${o.say}" Don't ask whether to use Workbench or something of the client's own. If the client says they don't want it, stop and say what they give up.`,
140
+ when,
100
141
  "",
101
- "## How to ask",
102
- "",
103
- ...(o.needs?.length ? [`- First check the project setup this needs: ${o.needs.map((n) => `\`workbench/${n}.json\``).join(", ")}. If one is missing, use that component's \`workbench-<component>\` skill first.`] : []),
104
- ...PATTERN_STEPS[o.pattern].map((step) => `- ${step}`),
105
- "- Ask with the AskUserQuestion tool, up to four questions per call. Give each question's answers in the order below; the first is recommended, so label it \"(Recommended)\". The tool adds \"Other\" itself.",
106
- "- Where the client accepts every recommendation, still ask each \"Must ask\" question.",
107
- "- An \"Other\" answer must meet the rule under its question. When it doesn't, say why and ask again. Never store a value outside it.",
108
- "- Ask nothing that isn't listed here, and invent no setting. A need the questions don't cover goes back to the client as a plain question, and isn't written to the answers file.",
109
- `- Write the answers to \`${o.answers_file}\`${o.per ? `, one entry per ${o.per}` : ""}: ${o.answers_format ?? `for each question its id, the value, and the client's own words when they typed "Other" or changed a recommendation.`}${o.generator ? ` Then run \`${o.generator}\`, which writes the code and refuses anything the package can't apply (it doesn't check the "Other" rules: you do). It builds nothing while an answers file is a draft. Never edit what it writes; change the answers file and run it again.` : ""}`,
110
- "",
111
- "## Questions",
112
- "",
113
- o.questions.map((q, i) => questionBlock(q, i + 1)).join("\n\n"),
142
+ ...beforeBlock(m),
114
143
  ];
144
+ if (!o) {
145
+ out.push("", "## Questions", "", "This tool's questions for the client aren't written yet. Use it with its defaults, tell the client that is what you did, and invent no setting. A choice only the client can make goes to them as a plain question.", "", ...useBlock(m, guide));
146
+ return `${out.join("\n")}\n`;
147
+ }
148
+ out.push("", "## What to say", "", d.choice === "optional"
149
+ ? `Once the owner's choice is yes, say: "${o.say}"`
150
+ : `Say: "${o.say}" Don't ask whether to use Workbench or something of the client's own. If the client says they don't want it, stop and say what they give up.`, "", "## How to ask", "", ...(o.needs?.length ? [`- First check the project setup this needs: ${o.needs.map((n) => `\`workbench/${n}.json\``).join(", ")}. If one is missing, use that component's \`workbench-<component>\` skill first.`] : []), ...PATTERN_STEPS[o.pattern].map((step) => `- ${step}`), "- Ask with the AskUserQuestion tool, up to four questions per call. Give each question's answers in the order below; the first is recommended, so label it \"(Recommended)\". The tool adds \"Other\" itself.", "- Where the client accepts every recommendation, still ask each \"Must ask\" question.", "- An \"Other\" answer must meet the rule under its question. When it doesn't, say why and ask again. Never store a value outside it.", "- Ask nothing that isn't listed here, and invent no setting. A need the questions don't cover goes back to the client as a plain question, and isn't written to the answers file.", `- Write the answers to \`${o.answers_file}\`${o.per ? `, one entry per ${o.per}` : ""}: ${o.answers_format ?? `for each question its id, the value, and the client's own words when they typed "Other" or changed a recommendation.`}${o.generator ? ` Then run \`${o.generator}\`, which writes the code and refuses anything the package can't apply (it doesn't check the "Other" rules: you do). It builds nothing while an answers file is a draft. Never edit what it writes; change the answers file and run it again.` : ""}`, "", "## Questions", "", o.questions.map((q, i) => questionBlock(q, i + 1)).join("\n\n"));
115
151
  if (o.drafts)
116
152
  out.push("", draftsBlock(o.drafts));
117
153
  if (o.not_asked?.length) {
118
154
  out.push("", "## Not asked", "", ...o.not_asked.map((n) => `- \`${n.sets}\`: ${n.why}`));
119
155
  }
156
+ if (m.styles)
157
+ out.push("", "## Restyling", "", restyleLine(m.styles.prefix));
158
+ out.push("", ...useBlock(m, guide));
159
+ return `${out.join("\n")}\n`;
160
+ }
161
+ /** How a project restyles what a component draws (docs/capabilities-manifest.md §10). */
162
+ function restyleLine(prefix) {
163
+ return `Everything this tool draws on a page has a \`${prefix}\` class with a default Workbench style, and its colours and sizes are \`--${prefix}*\` variables. All of them are listed, each with what it is, in \`public/styles/workbench.css\`, which \`npx workbench-ui styles\` generates: never edit it. To match the project's own look, write rules for those classes, or set the variables to the project's own colours, in \`public/styles/project.css\`. It loads after workbench.css and always wins. When the client asks for a different look, change project.css, never the tool's markup.`;
164
+ }
165
+ /** The `workbench-start` skill: every tool introduced to the owner, and how their choices are recorded (§9.6). */
166
+ export function renderStart(manifests) {
167
+ const live = manifests.filter((m) => m.status !== "deprecated");
168
+ const row = (m) => `| ${cell(m.delivery.intro)} | \`${m.component}\` | \`${skillName(m.component)}\` |`;
169
+ const group = (choice) => live.filter((m) => m.delivery?.choice === choice).map(row);
170
+ const plumbing = live.filter((m) => !m.delivery).map((m) => m.title);
171
+ const description = "Use at the start of the first session in a Workbench project, whenever workbench/tools.json is missing or the session-start status lists tools the owner hasn't decided on, and when the owner asks what Workbench can do. Introduces every Workbench tool in plain words and records which optional ones the owner wants.";
172
+ const out = [
173
+ "---",
174
+ `name: ${START_SKILL}`,
175
+ `description: ${JSON.stringify(description)}`,
176
+ "---",
177
+ "",
178
+ GENERATED("every manifest in capabilities/"),
179
+ "",
180
+ "# Introduce the Workbench tools",
181
+ "",
182
+ "This project was made by Workbench, so it comes with tools that are already wired in. The owner may never have heard of them. Before other work, tell them what is here and ask which of the optional ones they want. Each tool has its own skill, named in the tables, which says how to use it and what to ask.",
183
+ "",
184
+ "## What to do",
185
+ "",
186
+ `1. Read \`${TOOLS_FILE}\`. A tool listed there is decided: don't ask about it again.`,
187
+ "2. If the file is missing, this is the first session. Introduce everything: say in two or three sentences that the project comes with Workbench tools, then show the two lists below in the owner's words (the first column), without package names.",
188
+ "3. If the file is there and only some optional tools are missing from it, they arrived in an update. Introduce only those.",
189
+ "4. Say the built-in tools are simply there. Don't ask about them.",
190
+ "5. Ask about each undecided optional tool with the AskUserQuestion tool, one question per tool, up to four questions per call: \"Use it (Recommended)\" or \"Not now\". Put what the tool does in the question, in plain words.",
191
+ "6. Write every answer to the file at once (the format is below), and tell the owner they can change any choice later by asking.",
192
+ "7. Then carry on with what the owner came to do. A tool with unanswered settings asks them when it is first used, through its own skill.",
193
+ "",
194
+ "Never use an optional tool the owner hasn't decided on, and never treat \"Not now\" as permanent: if they later ask for something it covers, its skill says to ask again.",
195
+ "",
196
+ "## Built in: say these are here, don't ask",
197
+ "",
198
+ "| What it does for the owner | Tool | Skill |",
199
+ "| --- | --- | --- |",
200
+ ...group("built_in"),
201
+ "",
202
+ "## Optional: ask about each one once",
203
+ "",
204
+ "| What it does for the owner | Tool | Skill |",
205
+ "| --- | --- | --- |",
206
+ ...group("optional"),
207
+ "",
208
+ ...(plumbing.length ? [`Underneath, with nothing for the owner to decide: ${plumbing.join("; ")}.`, ""] : []),
209
+ `## The choices file, \`${TOOLS_FILE}\``,
210
+ "",
211
+ "```json",
212
+ "{",
213
+ ' "format": 1,',
214
+ ' "tools": {',
215
+ ' "pdf": { "use": true, "decided": "2026-11-12" },',
216
+ ' "analytics": { "use": false, "decided": "2026-11-12", "reason": "We already count visits another way." }',
217
+ " }",
218
+ "}",
219
+ "```",
220
+ "",
221
+ "- One entry per optional tool, keyed by the name in the Tool column. Built-in tools are never listed.",
222
+ "- `decided` is today's date. `reason` is the owner's own words, when they gave any.",
223
+ "- Commit it with the rest of the work. `npx workbench-config status` reads it at the start of every session and names anything still undecided.",
224
+ ];
120
225
  return `${out.join("\n")}\n`;
121
226
  }
122
- /** Every skill the manifests produce, keyed by its path in a project. */
123
- export function renderSkills(manifests) {
227
+ /** Every skill the manifests produce, keyed by its path in a project. `guides` holds each `capabilities/<component>.md` by component. */
228
+ export function renderSkills(manifests, guides = {}) {
124
229
  const files = {};
125
230
  for (const m of manifests) {
126
- const text = renderSkill(m);
231
+ const text = renderSkill(m, guides[m.component]);
127
232
  if (text)
128
233
  files[`${SKILLS_DIR}/${skillName(m.component)}/SKILL.md`] = text;
129
234
  }
235
+ if (Object.keys(files).length)
236
+ files[`${SKILLS_DIR}/${START_SKILL}/SKILL.md`] = renderStart(manifests);
130
237
  return files;
131
238
  }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import type { Capability } from "./capability-schema.ts";
3
+ declare const toolsFile: z.ZodObject<{
4
+ format: z.ZodLiteral<1>;
5
+ tools: z.ZodRecord<z.ZodString, z.ZodObject<{
6
+ use: z.ZodBoolean;
7
+ decided: z.ZodString;
8
+ reason: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strict>>;
10
+ }, z.core.$strict>;
11
+ export type ToolChoices = z.infer<typeof toolsFile>;
12
+ export interface ToolsState {
13
+ /** False when the project has no choices file yet: the first session. */
14
+ exists: boolean;
15
+ /** Optional tools with no entry, as `{ component, title }`. */
16
+ undecided: {
17
+ component: string;
18
+ title: string;
19
+ }[];
20
+ /** Optional tools the owner said yes to, and no to. */
21
+ using: string[];
22
+ declined: string[];
23
+ /** What is wrong with the file: bad JSON, a bad shape, or a name that isn't an optional tool. */
24
+ problems: string[];
25
+ }
26
+ /** Compares a project's choices file (its text, or undefined when missing) with the installed manifests. */
27
+ export declare function toolsState(manifests: readonly Capability[], text: string | undefined): ToolsState;
28
+ export {};
package/dist/tools.js ADDED
@@ -0,0 +1,47 @@
1
+ // The owner's choices of optional tools, `workbench/tools.json` in a project
2
+ // (docs/capabilities-manifest.md §9.6). Pure: the file is read by the caller.
3
+ import { z } from "zod";
4
+ const toolsFile = z.strictObject({
5
+ format: z.literal(1),
6
+ tools: z.record(z.string(), z.strictObject({
7
+ use: z.boolean(),
8
+ decided: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date, YYYY-MM-DD"),
9
+ reason: z.string().min(1).optional(),
10
+ })),
11
+ });
12
+ /** Compares a project's choices file (its text, or undefined when missing) with the installed manifests. */
13
+ export function toolsState(manifests, text) {
14
+ const optional = manifests.filter((m) => m.delivery?.choice === "optional" && m.status !== "deprecated");
15
+ const state = { exists: text !== undefined, undecided: [], using: [], declined: [], problems: [] };
16
+ let tools = {};
17
+ if (text !== undefined) {
18
+ let json;
19
+ try {
20
+ json = JSON.parse(text);
21
+ }
22
+ catch {
23
+ state.problems.push("it isn't valid JSON");
24
+ }
25
+ if (json !== undefined) {
26
+ const parsed = toolsFile.safeParse(json);
27
+ if (parsed.success)
28
+ tools = parsed.data.tools;
29
+ else
30
+ state.problems.push(...parsed.error.issues.map((i) => `${i.path.join(".") || "file"}: ${i.message}`));
31
+ }
32
+ }
33
+ // A deprecated tool's entry stays valid: the owner chose it while it was offered.
34
+ const names = new Set(manifests.filter((m) => m.delivery?.choice === "optional").map((m) => m.component));
35
+ for (const name of Object.keys(tools)) {
36
+ if (!names.has(name))
37
+ state.problems.push(`${name} isn't an optional Workbench tool`);
38
+ }
39
+ for (const m of optional) {
40
+ const choice = tools[m.component];
41
+ if (!choice)
42
+ state.undecided.push({ component: m.component, title: m.title });
43
+ else
44
+ (choice.use ? state.using : state.declined).push(m.component);
45
+ }
46
+ return state;
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",