@mccaskie-labs/workbench-config 0.1.26 → 0.1.28

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.28
4
+
5
+ ### Patch Changes
6
+
7
+ - Client questions: manifests gain an optional `offer` section (docs/capabilities-manifest.md §9), checked for unique question ids, two to four answers on a cards question, no written "Other", and each package option set once. `renderSkill` and `npx workbench-config skills [--check] [dir]` write or compare the project skills `.claude/skills/workbench-<component>/SKILL.md` generated from them. No manifest has an offer yet.
8
+
9
+ ## 0.1.27
10
+
11
+ ### Patch Changes
12
+
13
+ - The forms manifest (`capabilities/forms.json`): five rules for `workbench-forms` (the endpoint decides, one verdict on the page and at the endpoint, codes turned into words by the strings catalogue, bounds from the limits register, no members on a form) and two no-cost probes, `forms.validate` and `forms.browser`. `forms` joins the component list.
14
+
3
15
  ## 0.1.26
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "forms",
5
+ "title": "Form validation",
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
+ "status": "built",
8
+ "surfaces": {
9
+ "package": [
10
+ "defineForm",
11
+ "text",
12
+ "longText",
13
+ "email",
14
+ "phone",
15
+ "money",
16
+ "whole",
17
+ "date",
18
+ "choice",
19
+ "validate",
20
+ "readForm",
21
+ "embedConfig",
22
+ "inputAttributes",
23
+ "messageKeys",
24
+ "tidyLine",
25
+ "tidyText",
26
+ "CODES",
27
+ "FormsSetupError"
28
+ ]
29
+ },
30
+ "rules": [
31
+ {
32
+ "id": "forms.endpoint-decides",
33
+ "kind": "must",
34
+ "text": "Validate every form at the endpoint with validate() and store only result.values; the page's copy of the validator (FORMS_SCRIPT) only helps the person, it never decides.",
35
+ "enforced_by": "review",
36
+ "probe": "forms.validate"
37
+ },
38
+ {
39
+ "id": "forms.same-verdict",
40
+ "kind": "must",
41
+ "text": "Put the form on the page with embedConfig and FORMS_SCRIPT, never a second set of rules, so the page and the endpoint reach the same verdict.",
42
+ "enforced_by": "review",
43
+ "probe": "forms.browser"
44
+ },
45
+ {
46
+ "id": "forms.codes-not-sentences",
47
+ "kind": "must",
48
+ "text": "Turn problem codes into words through the strings catalogue (messageKeys: validation.<form>.<field>.<code>, then validation.<code>), never words written in code.",
49
+ "enforced_by": "review",
50
+ "probe": "forms.validate"
51
+ },
52
+ {
53
+ "id": "forms.limits-register",
54
+ "kind": "must",
55
+ "text": "Take every length, amount and date bound from the app's limits register (defineLimits, boundsFor), and keep it matched to the database with workbench-supabase check:limits.",
56
+ "enforced_by": "types",
57
+ "probe": "forms.validate"
58
+ },
59
+ {
60
+ "id": "forms.no-members-on-a-form",
61
+ "kind": "must_not",
62
+ "text": "List a choice field's members on the form: the vocabulary owns them, and the app passes them in context.choices.",
63
+ "enforced_by": "types",
64
+ "probe": "forms.validate"
65
+ }
66
+ ],
67
+ "examples": [
68
+ {
69
+ "id": "forms.example.endpoint",
70
+ "title": "Check a posted form at the endpoint",
71
+ "lang": "ts",
72
+ "probe": "forms.validate",
73
+ "code": "import { defineForm, email, money, readForm, text, validate } from '@mccaskie-labs/workbench-forms'\nimport { boundsFor } from '@mccaskie-labs/workbench-forms/limits'\nimport { COUNTRY_PACKS } from '@mccaskie-labs/workbench-country'\n\nconst lead = defineForm('lead', { name: text({ limit: 'lead.name', required: true }), email: email({ required: true }), budget: money({ limit: 'lead.budget' }) })\nconst result = validate(lead, readForm(lead, await request.formData()), { bounds: boundsFor(LIMITS, lead), country: COUNTRY_PACKS.PH! })\nif (!result.ok) return show(result.problems, result.shown)\nawait db.from('leads').insert(result.values)"
74
+ }
75
+ ],
76
+ "probes": [
77
+ {
78
+ "id": "forms.validate",
79
+ "half": "runtime",
80
+ "expect": "pass",
81
+ "smoke": true,
82
+ "cost_hint_usd": 0,
83
+ "note": "In the Worker itself: validates a fixed form (text, email, phone, money, date, choice) for PH with one good and one bad input, and compares the stored values (E.164 phone, decimal-string amount, ISO date) and the problem codes with the expected ones"
84
+ },
85
+ {
86
+ "id": "forms.browser",
87
+ "half": "runtime",
88
+ "expect": "pass",
89
+ "cost_hint_usd": 0,
90
+ "note": "The probe page loads FORMS_SCRIPT and the form's embedConfig block, runs the same inputs in the browser and checks the verdict matches the Worker's; no Gateway call"
91
+ }
92
+ ]
93
+ }
@@ -33,7 +33,8 @@
33
33
  "kv",
34
34
  "github",
35
35
  "strings",
36
- "ui"
36
+ "ui",
37
+ "forms"
37
38
  ]
38
39
  },
39
40
  "title": {
@@ -400,6 +401,189 @@
400
401
  ],
401
402
  "additionalProperties": false
402
403
  }
404
+ },
405
+ "offer": {
406
+ "type": "object",
407
+ "properties": {
408
+ "when": {
409
+ "type": "string",
410
+ "minLength": 1
411
+ },
412
+ "say": {
413
+ "type": "string",
414
+ "minLength": 1
415
+ },
416
+ "answers_file": {
417
+ "type": "string",
418
+ "pattern": "^workbench\\/[a-z0-9<>/._-]+\\.json$"
419
+ },
420
+ "per": {
421
+ "type": "string",
422
+ "minLength": 1
423
+ },
424
+ "generator": {
425
+ "type": "string",
426
+ "minLength": 1
427
+ },
428
+ "questions": {
429
+ "minItems": 1,
430
+ "type": "array",
431
+ "items": {
432
+ "type": "object",
433
+ "properties": {
434
+ "id": {
435
+ "type": "string",
436
+ "pattern": "^[a-z0-9][a-z0-9.-]*$"
437
+ },
438
+ "sets": {
439
+ "anyOf": [
440
+ {
441
+ "type": "string",
442
+ "minLength": 1
443
+ },
444
+ {
445
+ "minItems": 1,
446
+ "type": "array",
447
+ "items": {
448
+ "type": "string",
449
+ "minLength": 1
450
+ }
451
+ }
452
+ ]
453
+ },
454
+ "ask": {
455
+ "type": "string",
456
+ "minLength": 1
457
+ },
458
+ "style": {
459
+ "type": "string",
460
+ "enum": [
461
+ "cards",
462
+ "table"
463
+ ]
464
+ },
465
+ "answers": {
466
+ "minItems": 2,
467
+ "maxItems": 4,
468
+ "type": "array",
469
+ "items": {
470
+ "type": "object",
471
+ "properties": {
472
+ "label": {
473
+ "type": "string",
474
+ "minLength": 1,
475
+ "maxLength": 40
476
+ },
477
+ "value": {
478
+ "type": [
479
+ "string",
480
+ "number",
481
+ "boolean",
482
+ "null"
483
+ ]
484
+ },
485
+ "means": {
486
+ "type": "string",
487
+ "minLength": 1
488
+ }
489
+ },
490
+ "required": [
491
+ "label",
492
+ "value"
493
+ ],
494
+ "additionalProperties": false
495
+ }
496
+ },
497
+ "other": {
498
+ "type": "object",
499
+ "properties": {
500
+ "kind": {
501
+ "type": "string",
502
+ "enum": [
503
+ "text",
504
+ "whole",
505
+ "money",
506
+ "date",
507
+ "list"
508
+ ]
509
+ },
510
+ "min": {
511
+ "type": [
512
+ "number",
513
+ "string"
514
+ ]
515
+ },
516
+ "max": {
517
+ "type": [
518
+ "number",
519
+ "string"
520
+ ]
521
+ },
522
+ "description": {
523
+ "type": "string",
524
+ "minLength": 1
525
+ }
526
+ },
527
+ "required": [
528
+ "kind"
529
+ ],
530
+ "additionalProperties": false
531
+ },
532
+ "for": {
533
+ "minItems": 1,
534
+ "type": "array",
535
+ "items": {
536
+ "type": "string",
537
+ "minLength": 1
538
+ }
539
+ },
540
+ "must_ask": {
541
+ "type": "boolean"
542
+ },
543
+ "note": {
544
+ "type": "string",
545
+ "minLength": 1
546
+ }
547
+ },
548
+ "required": [
549
+ "id",
550
+ "sets",
551
+ "ask",
552
+ "style"
553
+ ],
554
+ "additionalProperties": false
555
+ }
556
+ },
557
+ "not_asked": {
558
+ "minItems": 1,
559
+ "type": "array",
560
+ "items": {
561
+ "type": "object",
562
+ "properties": {
563
+ "sets": {
564
+ "type": "string",
565
+ "minLength": 1
566
+ },
567
+ "why": {
568
+ "type": "string",
569
+ "minLength": 1
570
+ }
571
+ },
572
+ "required": [
573
+ "sets",
574
+ "why"
575
+ ],
576
+ "additionalProperties": false
577
+ }
578
+ }
579
+ },
580
+ "required": [
581
+ "when",
582
+ "say",
583
+ "answers_file",
584
+ "questions"
585
+ ],
586
+ "additionalProperties": false
403
587
  }
404
588
  },
405
589
  "required": [
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "7d046814165b";
1
+ export declare const MANIFEST_VERSION: "c83a7c612319";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -1084,6 +1084,69 @@ export declare const capabilities: {
1084
1084
  readonly note: "Another project's namespace";
1085
1085
  }];
1086
1086
  };
1087
+ readonly forms: {
1088
+ readonly $schema: "./schema.json";
1089
+ readonly format: 1;
1090
+ readonly component: "forms";
1091
+ readonly title: "Form validation";
1092
+ readonly 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.";
1093
+ readonly status: "built";
1094
+ readonly surfaces: {
1095
+ readonly package: readonly ["defineForm", "text", "longText", "email", "phone", "money", "whole", "date", "choice", "validate", "readForm", "embedConfig", "inputAttributes", "messageKeys", "tidyLine", "tidyText", "CODES", "FormsSetupError"];
1096
+ };
1097
+ readonly rules: readonly [{
1098
+ readonly id: "forms.endpoint-decides";
1099
+ readonly kind: "must";
1100
+ readonly text: "Validate every form at the endpoint with validate() and store only result.values; the page's copy of the validator (FORMS_SCRIPT) only helps the person, it never decides.";
1101
+ readonly enforced_by: "review";
1102
+ readonly probe: "forms.validate";
1103
+ }, {
1104
+ readonly id: "forms.same-verdict";
1105
+ readonly kind: "must";
1106
+ readonly text: "Put the form on the page with embedConfig and FORMS_SCRIPT, never a second set of rules, so the page and the endpoint reach the same verdict.";
1107
+ readonly enforced_by: "review";
1108
+ readonly probe: "forms.browser";
1109
+ }, {
1110
+ readonly id: "forms.codes-not-sentences";
1111
+ readonly kind: "must";
1112
+ readonly text: "Turn problem codes into words through the strings catalogue (messageKeys: validation.<form>.<field>.<code>, then validation.<code>), never words written in code.";
1113
+ readonly enforced_by: "review";
1114
+ readonly probe: "forms.validate";
1115
+ }, {
1116
+ readonly id: "forms.limits-register";
1117
+ readonly kind: "must";
1118
+ readonly text: "Take every length, amount and date bound from the app's limits register (defineLimits, boundsFor), and keep it matched to the database with workbench-supabase check:limits.";
1119
+ readonly enforced_by: "types";
1120
+ readonly probe: "forms.validate";
1121
+ }, {
1122
+ readonly id: "forms.no-members-on-a-form";
1123
+ readonly kind: "must_not";
1124
+ readonly text: "List a choice field's members on the form: the vocabulary owns them, and the app passes them in context.choices.";
1125
+ readonly enforced_by: "types";
1126
+ readonly probe: "forms.validate";
1127
+ }];
1128
+ readonly examples: readonly [{
1129
+ readonly id: "forms.example.endpoint";
1130
+ readonly title: "Check a posted form at the endpoint";
1131
+ readonly lang: "ts";
1132
+ readonly code: "import { defineForm, email, money, readForm, text, validate } from '@mccaskie-labs/workbench-forms'\nimport { boundsFor } from '@mccaskie-labs/workbench-forms/limits'\nimport { COUNTRY_PACKS } from '@mccaskie-labs/workbench-country'\n\nconst lead = defineForm('lead', { name: text({ limit: 'lead.name', required: true }), email: email({ required: true }), budget: money({ limit: 'lead.budget' }) })\nconst result = validate(lead, readForm(lead, await request.formData()), { bounds: boundsFor(LIMITS, lead), country: COUNTRY_PACKS.PH! })\nif (!result.ok) return show(result.problems, result.shown)\nawait db.from('leads').insert(result.values)";
1133
+ readonly probe: "forms.validate";
1134
+ }];
1135
+ readonly probes: readonly [{
1136
+ readonly id: "forms.validate";
1137
+ readonly half: "runtime";
1138
+ readonly expect: "pass";
1139
+ readonly smoke: true;
1140
+ readonly cost_hint_usd: 0;
1141
+ readonly note: "In the Worker itself: validates a fixed form (text, email, phone, money, date, choice) for PH with one good and one bad input, and compares the stored values (E.164 phone, decimal-string amount, ISO date) and the problem codes with the expected ones";
1142
+ }, {
1143
+ readonly id: "forms.browser";
1144
+ readonly half: "runtime";
1145
+ readonly expect: "pass";
1146
+ readonly cost_hint_usd: 0;
1147
+ readonly note: "The probe page loads FORMS_SCRIPT and the form's embedConfig block, runs the same inputs in the browser and checks the verdict matches the Worker's; no Gateway call";
1148
+ }];
1149
+ };
1087
1150
  readonly gateway: {
1088
1151
  readonly $schema: "./schema.json";
1089
1152
  readonly format: 1;
@@ -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 = "7d046814165b";
3
+ export const MANIFEST_VERSION = "c83a7c612319";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -1475,6 +1475,99 @@ export const capabilities = {
1475
1475
  }
1476
1476
  ]
1477
1477
  },
1478
+ "forms": {
1479
+ "$schema": "./schema.json",
1480
+ "format": 1,
1481
+ "component": "forms",
1482
+ "title": "Form validation",
1483
+ "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.",
1484
+ "status": "built",
1485
+ "surfaces": {
1486
+ "package": [
1487
+ "defineForm",
1488
+ "text",
1489
+ "longText",
1490
+ "email",
1491
+ "phone",
1492
+ "money",
1493
+ "whole",
1494
+ "date",
1495
+ "choice",
1496
+ "validate",
1497
+ "readForm",
1498
+ "embedConfig",
1499
+ "inputAttributes",
1500
+ "messageKeys",
1501
+ "tidyLine",
1502
+ "tidyText",
1503
+ "CODES",
1504
+ "FormsSetupError"
1505
+ ]
1506
+ },
1507
+ "rules": [
1508
+ {
1509
+ "id": "forms.endpoint-decides",
1510
+ "kind": "must",
1511
+ "text": "Validate every form at the endpoint with validate() and store only result.values; the page's copy of the validator (FORMS_SCRIPT) only helps the person, it never decides.",
1512
+ "enforced_by": "review",
1513
+ "probe": "forms.validate"
1514
+ },
1515
+ {
1516
+ "id": "forms.same-verdict",
1517
+ "kind": "must",
1518
+ "text": "Put the form on the page with embedConfig and FORMS_SCRIPT, never a second set of rules, so the page and the endpoint reach the same verdict.",
1519
+ "enforced_by": "review",
1520
+ "probe": "forms.browser"
1521
+ },
1522
+ {
1523
+ "id": "forms.codes-not-sentences",
1524
+ "kind": "must",
1525
+ "text": "Turn problem codes into words through the strings catalogue (messageKeys: validation.<form>.<field>.<code>, then validation.<code>), never words written in code.",
1526
+ "enforced_by": "review",
1527
+ "probe": "forms.validate"
1528
+ },
1529
+ {
1530
+ "id": "forms.limits-register",
1531
+ "kind": "must",
1532
+ "text": "Take every length, amount and date bound from the app's limits register (defineLimits, boundsFor), and keep it matched to the database with workbench-supabase check:limits.",
1533
+ "enforced_by": "types",
1534
+ "probe": "forms.validate"
1535
+ },
1536
+ {
1537
+ "id": "forms.no-members-on-a-form",
1538
+ "kind": "must_not",
1539
+ "text": "List a choice field's members on the form: the vocabulary owns them, and the app passes them in context.choices.",
1540
+ "enforced_by": "types",
1541
+ "probe": "forms.validate"
1542
+ }
1543
+ ],
1544
+ "examples": [
1545
+ {
1546
+ "id": "forms.example.endpoint",
1547
+ "title": "Check a posted form at the endpoint",
1548
+ "lang": "ts",
1549
+ "code": "import { defineForm, email, money, readForm, text, validate } from '@mccaskie-labs/workbench-forms'\nimport { boundsFor } from '@mccaskie-labs/workbench-forms/limits'\nimport { COUNTRY_PACKS } from '@mccaskie-labs/workbench-country'\n\nconst lead = defineForm('lead', { name: text({ limit: 'lead.name', required: true }), email: email({ required: true }), budget: money({ limit: 'lead.budget' }) })\nconst result = validate(lead, readForm(lead, await request.formData()), { bounds: boundsFor(LIMITS, lead), country: COUNTRY_PACKS.PH! })\nif (!result.ok) return show(result.problems, result.shown)\nawait db.from('leads').insert(result.values)",
1550
+ "probe": "forms.validate"
1551
+ }
1552
+ ],
1553
+ "probes": [
1554
+ {
1555
+ "id": "forms.validate",
1556
+ "half": "runtime",
1557
+ "expect": "pass",
1558
+ "smoke": true,
1559
+ "cost_hint_usd": 0,
1560
+ "note": "In the Worker itself: validates a fixed form (text, email, phone, money, date, choice) for PH with one good and one bad input, and compares the stored values (E.164 phone, decimal-string amount, ISO date) and the problem codes with the expected ones"
1561
+ },
1562
+ {
1563
+ "id": "forms.browser",
1564
+ "half": "runtime",
1565
+ "expect": "pass",
1566
+ "cost_hint_usd": 0,
1567
+ "note": "The probe page loads FORMS_SCRIPT and the form's embedConfig block, runs the same inputs in the browser and checks the verdict matches the Worker's; no Gateway call"
1568
+ }
1569
+ ]
1570
+ },
1478
1571
  "gateway": {
1479
1572
  "$schema": "./schema.json",
1480
1573
  "format": 1,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings", "ui", "forms"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -87,6 +87,46 @@ export declare const allowedSchemas: {
87
87
  }>>;
88
88
  }, z.core.$strict>;
89
89
  };
90
+ declare const offer: z.ZodObject<{
91
+ when: z.ZodString;
92
+ say: z.ZodString;
93
+ answers_file: z.ZodString;
94
+ per: z.ZodOptional<z.ZodString>;
95
+ generator: z.ZodOptional<z.ZodString>;
96
+ questions: z.ZodArray<z.ZodObject<{
97
+ id: z.ZodString;
98
+ sets: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
99
+ ask: z.ZodString;
100
+ style: z.ZodEnum<{
101
+ cards: "cards";
102
+ table: "table";
103
+ }>;
104
+ answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
105
+ label: z.ZodString;
106
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
107
+ means: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strict>>>;
109
+ other: z.ZodOptional<z.ZodObject<{
110
+ kind: z.ZodEnum<{
111
+ date: "date";
112
+ list: "list";
113
+ money: "money";
114
+ text: "text";
115
+ whole: "whole";
116
+ }>;
117
+ min: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
118
+ max: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
119
+ description: z.ZodOptional<z.ZodString>;
120
+ }, z.core.$strict>>;
121
+ for: z.ZodOptional<z.ZodArray<z.ZodString>>;
122
+ must_ask: z.ZodOptional<z.ZodBoolean>;
123
+ note: z.ZodOptional<z.ZodString>;
124
+ }, z.core.$strict>>;
125
+ not_asked: z.ZodOptional<z.ZodArray<z.ZodObject<{
126
+ sets: z.ZodString;
127
+ why: z.ZodString;
128
+ }, z.core.$strict>>>;
129
+ }, z.core.$strict>;
90
130
  /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
91
131
  export declare const capabilitySchema: z.ZodObject<{
92
132
  $schema: z.ZodLiteral<"./schema.json">;
@@ -103,6 +143,7 @@ export declare const capabilitySchema: z.ZodObject<{
103
143
  email: "email";
104
144
  "email-inbound": "email-inbound";
105
145
  "email-sending": "email-sending";
146
+ forms: "forms";
106
147
  gateway: "gateway";
107
148
  github: "github";
108
149
  "image-search": "image-search";
@@ -220,8 +261,49 @@ export declare const capabilitySchema: z.ZodObject<{
220
261
  two_step: z.ZodOptional<z.ZodBoolean>;
221
262
  note: z.ZodOptional<z.ZodString>;
222
263
  }, z.core.$strict>>;
264
+ offer: z.ZodOptional<z.ZodObject<{
265
+ when: z.ZodString;
266
+ say: z.ZodString;
267
+ answers_file: z.ZodString;
268
+ per: z.ZodOptional<z.ZodString>;
269
+ generator: z.ZodOptional<z.ZodString>;
270
+ questions: z.ZodArray<z.ZodObject<{
271
+ id: z.ZodString;
272
+ sets: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
273
+ ask: z.ZodString;
274
+ style: z.ZodEnum<{
275
+ cards: "cards";
276
+ table: "table";
277
+ }>;
278
+ answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
279
+ label: z.ZodString;
280
+ value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
281
+ means: z.ZodOptional<z.ZodString>;
282
+ }, z.core.$strict>>>;
283
+ other: z.ZodOptional<z.ZodObject<{
284
+ kind: z.ZodEnum<{
285
+ date: "date";
286
+ list: "list";
287
+ money: "money";
288
+ text: "text";
289
+ whole: "whole";
290
+ }>;
291
+ min: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
292
+ max: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
293
+ description: z.ZodOptional<z.ZodString>;
294
+ }, z.core.$strict>>;
295
+ for: z.ZodOptional<z.ZodArray<z.ZodString>>;
296
+ must_ask: z.ZodOptional<z.ZodBoolean>;
297
+ note: z.ZodOptional<z.ZodString>;
298
+ }, z.core.$strict>>;
299
+ not_asked: z.ZodOptional<z.ZodArray<z.ZodObject<{
300
+ sets: z.ZodString;
301
+ why: z.ZodString;
302
+ }, z.core.$strict>>>;
303
+ }, z.core.$strict>>;
223
304
  }, z.core.$strict>;
224
305
  export type Capability = z.infer<typeof capabilitySchema>;
306
+ export type Offer = z.infer<typeof offer>;
225
307
  export interface ManifestProblem {
226
308
  file: string;
227
309
  check: "schema" | "proof" | "ids" | "surfaces" | "errors" | "generated";
@@ -235,3 +317,4 @@ export declare function checkManifests(files: Record<string, unknown>): {
235
317
  manifests: Capability[];
236
318
  problems: ManifestProblem[];
237
319
  };
320
+ export {};
@@ -24,6 +24,7 @@ export const COMPONENTS = [
24
24
  "github",
25
25
  "strings",
26
26
  "ui",
27
+ "forms",
27
28
  ];
28
29
  const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
29
30
  const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");
@@ -135,6 +136,42 @@ export const allowedSchemas = {
135
136
  environments: z.array(envName).min(1),
136
137
  }),
137
138
  };
139
+ // Client questions (docs/capabilities-manifest.md §9): what Claude Code asks
140
+ // the client when it builds with this component. A project skill is
141
+ // generated from it (`workbench-config skills`).
142
+ const answerValue = z.union([z.string(), z.number(), z.boolean(), z.null()]);
143
+ const otherRule = z.strictObject({
144
+ kind: z.enum(["text", "whole", "money", "date", "list"]),
145
+ min: z.union([z.number(), z.string()]).optional(),
146
+ max: z.union([z.number(), z.string()]).optional(),
147
+ description: z.string().min(1).optional(),
148
+ });
149
+ const question = z.strictObject({
150
+ id,
151
+ sets: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
152
+ ask: z.string().min(1),
153
+ /** `cards`: two to four answers, recommended first. `table`: a table of proposed values to accept or correct. */
154
+ style: z.enum(["cards", "table"]),
155
+ answers: z
156
+ .array(z.strictObject({ label: z.string().min(1).max(40), value: answerValue, means: z.string().min(1).optional() }))
157
+ .min(2)
158
+ .max(4)
159
+ .optional(),
160
+ /** Absent: "Other" isn't allowed. Present: what a typed answer must satisfy. */
161
+ other: otherRule.optional(),
162
+ for: z.array(z.string().min(1)).min(1).optional(),
163
+ must_ask: z.boolean().optional(),
164
+ note: z.string().min(1).optional(),
165
+ });
166
+ const offer = z.strictObject({
167
+ when: z.string().min(1),
168
+ say: z.string().min(1),
169
+ answers_file: z.string().regex(/^workbench\/[a-z0-9<>/._-]+\.json$/, "a JSON file under workbench/"),
170
+ per: z.string().min(1).optional(),
171
+ generator: z.string().min(1).optional(),
172
+ questions: z.array(question).min(1),
173
+ not_asked: z.array(z.strictObject({ sets: z.string().min(1), why: z.string().min(1) })).min(1).optional(),
174
+ });
138
175
  const baseShape = {
139
176
  $schema: z.literal("./schema.json"),
140
177
  format: z.literal(1),
@@ -157,6 +194,7 @@ const baseShape = {
157
194
  rules: z.array(rule).min(1),
158
195
  examples: z.array(example).min(1).optional(),
159
196
  probes: z.array(probe).min(1),
197
+ offer: offer.optional(),
160
198
  };
161
199
  /** One file in `capabilities/`. Parsing checks shape only; cross-references are checked by `checkManifests`. */
162
200
  export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) => {
@@ -174,6 +212,39 @@ export const capabilitySchema = z.strictObject(baseShape).superRefine((m, ctx) =
174
212
  }
175
213
  }
176
214
  });
215
+ function checkOffer(file, component, o, seen) {
216
+ const problems = [];
217
+ const named = new Map();
218
+ const name = (option, by) => {
219
+ const prior = named.get(option);
220
+ if (prior)
221
+ problems.push({ file, check: "ids", message: `offer: ${option} is set by ${prior} and ${by}` });
222
+ else
223
+ named.set(option, by);
224
+ };
225
+ for (const q of o.questions) {
226
+ seen("question", q.id);
227
+ if (!q.id.startsWith(`${component}.`))
228
+ problems.push({ file, check: "ids", message: `question ${q.id} must start with ${component}.` });
229
+ if (q.style === "cards" && !q.answers)
230
+ problems.push({ file, check: "schema", message: `question ${q.id} is cards but has no answers` });
231
+ if (q.style === "table" && q.answers)
232
+ problems.push({ file, check: "schema", message: `question ${q.id} is a table, so it lists no answers` });
233
+ const labels = new Set();
234
+ for (const a of q.answers ?? []) {
235
+ if (labels.has(a.label))
236
+ problems.push({ file, check: "ids", message: `question ${q.id} has the answer ${a.label} twice` });
237
+ labels.add(a.label);
238
+ if (/^other$/i.test(a.label))
239
+ problems.push({ file, check: "schema", message: `question ${q.id}: "Other" is added by Claude Code; use \`other\` instead` });
240
+ }
241
+ for (const option of typeof q.sets === "string" ? [q.sets] : q.sets)
242
+ name(option, `question ${q.id}`);
243
+ }
244
+ for (const n of o.not_asked ?? [])
245
+ name(n.sets, "not_asked");
246
+ return problems;
247
+ }
177
248
  /**
178
249
  * The checks that need only the manifests themselves (§5: schema, proof, ids).
179
250
  * `files` maps a file name (e.g. `ai.json`) to its parsed JSON.
@@ -243,6 +314,8 @@ export function checkManifests(files) {
243
314
  if (a.gate === "two_step_when" && !a.when)
244
315
  problems.push({ file, check: "schema", message: `action ${a.tool} is two_step_when but has no when` });
245
316
  }
317
+ if (m.offer)
318
+ problems.push(...checkOffer(file, m.component, m.offer, seen));
246
319
  }
247
320
  return { manifests, problems };
248
321
  }
package/dist/cli.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  // `npx workbench-config check [dir]`: compares the variable names in
3
3
  // <dir>/.dev.vars with <dir>/.dev.vars.example. Prints names only.
4
- import { readFileSync, existsSync } from "node:fs";
5
- import { join } from "node:path";
4
+ // `npx workbench-config skills [--check] [dir]`: writes (or, with --check,
5
+ // compares) the project skills generated from the manifests' offer sections
6
+ // (docs/capabilities-manifest.md §9).
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
6
10
  import { compareDevVars } from "./dev-vars.js";
7
- function main(argv) {
8
- const [command, dir = "."] = argv;
9
- if (command !== "check") {
10
- console.error("Usage: workbench-config check [dir]");
11
- return 2;
12
- }
11
+ import { planSkills } from "./skills-plan.js";
12
+ const USAGE = "Usage: workbench-config check [dir]\n workbench-config skills [--check] [dir]";
13
+ function checkDevVars(dir) {
13
14
  const examplePath = join(dir, ".dev.vars.example");
14
15
  const devVarsPath = join(dir, ".dev.vars");
15
16
  if (!existsSync(examplePath)) {
@@ -31,4 +32,50 @@ function main(argv) {
31
32
  console.error(`In .dev.vars but not in .dev.vars.example: ${result.extra.join(", ")}`);
32
33
  return 1;
33
34
  }
35
+ function skills(args) {
36
+ const check = args.includes("--check");
37
+ const dir = args.find((a) => a !== "--check") ?? ".";
38
+ const pkg = join(dirname(fileURLToPath(import.meta.url)), "..");
39
+ const version = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")).version;
40
+ const { files, stale, problems } = planSkills(join(pkg, "capabilities"), dir);
41
+ if (problems.length) {
42
+ for (const p of problems)
43
+ console.error(p);
44
+ return 1;
45
+ }
46
+ if (check) {
47
+ const wrong = Object.entries(files).filter(([path, text]) => !existsSync(join(dir, path)) || readFileSync(join(dir, path), "utf8") !== text).map(([path]) => path);
48
+ for (const path of wrong)
49
+ console.error(`${path} is missing or differs from workbench-config ${version}.`);
50
+ for (const path of stale)
51
+ console.error(`${path} is no longer generated by workbench-config ${version}.`);
52
+ if (wrong.length || stale.length) {
53
+ console.error("Run `npx workbench-config skills` and commit the result.");
54
+ return 1;
55
+ }
56
+ console.log(`Skills match workbench-config ${version} (${Object.keys(files).length}).`);
57
+ return 0;
58
+ }
59
+ for (const [path, text] of Object.entries(files)) {
60
+ mkdirSync(dirname(join(dir, path)), { recursive: true });
61
+ writeFileSync(join(dir, path), text);
62
+ console.log(`wrote ${path}`);
63
+ }
64
+ for (const path of stale) {
65
+ rmSync(join(dir, path));
66
+ if (readdirSync(dirname(join(dir, path))).length === 0)
67
+ rmSync(dirname(join(dir, path)), { recursive: true });
68
+ console.log(`removed ${path}`);
69
+ }
70
+ return 0;
71
+ }
72
+ function main(argv) {
73
+ const [command, ...rest] = argv;
74
+ if (command === "check")
75
+ return checkDevVars(rest[0] ?? ".");
76
+ if (command === "skills")
77
+ return skills(rest);
78
+ console.error(USAGE);
79
+ return 2;
80
+ }
34
81
  process.exitCode = main(process.argv.slice(2));
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { WORKBENCH_ENVS, DEFAULT_GATEWAY_URL, workbenchEnv, type WorkbenchEnv }
2
2
  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
- export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, } from "./capability-schema.ts";
5
+ export { capabilitySchema, checkManifests, allowedSchemas, COMPONENTS, type Capability, type Component, type ManifestProblem, type Offer, } from "./capability-schema.ts";
6
+ export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.ts";
6
7
  export { capabilities, MANIFEST_VERSION, type Alias, type GatewayScope } from "./capabilities.gen.ts";
7
8
  export { compareDevVars, parseDevVarNames } from "./dev-vars.ts";
8
9
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS, type Brand, type ResolvedBrand } from "./brand.ts";
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ 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, } from "./capability-schema.js";
6
+ export { renderSkill, renderSkills, skillName, SKILLS_DIR } from "./skills.js";
6
7
  export { capabilities, MANIFEST_VERSION } from "./capabilities.gen.js";
7
8
  export { compareDevVars, parseDevVarNames } from "./dev-vars.js";
8
9
  export { brandSchema, defineBrand, BRAND_FILE, resolveBrand, BRAND_DEFAULTS } from "./brand.js";
@@ -0,0 +1,7 @@
1
+ export declare const GENERATED_MARK = "Generated by `npx workbench-config skills`";
2
+ /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
3
+ export declare function planSkills(manifestDir: string, projectDir: string): {
4
+ files: Record<string, string>;
5
+ stale: string[];
6
+ problems: string[];
7
+ };
@@ -0,0 +1,26 @@
1
+ // Node only: reads a manifests directory and a project, for
2
+ // `workbench-config skills` (cli.ts). Kept out of index.ts, which runs in Workers.
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { checkManifests } from "./capability-schema.js";
6
+ import { renderSkills, SKILLS_DIR } from "./skills.js";
7
+ export const GENERATED_MARK = "Generated by `npx workbench-config skills`";
8
+ /** Skill files the manifests in `manifestDir` produce, and the generated ones in `projectDir` they don't. */
9
+ export function planSkills(manifestDir, projectDir) {
10
+ const json = {};
11
+ for (const f of readdirSync(manifestDir).filter((f) => f.endsWith(".json") && f !== "schema.json")) {
12
+ json[f] = JSON.parse(readFileSync(join(manifestDir, f), "utf8"));
13
+ }
14
+ const { manifests, problems } = checkManifests(json);
15
+ const files = renderSkills(manifests);
16
+ const stale = [];
17
+ const skillsDir = join(projectDir, SKILLS_DIR);
18
+ if (existsSync(skillsDir)) {
19
+ for (const d of readdirSync(skillsDir)) {
20
+ const path = `${SKILLS_DIR}/${d}/SKILL.md`;
21
+ if (d.startsWith("workbench-") && existsSync(join(projectDir, path)) && !(path in files) && readFileSync(join(projectDir, path), "utf8").includes(GENERATED_MARK))
22
+ stale.push(path);
23
+ }
24
+ }
25
+ return { files, stale, problems: problems.map((p) => `${p.file}: [${p.check}] ${p.message}`) };
26
+ }
@@ -0,0 +1,7 @@
1
+ import type { Capability } from "./capability-schema.ts";
2
+ export declare const SKILLS_DIR = ".claude/skills";
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>;
package/dist/skills.js ADDED
@@ -0,0 +1,91 @@
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.
6
+ export const SKILLS_DIR = ".claude/skills";
7
+ export const skillName = (component) => `workbench-${component}`;
8
+ const show = (v) => (v === null ? "none" : `\`${JSON.stringify(v)}\``);
9
+ const cell = (s) => s.replaceAll("|", "\\|").replaceAll("\n", " ");
10
+ const sets = (q) => (typeof q.sets === "string" ? [q.sets] : q.sets).map((s) => `\`${s}\``).join(", ");
11
+ function otherLine(q) {
12
+ if (!q.other)
13
+ return "Other: not allowed. Only the answers above are accepted.";
14
+ const { kind, min, max, description } = q.other;
15
+ const range = min !== undefined && max !== undefined ? ` from ${min} to ${max}` : min !== undefined ? ` of at least ${min}` : max !== undefined ? ` of at most ${max}` : "";
16
+ const what = { text: "text", whole: "a whole number", money: "an amount", date: "a date (YYYY-MM-DD)", list: "a list, one item per line" }[kind];
17
+ const length = kind === "text" && range ? ` (length in characters)` : "";
18
+ return `Other: allowed. The client must type ${what}${range}${length}.${description ? ` ${description}` : ""} Refuse anything else and ask again.`;
19
+ }
20
+ function questionBlock(q, n) {
21
+ const lines = [`### ${n}. ${q.ask}`, "", `Id \`${q.id}\`. Sets ${sets(q)}.`];
22
+ if (q.for)
23
+ lines.push(`Only for: ${q.for.map((f) => `\`${f}\``).join(", ")}.`);
24
+ if (q.must_ask)
25
+ lines.push("**Must ask:** there is no safe default, so always ask it, even when the client accepts every recommendation.");
26
+ lines.push("");
27
+ if (q.style === "table") {
28
+ lines.push("Ask as a table: propose a value for each row, show the table, then ask one question with the answers \"Accept all (Recommended)\", \"Change some\" and \"Add or remove\".");
29
+ }
30
+ else {
31
+ lines.push("| Answer | Value | Means |", "| --- | --- | --- |");
32
+ (q.answers ?? []).forEach((a, i) => lines.push(`| ${cell(a.label)}${i === 0 ? " (Recommended)" : ""} | ${show(a.value)} | ${cell(a.means ?? "")} |`));
33
+ }
34
+ lines.push("", otherLine(q));
35
+ if (q.note)
36
+ lines.push("", q.note);
37
+ return lines.join("\n");
38
+ }
39
+ /** The SKILL.md for one manifest, or undefined when it has no offer. */
40
+ export function renderSkill(m) {
41
+ const o = m.offer;
42
+ if (!o)
43
+ return undefined;
44
+ const description = `${o.when} Offers Workbench ${m.title} and asks the client for its settings.`.replaceAll("\n", " ");
45
+ const out = [
46
+ "---",
47
+ `name: ${skillName(m.component)}`,
48
+ `description: ${JSON.stringify(description)}`,
49
+ "---",
50
+ "",
51
+ `<!-- 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. -->`,
52
+ "",
53
+ `# ${m.title}`,
54
+ "",
55
+ m.summary,
56
+ "",
57
+ "## When this applies",
58
+ "",
59
+ o.when,
60
+ "",
61
+ "## What to say",
62
+ "",
63
+ `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.`,
64
+ "",
65
+ "## How to ask",
66
+ "",
67
+ "- 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.",
68
+ "- Where the client accepts every recommendation, still ask each \"Must ask\" question.",
69
+ "- 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.",
70
+ "- 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.",
71
+ `- Write the answers to \`${o.answers_file}\`${o.per ? `, one entry per ${o.per}` : ""}: 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 an out-of-range answer. Never edit what it writes; change the answers file and run it again.` : ""}`,
72
+ "",
73
+ "## Questions",
74
+ "",
75
+ o.questions.map((q, i) => questionBlock(q, i + 1)).join("\n\n"),
76
+ ];
77
+ if (o.not_asked?.length) {
78
+ out.push("", "## Not asked", "", ...o.not_asked.map((n) => `- \`${n.sets}\`: ${n.why}`));
79
+ }
80
+ return `${out.join("\n")}\n`;
81
+ }
82
+ /** Every skill the manifests produce, keyed by its path in a project. */
83
+ export function renderSkills(manifests) {
84
+ const files = {};
85
+ for (const m of manifests) {
86
+ const text = renderSkill(m);
87
+ if (text)
88
+ files[`${SKILLS_DIR}/${skillName(m.component)}/SKILL.md`] = text;
89
+ }
90
+ return files;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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",