389 Commits

Author SHA1 Message Date
bd115c8bee Prepare the ckconfig release candidate 2026-11-07 11:12:23 +00:00
f66502cb23 Record the core source release 2026-11-07 11:09:46 +00:00
7416bf6fdc Prepare the core release candidate 2026-11-07 05:08:12 +00:00
d14fa366f5 Align reporting contracts and documentation 2026-11-07 05:06:47 +00:00
1289868ed0 Update the composed PromptKit consumer 2026-11-07 05:01:30 +00:00
42af1da142 Update the simple configuration consumer 2026-11-07 04:54:44 +00:00
0b70f37267 Add detailed validation evidence 2026-11-07 04:52:58 +00:00
1986c44f49 Add detailed comparison assertions 2026-11-07 04:49:40 +00:00
65b454b2ab Add focused configuration reports 2026-11-07 04:47:23 +00:00
b38130548e Add configuration path discovery 2026-11-07 04:44:01 +00:00
aeb70e5873 Add opt-in configuration overrides 2026-11-07 04:39:34 +00:00
85ae55bf42 Separate comparison side selectors 2026-11-07 04:36:05 +00:00
da120c2dd4 Expose configuration metadata and findings 2026-11-07 04:32:24 +00:00
0183022a28 Add scoped PromptKit flag aliases 2026-11-07 04:27:28 +00:00
644e6c365b Add opt-in PromptKit namespaces 2026-11-07 04:23:39 +00:00
8e5e64c6e9 Add prepared request preview 2026-11-07 04:19:58 +00:00
c3d5f551c3 Add reusable PromptKit request flags 2026-11-07 04:16:09 +00:00
6378ceb3c5 Migrate PromptKit reports to shared rendering 2026-11-07 04:10:47 +00:00
5ff0dd4ea2 Adopt native PromptKit resource inspection 2026-11-07 04:05:51 +00:00
6c35da3ab6 Move configuration reports to shared rendering 2026-11-07 03:57:10 +00:00
7159f52f4d Preserve ordered flag occurrences 2026-11-07 03:49:34 +00:00
d748056652 Add CLI JSON value parsing 2026-11-07 03:46:25 +00:00
924692b541 Add shared report format flag 2026-11-07 03:45:12 +00:00
3a6c66f550 Connect report rendering to invocation outcomes 2026-11-07 03:41:21 +00:00
190bfbd77b Add shared report rendering 2026-11-07 03:40:09 +00:00
7693482a8b Add shared report value conversion 2026-11-07 03:38:22 +00:00
105bb741cc Plan shared reporting and PromptKit adoption 2026-11-07 03:33:32 +00:00
cb7e05284c Clarify native preview reporting and cancellation ownership 2026-11-07 03:17:23 +00:00
022055dc26 Retarget adoption roadmap to PromptKit v0.20.0 2026-11-07 03:05:00 +00:00
e908213318 Record ownership and Go-native principles in architecture policy 2026-11-07 02:49:13 +00:00
5d90d59996 Consolidate and renumber architectural decision records 2026-11-07 02:42:54 +00:00
76c00e32d0 Update documentation policy from upstream 2026-11-07 02:27:07 +00:00
9b85c7cf60 Consolidate adoption roadmap and finalize core prerequisites 2026-11-07 02:04:57 +00:00
ee3cb21ebc Settle override syntax and pass-through loading policy 2026-11-07 01:46:09 +00:00
93c16bd57e Close comparison collection scope and defer general clearing support 2026-11-07 01:36:07 +00:00
ca454624cc Apply ordinary Go numeric policy to configuration overrides 2026-11-07 01:32:44 +00:00
90d9706f6d Plan independent defaults for both configuration comparison sides 2026-11-07 01:19:05 +00:00
f494088124 Record namespace selector applicability and profile inspection behavior 2026-11-07 01:11:59 +00:00
5010f9a102 Record ordinary Go numeric types for CLI JSON inputs 2026-11-07 00:54:51 +00:00
3b6614605a Use a scope-keyed mapping for aggregate configuration reports 2026-11-07 00:41:56 +00:00
e7b00efc07 Simplify safe YAML conversion into shared report values 2026-11-07 00:32:51 +00:00
647a9d5372 Record ownership principles at the top of the roadmap 2026-11-06 22:02:32 +00:00
c52034f3fe Define original-value text templates and YAML fallback 2026-11-06 21:40:52 +00:00
408383db60 Adopt JSON conventions for shared report representation 2026-11-06 21:09:19 +00:00
e82101434f Select report formats automatically from content and templates 2026-11-06 20:05:24 +00:00
ffe568b6f0 Record universal report formats and optional command defaults 2026-11-06 19:49:36 +00:00
756954f056 Plan shared core report output before integration additions 2026-11-06 19:34:26 +00:00
b6a8f1f2cc Set initial report designs and lightweight YAML implementation guidance 2026-11-06 19:00:02 +00:00
d15926a878 Record remaining adoption scope and CLI decisions 2026-11-06 18:50:16 +00:00
7f2fdce8e7 Record optional validation details and failure evidence 2026-11-06 18:40:55 +00:00
491f0b49f4 Record optional detailed configuration diff report 2026-11-06 18:37:32 +00:00
61bcd80171 Record config explain modes and default help behavior 2026-11-06 18:27:39 +00:00
9076cb2c9c Record agreed namespace default composition 2026-11-06 18:21:14 +00:00
f63af12097 Defer appended-message flags and preserve operator use cases 2026-11-06 16:39:51 +00:00
111f963c67 Record explicit prompt input flags and text aliases 2026-11-06 13:56:45 +00:00
66a33756c3 Record reusable LLM flags and scoped convenience aliases 2026-11-06 12:29:25 +00:00
ff9f840edd Record agreed prepared request preview command 2026-11-06 03:52:51 +00:00
f6f41ee6aa Fold configuration discovery and diagnostics into existing commands 2026-11-06 03:38:12 +00:00
df33e8aefc Plan reusable PromptKit CLI flag collections 2026-11-06 03:28:21 +00:00
6830b96821 Expand operator roadmap with ConfigKit CLI features 2026-11-06 03:03:31 +00:00
42af12cb0e Plan PromptKit v0.19.0 adoption and operator CLI additions 2026-11-06 02:42:15 +00:00
0b4e47c420 Record PromptKit roadmap scope and backend inspection deferral 2026-11-06 02:34:43 +00:00
ad16bfc521 Retire completed PromptKit planning documents 2026-11-06 01:18:55 +00:00
540e2359d6 Complete PromptKit adoption validation 2026-11-06 01:05:51 +00:00
e28ba63b59 Document PromptKit adoption and release candidate 2026-11-06 01:03:55 +00:00
7f327758d8 Migrate composed PromptKit consumer 2026-11-06 01:01:13 +00:00
1e3879e880 Verify native bundle and configuration reports 2026-11-06 00:59:56 +00:00
33cc36ff3b Migrate native PromptKit test fixtures 2026-11-06 00:59:05 +00:00
983c22be77 Remove obsolete prompt version reports 2026-11-06 00:57:50 +00:00
f933f056d7 Align PromptKit dependency identities 2026-11-06 00:55:48 +00:00
cf50b135f2 Plan PromptKit adoption implementation 2026-11-05 19:54:05 +00:00
ee0debe4fb Record PromptKit adoption scope and completion criteria 2026-11-04 17:09:47 +00:00
4f851371da Record PromptKit capability options and scope decisions 2026-11-04 16:54:29 +00:00
69cc6429ff Plan PromptKit 0.18 adoption 2026-11-04 16:35:03 +00:00
eeae10fa05 Record ckconfig release completion 2026-11-04 16:17:49 +00:00
bc1d1d5844 Retire completed roadmaps and collect future work 2026-11-04 16:04:32 +00:00
260256d722 Complete ConfigKit adoption validation 2026-11-04 15:11:05 +00:00
5a79b56e3c Align ckconfig dependency attribution 2026-11-04 15:07:05 +00:00
5ccdcb91c9 Document ConfigKit adoption 2026-11-04 15:05:42 +00:00
42176a7ac4 Test configuration source evidence 2026-11-04 15:02:06 +00:00
72099958d1 Expose current configuration source evidence 2026-11-04 15:00:04 +00:00
2904eadf4f Adopt ConfigKit module and APIs 2026-11-04 14:58:20 +00:00
1c778d7ad3 Plan ConfigKit adoption sprint 2026-11-04 14:42:23 +00:00
16584084b1 Record ConfigKit adoption scope and deferred inspection work 2026-11-04 14:32:02 +00:00
ce8ab3be38 Expand ConfigKit roadmap with API coverage findings 2026-11-04 13:25:20 +00:00
7a9e3a1f7c Plan ConfigKit 1.1 adoption 2026-11-04 13:14:31 +00:00
60841227b7 Complete vanity migration roadmap 2026-11-04 03:56:33 +00:00
3699c6c5e6 Record ckprompt source release 2026-11-04 03:55:50 +00:00
3f1ed2f107 Migrate ckprompt to the vanity module path 2026-11-04 03:55:17 +00:00
4a8f73f289 Record ckconfig source release 2026-11-04 03:53:20 +00:00
92a327db97 Migrate ckconfig to the vanity module path 2026-11-04 03:52:46 +00:00
f0121ad466 Record core source release 2026-11-04 03:50:15 +00:00
e256eb1cc0 Migrate core to the vanity module path 2026-11-04 03:49:38 +00:00
881dae27c4 Plan vanity migration and integration releases 2026-11-04 03:44:22 +00:00
c8d659dfe5 Plan CommandKit vanity path migration 2026-11-04 03:37:44 +00:00
d7cbaed3ed Plan integration dependency alignment 2026-11-04 03:28:25 +00:00
289a171e97 Complete ReleaseKit adoption roadmap 2026-11-04 03:04:15 +00:00
2cde616ce5 Record integration release validation 2026-11-04 03:03:44 +00:00
42d1774019 Record root release validation 2026-11-04 03:03:09 +00:00
bdc3fd8fa3 Update ReleaseKit documentation and attribution 2026-11-04 03:02:16 +00:00
eb974b6d29 Upgrade ReleaseKit to version 0.9.0 2026-11-04 03:01:25 +00:00
0dc4ac41f0 Make completion alias assertion order independent 2026-11-04 03:00:58 +00:00
129f95be0f Plan ReleaseKit v0.9.0 implementation 2026-11-04 02:52:09 +00:00
87eb84ccb4 Record release code retention decisions 2026-11-04 02:44:34 +00:00
0fc00039bd Update ReleaseKit adoption plan after remediation 2026-11-04 02:38:06 +00:00
4d92ee4ee6 Restore optional shell handling in launcher tests 2026-11-04 02:34:30 +00:00
7eac3fea65 Reject invalid legacy release selectors 2026-11-04 02:29:09 +00:00
12a6fa66fa Plan ReleaseKit v0.9.0 adoption 2026-11-04 02:26:33 +00:00
98e7e99333 Record release tooling review findings 2026-11-03 15:53:45 +00:00
124a9fa57f Complete release tooling roadmap 2026-11-03 15:47:34 +00:00
75c0d5bce2 Record release tooling validation 2026-11-03 15:47:18 +00:00
007eae4ed0 Document release delivery workflow 2026-11-03 15:46:20 +00:00
48fd2dc7bc Test release launcher wiring 2026-11-03 15:45:45 +00:00
b94ed6665b Centralize release tool invocation 2026-11-03 15:45:15 +00:00
7344994198 Configure release checks and delivery 2026-11-03 15:44:56 +00:00
cb40f1d9f8 Finalize release tooling implementation plan 2026-11-03 15:36:23 +00:00
3d396b1a66 Plan hosted Gitea release delivery 2026-11-03 15:26:02 +00:00
09bb51d1c3 Plan release tooling simplification 2026-11-03 15:21:51 +00:00
a74fcb2004 Upgrade ReleaseKit and align release profiles with SSH 2026-11-03 15:09:19 +00:00
3934038b98 Retire completed shell completion roadmaps 2026-11-03 14:47:36 +00:00
0acbbf45ad Skip sorting flag aliases during completion 2026-11-03 13:15:21 +00:00
f4ed4c4839 Index completion metadata by canonical node 2026-11-03 13:13:20 +00:00
5227049de6 Suggest PromptKit smoke test kinds 2026-11-03 13:13:01 +00:00
8964ad02ad Document Bash completion setup 2026-11-03 13:12:36 +00:00
f79f446726 Restore comparability of completion metadata 2026-11-03 13:12:22 +00:00
b83c0a0dfc Separate completion metadata from literal choices 2026-11-03 13:12:05 +00:00
f39f27b5cd Use parsed replacement prefixes in shell completion 2026-11-03 13:11:07 +00:00
8b48115328 Keep completion words out of shell evaluation 2026-11-03 13:09:00 +00:00
c205903900 Record shell completion efficiency findings 2026-11-03 12:54:34 +00:00
c87bba3d2e Record shell completion interaction finding 2026-11-03 12:50:29 +00:00
d3d40acea0 Record shell completion audit findings 2026-11-03 12:48:52 +00:00
0450ecfc16 Validate shell completion across supported shells 2026-11-03 04:16:39 +00:00
96250d1887 Add completion hints to maintained commands 2026-11-03 04:09:27 +00:00
d1f31d2931 Deliver shell completion query replies 2026-11-03 04:06:05 +00:00
4c47a0ca0b Interpret flags and operands for completion 2026-11-03 03:54:22 +00:00
8bb5383169 Resolve command context for completion 2026-11-03 03:51:26 +00:00
fc2f6d5346 Install shell completion as an optional command 2026-11-03 03:50:12 +00:00
0bed76a6f7 Capture completion hints in command metadata 2026-11-03 03:47:23 +00:00
1d193c87fe Verify shell completion transport 2026-11-03 03:45:47 +00:00
10a799a2b7 Plan shell completion implementation 2026-11-03 03:32:18 +00:00
230187296f Stop flag interpretation at unknown flags 2026-10-26 14:37:33 +00:00
473fb027a1 Avoid retaining hidden simple deferred messages 2026-10-26 14:35:44 +00:00
135d8da2b9 Resolve flag aliases in failed command routes 2026-10-26 14:33:31 +00:00
de2266af75 Restore cancellation checks before report delivery 2026-10-26 14:32:03 +00:00
13a4d4eacd Explain invocation presentation failures 2026-10-26 14:28:58 +00:00
215fc34d51 Preserve joined rendering error evidence 2026-10-26 14:27:24 +00:00
3338b902a2 Present native PromptKit operation failures 2026-10-26 14:18:21 +00:00
41c6ca8070 Record reporting efficiency opportunities 2026-10-26 14:14:50 +00:00
ee76b0f5f1 Record reporting integration audit findings 2026-10-26 14:01:28 +00:00
1e532cdfa5 Record reporting audit findings 2026-10-26 12:41:36 +00:00
7c035ccc54 Validate invocation reporting implementation 2026-10-26 03:59:28 +00:00
96aa6b5ca0 Document invocation reporting contracts 2026-10-26 03:57:11 +00:00
a6c0fb5b0d Reconcile reporting tests and boundaries 2026-10-26 03:51:14 +00:00
6264a87183 Migrate maintained examples to invocation reporting 2026-10-26 03:44:19 +00:00
b6f22462d3 Add a core reporting example 2026-10-26 03:38:48 +00:00
401232a1b0 Migrate PromptKit validation and smoke reporting 2026-10-26 03:35:39 +00:00
4ace616bc2 Fix PromptKit inspection reporter assertions 2026-10-26 03:34:50 +00:00
7c010e945a Update requirements inspection reporting tests 2026-10-26 03:31:06 +00:00
7fc89768ef Migrate PromptKit inspection reports to the reporter 2026-10-26 03:30:19 +00:00
37dfea5b25 Separate PromptKit messages from terminal context 2026-10-26 03:26:14 +00:00
c0e0010683 Migrate ConfigKit reports to the reporter 2026-10-26 03:20:17 +00:00
5f37aac70f Render ConfigKit show and validate through reporter 2026-10-26 03:16:02 +00:00
9a4d1db955 Separate ConfigKit messages from explanations 2026-10-26 03:10:18 +00:00
f3e9326544 Add debug events to command execution 2026-10-26 03:03:57 +00:00
7a1067d387 Route help and version through the reporter 2026-10-26 03:02:08 +00:00
49a10690c6 Connect invocation execution to one reporter 2026-10-26 02:59:28 +00:00
df21b75f9c Preserve reporting flag occurrence order 2026-10-26 02:55:08 +00:00
f3bd6dc2ae Install shared reporting flags 2026-10-26 02:50:54 +00:00
6ba9338c0a Add deferred reporting finalization 2026-10-26 02:44:41 +00:00
5df88a6d94 Add atomic reporting and message categories 2026-10-26 02:42:22 +00:00
f5a5bbac58 Add managed reporter output state 2026-10-26 02:39:16 +00:00
e56537690b Define invocation outcome reporting types 2026-10-26 02:37:19 +00:00
2a1096bd1f Plan invocation reporting implementation 2026-10-26 01:39:40 +00:00
1d29cdd029 Clarify provider diagnostics and reporting edge cases 2026-10-26 01:25:11 +00:00
e48187d822 Accept invocation reporting architecture and clarify error ownership 2026-10-26 01:18:28 +00:00
e951a48902 Specify invocation reporting API signatures 2026-10-26 00:10:36 +00:00
976ed89403 Clarify reporting lifecycle and concurrency contracts 2026-10-26 00:00:25 +00:00
a5a92c7f08 Settle reporting delivery lifecycle and quiet rendering 2026-10-24 02:01:59 +00:00
514c4d47bb Settle failure presentation and reporting retention boundaries 2026-10-24 01:56:41 +00:00
ec29d658b7 Clarify application error handling and reporting ownership 2026-10-24 01:50:38 +00:00
085c296d89 Define terminal failure presentation for invocation reporting 2026-10-24 01:19:16 +00:00
0869b17b43 Select unified invocation reporting API direction 2026-10-24 01:01:48 +00:00
810333e376 Record progress controls and reporting prefixes 2026-10-24 00:42:35 +00:00
2e337155ce Settle reporting flag grammar and core debug guidance 2026-10-23 18:42:21 +00:00
fa0aeace36 Expand reporting categories and define failure-bound errors 2026-10-23 18:08:13 +00:00
09d4d7992a Draft output policy and invocation reporting roadmap 2026-10-23 14:08:10 +00:00
c70e29d3fd Remove completed alias interaction remediation document 2026-10-20 04:12:02 +00:00
e47ae799d7 Keep empty and dash operands with runnable parents 2026-10-20 01:07:23 +00:00
5c26d80228 Retain discovery prefixes in usage diagnostics 2026-10-20 01:04:56 +00:00
bafd365115 Discover help routes through intermediate aliases 2026-10-20 01:03:54 +00:00
4ddf960836 Record alias interaction audit findings 2026-10-20 00:39:24 +00:00
08ad7427e2 Remove completed alias planning and remediation documents 2026-10-20 00:33:38 +00:00
f42aefe02d Reconcile alias documentation with the completed implementation 2026-10-20 00:30:53 +00:00
520d7e125b Retain entered flag spellings in parser failures 2026-10-20 00:29:49 +00:00
eb92f7be7f Preserve double dash values during flag alias parsing 2026-10-20 00:28:38 +00:00
744b8135f2 Show aliases beneath empty command groups 2026-10-20 00:26:07 +00:00
c231a41665 Compose discovery help through group aliases 2026-10-20 00:25:33 +00:00
fa09edf8cc Require exact resolved paths for explicit help 2026-10-20 00:23:19 +00:00
7dad20680e Keep command routing independent of operand counts 2026-10-20 00:22:25 +00:00
ccf8acd861 Protect canonical operand prefixes from aliases 2026-10-20 00:20:40 +00:00
c225a66c97 Record alias implementation review findings 2026-10-20 00:03:54 +00:00
a54a9354b1 Verify and complete alias implementation 2026-10-19 20:02:07 +00:00
42d9989533 Prepare core alias release notes 2026-10-19 20:00:09 +00:00
333a630cf1 Document application-owned alias contracts 2026-10-19 19:59:41 +00:00
666c006a89 Add aliases to maintained examples 2026-10-19 19:56:33 +00:00
c3bb3941d3 Verify typed alias composition in integrations 2026-10-19 19:53:33 +00:00
f34ab12ca5 Verify public alias registration flows 2026-10-19 19:48:57 +00:00
1fa026e5bd Preserve alias context in usage errors 2026-10-19 19:46:22 +00:00
a504efbad7 Render help through command aliases 2026-10-19 19:40:55 +00:00
451a0c483f Expose canonical paths on command invocations 2026-10-19 19:33:49 +00:00
75fc45f072 Interpret command flag aliases in the parser 2026-10-19 19:31:23 +00:00
58b71c390f Resolve entered command routes to canonical paths 2026-10-19 17:37:59 +00:00
6ea8c4dd3f Compile effective flag aliases during installation 2026-10-19 17:33:39 +00:00
2ab87e9333 Install command aliases atomically 2026-10-19 17:27:17 +00:00
7a4efddab8 Add captured alias declaration types 2026-10-19 17:23:32 +00:00
854426a0c9 Validate command alias termination 2026-10-19 15:16:07 +00:00
80d4d6e1bd Model command alias prefix rewriting 2026-10-19 15:11:43 +00:00
b3fb795224 Add shared command path primitives 2026-10-19 15:08:00 +00:00
d7cc12a347 Plan application alias implementation 2026-10-19 14:41:10 +00:00
f3b8a129d9 Correct alias examples to respect smoke-test operand grammar 2026-10-19 14:26:49 +00:00
fae8c4dafa Accept ADR 0013 for application alias routing 2026-10-19 14:21:12 +00:00
ca85d5b012 Record future shell completion direction for aliases 2026-10-19 14:16:05 +00:00
93ee328779 Clarify alias routing interactions and implementation checks 2026-10-19 14:06:10 +00:00
c6c4b01902 Resolve alias declaration API and shared path handling 2026-10-19 13:48:07 +00:00
55330d2817 Settle short alias grammar and empty-value correction 2026-10-19 12:19:30 +00:00
fbef0f2ecc Record flag alias chains short spellings and help decisions 2026-10-19 01:44:38 +00:00
573a22a38e Record flag alias scope and target availability decisions 2026-10-19 01:21:33 +00:00
f526198059 Record alias invocation metadata decision 2026-10-19 01:18:13 +00:00
4b53c96cf3 Record alias help and discovery decisions 2026-10-19 01:16:50 +00:00
50c54c229e Record alias chain and group composition decisions 2026-10-19 00:20:47 +00:00
0dfeac3022 Record alias prefix and operand collision decisions 2026-10-19 00:19:28 +00:00
8d5966a868 Record alias registration conventions in roadmap 2026-10-18 23:51:39 +00:00
a4cf55f7a5 Record canonical collision and deterministic alias rules 2026-10-18 21:12:53 +00:00
1f3d1594c9 Add flag alias direction to alias roadmap 2026-10-18 16:09:15 +00:00
ae6af1dc65 Record application alias routing roadmap 2026-10-18 15:40:31 +00:00
df6ed89a97 Record published dependencies for ckprompt 2026-10-18 13:54:21 +00:00
a320c76574 Record published core dependency checksums for ckconfig 2026-10-18 13:53:48 +00:00
841e60fed3 Prepare core and integration release notes 2026-10-18 13:51:15 +00:00
654d1b4a6d Retire completed upgrade planning documents 2026-10-18 13:48:31 +00:00
a6278a6d9f Mark the upgrade roadmap as implemented 2026-10-18 13:32:56 +00:00
114c7da7be Keep aggregate smoke tests focused on behavior 2026-10-18 13:30:02 +00:00
94c352dbd6 Test smoke forwarding without private PromptKit assumptions 2026-10-18 13:29:13 +00:00
1d0085450a Preserve empty smoke error messages in aggregate reports 2026-10-18 13:28:29 +00:00
0fe77baa29 Record dependency and smoke review findings 2026-10-18 03:52:35 +00:00
730f930b47 Verify dependency upgrade and smoke integration 2026-10-17 19:19:04 +00:00
e653b7d6ec Document PromptKit catalogs and smoke testing 2026-10-17 19:15:58 +00:00
8d1f33a40e Reconcile integration dependency graphs and notices 2026-10-17 19:08:04 +00:00
35e958c210 Demonstrate PromptKit catalogs and smoke tests 2026-10-17 19:06:05 +00:00
079576361b Verify PromptKit smoke outcomes and delivery 2026-10-17 19:01:34 +00:00
80f31d2ec0 Verify PromptKit smoke command lifecycles 2026-10-17 19:00:03 +00:00
2e14d973ee Coordinate aggregate PromptKit smoke tests 2026-10-17 18:57:37 +00:00
20ef7bb8c4 Add PromptKit single smoke test command 2026-10-17 18:55:41 +00:00
1650680723 Project native smoke reports to YAML 2026-10-17 18:51:17 +00:00
3e9c871cdc Add PromptKit smoke test adapter 2026-10-17 18:49:18 +00:00
7a91f722fd Verify catalog capability lifecycles 2026-10-17 18:46:47 +00:00
504ea03574 Add filtered PromptKit catalog commands 2026-10-17 18:45:39 +00:00
dc4261a1ca Extend native catalog adapters 2026-10-17 18:44:10 +00:00
c1fa18629d Migrate prompt fixtures to direct definitions 2026-10-17 18:39:38 +00:00
66b2994e48 Migrate prompt integration to PromptKit 0.16 2026-10-17 18:34:03 +00:00
b56cae70b9 Expose ConfigKit source evidence in reports 2026-10-17 18:31:11 +00:00
3124d572cf Adopt ConfigKit v0.8.0 in both integrations 2026-10-17 18:29:39 +00:00
b2caf8371a Adopt ReleaseKit v0.4.2 2026-10-17 18:28:01 +00:00
29582dd6b7 Plan dependency upgrades and PromptKit diagnostics 2026-10-17 18:10:34 +00:00
38b49f29fb Record PromptKit test commands and aggregate behavior 2026-10-17 16:34:07 +00:00
f173fdbc1b Keep upgrade adapters and examples focused 2026-10-17 15:54:19 +00:00
7eb859d036 Record upgrade decisions and include smoke testing 2026-10-17 15:52:15 +00:00
502ea5eca8 Clarify ownership of upstream integration tests 2026-10-17 15:37:20 +00:00
7b9b945baa Plan ConfigKit, PromptKit, and ReleaseKit upgrades 2026-10-17 15:02:03 +00:00
5f633e3fee Check validation diagnostics without fixing their layout 2026-10-12 23:43:16 +00:00
775c32479d Allow early missing-selector failures in validation tests 2026-10-12 23:42:18 +00:00
99e2089e75 Record PromptKit validation review findings 2026-10-12 23:27:27 +00:00
420483b2d4 Mark PromptKit validation implementation complete 2026-10-12 22:27:28 +00:00
88d4059e8d Document PromptKit request validation 2026-10-12 22:25:43 +00:00
be8bd9c1ea Extend composed consumer with request validation 2026-10-12 22:22:51 +00:00
9e6431be63 Verify validation outcomes and report delivery 2026-10-12 22:20:07 +00:00
346bcaeddf Cover validation command lifecycle and grammar 2026-10-12 22:18:50 +00:00
686a7ba0ab Add PromptKit requirements validation command 2026-10-12 22:16:44 +00:00
f5c1c69ccb Verify native PromptKit request validation 2026-10-12 22:15:06 +00:00
7fc5423688 Add PromptKit request validation adapter 2026-10-12 22:13:16 +00:00
1c542505b7 Adopt PromptKit v0.15.0 in ckprompt 2026-10-12 22:11:38 +00:00
3218222e53 Plan PromptKit request validation implementation 2026-10-12 22:05:16 +00:00
1119bda6b7 Settle PromptKit request validation roadmap 2026-10-12 21:53:52 +00:00
87391903f4 Plan PromptKit request validation adoption 2026-10-12 21:37:43 +00:00
700f10d78e Remove completed requirements planning records 2026-10-12 21:08:42 +00:00
40499a4360 Check default YAML output semantically 2026-10-12 17:45:35 +00:00
e5faa1855d Document requirements variable report fields 2026-10-12 17:43:54 +00:00
92c1d49ade Clarify requirements flag repetition 2026-10-12 17:42:32 +00:00
5dc83d9460 Record requirements inspection review findings 2026-10-12 17:39:38 +00:00
cfc2d1dfd0 Complete PromptKit requirements implementation 2026-10-12 17:30:37 +00:00
d3cbfc4e9a Document PromptKit requirements discovery 2026-10-12 17:26:11 +00:00
3710492d2d Demonstrate requirements discovery in composed example 2026-10-12 17:17:49 +00:00
b27cf02c0e Verify PromptKit requirements command behavior 2026-10-12 17:14:12 +00:00
7dce7b816b Add PromptKit requirements command 2026-10-12 17:05:33 +00:00
0276930b12 Add PromptKit requirements YAML report 2026-10-12 16:57:23 +00:00
ebd29598db Add PromptKit requirements adapter 2026-10-12 16:54:00 +00:00
3935dcdbda Plan requirements inspection implementation 2026-10-12 16:44:39 +00:00
49b7e686b3 Settle PromptKit requirements inspection roadmap 2026-10-12 16:34:56 +00:00
fa0cd9a73b Plan PromptKit requirements inspection 2026-10-12 16:24:43 +00:00
12b52b3174 Remove completed roadmap and remediation records 2026-10-12 15:27:27 +00:00
8a82620dfc Check PromptKit capabilities without assuming their order 2026-10-12 15:11:13 +00:00
61f0d8806d Use the declared YAML dependency in comparison tests 2026-10-12 15:08:44 +00:00
b9d5ea4b1b Record PromptKit implementation review findings 2026-10-12 14:02:26 +00:00
f8f8d27e71 Validate PromptKit comparison and catalogs 2026-10-12 02:47:08 +00:00
def9b90b7c Update PromptKit notices and release notes 2026-10-12 02:42:52 +00:00
ee038df530 Document PromptKit comparison and catalogs 2026-10-12 02:38:27 +00:00
4bc0689c44 Demonstrate PromptKit comparison and catalogs 2026-10-12 02:28:20 +00:00
526a033da5 Cover PromptKit catalog command behavior 2026-10-12 02:24:25 +00:00
e4cdedd6db Add PromptKit catalog commands 2026-10-12 02:17:54 +00:00
c75d371ecd Add PromptKit catalog YAML encoders 2026-10-12 02:12:47 +00:00
330017fd75 Add PromptKit catalog providers 2026-10-12 02:10:45 +00:00
f7b1d2c583 Verify PromptKit comparison through config commands 2026-10-12 02:07:28 +00:00
ddd14b70a8 Add PromptKit configuration comparison 2026-10-12 02:01:10 +00:00
c6b4a42287 Retain PromptKit diagnostics after cancellation 2026-10-12 01:55:08 +00:00
ec4eb9d3a6 Upgrade ckprompt to PromptKit v0.14.0 2026-10-12 01:46:28 +00:00
d825538921 Plan PromptKit implementation stages 2026-10-12 01:40:15 +00:00
20e19b9569 Settle PromptKit roadmap API and presentation decisions 2026-10-12 01:22:50 +00:00
603b6b3758 Plan PromptKit comparison and catalog adoption 2026-10-12 01:12:59 +00:00
ab270e2c82 Align the feature roadmap with completed validation 2026-10-12 00:17:34 +00:00
c7fb5a27bb Test comparison preparation through provider behavior 2026-10-12 00:16:47 +00:00
251d3adeee Clarify comparison provider and delivery guarantees 2026-10-12 00:15:04 +00:00
98c9c3f00d Index mappings while assembling configuration reports 2026-10-12 00:14:20 +00:00
7bf89e9b56 Record configuration comparison review findings 2026-10-11 23:08:58 +00:00
28aa3b04ad Mark configuration comparison roadmap complete 2026-10-11 22:06:20 +00:00
c69a75d1c9 Validate configuration comparison sprint 2026-10-11 22:06:08 +00:00
976b55309c Reconcile integration dependency notices 2026-10-11 22:03:58 +00:00
8b46e12da7 Document configuration comparison behavior 2026-10-11 22:00:22 +00:00
f0853d1000 Demonstrate composed configuration comparison 2026-10-11 21:54:39 +00:00
3cbb1f35b0 Demonstrate config diff in simple consumer 2026-10-11 21:52:47 +00:00
18ee987a88 Execute configuration comparisons 2026-10-11 21:51:54 +00:00
783f17f274 Render native configuration differences as YAML 2026-10-11 21:49:50 +00:00
30787b2806 Add safe configuration report helpers 2026-10-11 21:48:49 +00:00
3542422b2f Add ConfigKit comparison acquisition 2026-10-11 21:47:16 +00:00
d2434b9580 Prepare both configuration comparison inputs 2026-10-11 21:43:30 +00:00
d1591463ee Add comparison selection and format flags 2026-10-11 21:39:19 +00:00
018f90a23c Add configuration comparison capability contract 2026-10-11 21:36:34 +00:00
c883c64be9 Adopt ConfigKit v0.7.1 in integrations 2026-10-11 21:33:22 +00:00
e8b44ca765 Split the implementation plan into smaller stages 2026-10-11 21:09:53 +00:00
7fc425ede3 Plan the ConfigKit comparison implementation 2026-10-11 20:35:38 +00:00
f8bb78924e Clarify the scope of architectural decision records 2026-10-11 20:26:11 +00:00
92727ecf2b Clarify CommandKit ownership of upstream integration and presentation 2026-10-11 19:14:23 +00:00
21c2fde8d5 Finalize native comparison ADRs and clarify declaration contracts 2026-10-11 18:59:57 +00:00
fbee871731 Organize the ConfigKit roadmap around the selected end state 2026-10-11 18:28:14 +00:00
f67ee235d7 Record the ConfigKit upgrade and comparison plan 2026-10-11 18:23:55 +00:00
ecd713ef2f Retire completed implementation and remediation roadmaps 2026-10-11 13:22:18 +00:00
bb2528532b Correct integration attribution dependency scopes 2026-10-11 13:10:18 +00:00
5aac2a0ee1 Remove unused flag clearing closures 2026-10-11 13:09:06 +00:00
960a7c963e Describe release file inputs and checks accurately 2026-10-11 13:08:31 +00:00
4ba5a56e21 Align release note policy with independent modules 2026-10-11 13:07:45 +00:00
7ce2fc0bf2 Clarify inspection input ownership and documentation 2026-10-11 13:07:00 +00:00
cec4da69d3 Compile configuration selector metadata during capture 2026-10-11 13:05:22 +00:00
fba917f5bd Capture PromptKit selectors before retaining callbacks 2026-10-11 13:02:54 +00:00
6d660585d1 Link the finalized backend design from the roadmap 2026-10-11 13:01:35 +00:00
dc252d9fb0 Record the implementation review findings 2026-10-11 05:20:14 +00:00
cc574915ee Validate independent module boundaries 2026-10-11 02:13:16 +00:00
d2b5b0f3a8 Reconcile module docs and release materials 2026-10-11 02:06:06 +00:00
18e81a5579 Adopt ReleaseKit module publishing 2026-10-11 01:54:04 +00:00
e89cc2220f Finish maintained consumer examples 2026-10-11 01:50:48 +00:00
f7174a078e Add PromptKit inspection commands 2026-10-11 01:46:54 +00:00
eeeaca6c5e Add PromptKit engine adapters 2026-10-11 01:43:02 +00:00
c0f3a21fc7 Port native configuration reports 2026-10-11 01:39:23 +00:00
809a36bc46 Assemble configuration families and inputs 2026-10-11 01:34:25 +00:00
1e1eca4a88 Add ConfigKit scope contracts and adapters 2026-10-11 01:30:18 +00:00
7e5f84bf16 Route executions through neutral snapshots 2026-10-11 01:25:45 +00:00
24e8cd8629 Install command batches through one path 2026-10-11 01:22:47 +00:00
74f6bdeade Compile typed command runners 2026-10-11 01:21:52 +00:00
4e0611bc13 Add captured flag value application 2026-10-11 01:20:43 +00:00
f63b096525 Unify typed binding preparation 2026-10-11 01:18:07 +00:00
de9bfdcc4c Capture immutable command declarations 2026-10-11 01:15:13 +00:00
476e98a463 Move integration code into optional modules 2026-10-11 01:06:21 +00:00
0d23676144 Plan the multi-module implementation sprint 2026-10-11 00:06:31 +00:00
bb849ff3e1 Link the finalized registration backend design 2026-10-10 23:44:28 +00:00
336d489727 Resolve unused binding validation for empty integration suites 2026-10-10 23:33:23 +00:00
3b69cf85e7 Refine backend composition and input lifecycle decisions 2026-10-10 22:56:40 +00:00
14c304b6f9 Plan shared registration and execution internals 2026-10-10 16:45:14 +00:00
b8aada3b6e Align documentation with accepted ADR 0011 2026-10-10 16:23:02 +00:00
160604095c Accept the captured declaration architecture and align documentation 2026-10-10 16:19:45 +00:00
8f86852456 Finalize the registration API roadmap 2026-10-10 15:44:53 +00:00
892186d4ac Clarify registration validation and invocation state contracts 2026-10-10 15:40:08 +00:00
0b5c1422ed Complete the registration proposal declarations and examples 2026-10-10 15:21:16 +00:00
48469ea509 Record the unified command registration direction 2026-10-10 14:46:56 +00:00
58ee418249 Complete the registration API design mockups 2026-10-10 14:21:11 +00:00
d9f60d2d5a Refine registration alternatives and list remaining decisions 2026-10-10 14:10:59 +00:00
eeb9fdd66c Compare integration registration API alternatives 2026-10-10 13:55:22 +00:00
4ca0e9fdae Record the forthcoming module release support 2026-10-10 13:22:38 +00:00
ecc0ad4e70 Clarify the final API design and module decisions 2026-10-09 15:51:58 +00:00
76f923e619 Accept optional integration modules architecture 2026-10-09 14:11:02 +00:00
9456bef1c1 Record integration module paths and package names 2026-10-09 13:28:45 +00:00
085302d78a Clarify ConfigKit integration scope and module naming options 2026-10-09 13:03:07 +00:00
06859b1b09 Document the optional integration modules roadmap 2026-10-09 12:54:28 +00:00
246 changed files with 35315 additions and 8172 deletions

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ go.work.sum
# temporary files which can be created if a process still has a handle open of a deleted file # temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden* .fuse_hidden*
# Local ReleaseKit bootstrap executable # Local ReleaseKit credentials
/.releasekit/ /.releasekit/
# KDE directory preferences # KDE directory preferences

View File

@@ -1,19 +1,40 @@
# CommandKit # CommandKit
CommandKit is a Go library project for consistent CLI inspection, command syntax, CommandKit is a Go library for typed command declarations, shared CLI behavior,
and shared command infrastructure across related applications. and application-owned execution. The core `commandkit` module is usable by
itself. ConfigKit and PromptKit support lives in separately versioned optional
modules, so applications choose which upstream libraries they add.
The current Go library provides an instance-owned command registry, typed flag The core captures reusable command declarations, validates installation batches
and operand bindings, Cobra-backed routing, invocation outcomes, generated help, before changing an application's registry, converts typed inputs before
application or CommandKit version reporting, configuration scope discovery, constructing them, and returns outcomes without exiting the process. Its
ConfigKit-backed validation, native effective YAML display, source inventory, [consumer guide](docs/consumers/guide.md) covers declaration, invocation
and path explanation reports. Applications can register a PromptKit engine and reporting. Each handler receives one invocation reporter for
configuration scope independently of PromptKit definition commands, and can substantive output, categorized messages, and terminal explanations; `Execute`
install optional prompt and profile inspection commands. returns delivery observations with the outcome. The [CLI reference](docs/cli.md)
defines the shared visibility policy.
The [consumer guide](docs/consumers/guide.md) links to two maintained examples: ## Modules
[simple](examples/simple/README.md) and
[composed](examples/composed/README.md). | Module | Package | Provides |
| --- | --- | --- |
| `go.maximumdirect.net/commandkit` | `commandkit` | Typed commands, bindings, execution reporting and outcomes, help, and version. |
| `go.maximumdirect.net/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
| `go.maximumdirect.net/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
Importing an integration does not install commands. Core v0.4.0 is published;
the published ckconfig and ckprompt v0.2.0 modules use core v0.3.0. The published
ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose with
ckprompt v0.2.0; the published v0.2.0 pair remains matched. Current ckconfig
source uses core v0.4.0. Current ckprompt source requires a newer
ckconfig release and is developed in an aligned workspace. See the
[consumer guide](docs/consumers/guide.md#module-selection) for dependency details.
## Examples
- [Core-only example](examples/core/README.md)
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
## Documentation ## Documentation
@@ -24,11 +45,7 @@ The [consumer guide](docs/consumers/guide.md) links to two maintained examples:
- [Maintainer release procedure](docs/release.md) - [Maintainer release procedure](docs/release.md)
- [Architecture policy](docs/policy/architecture.md) - [Architecture policy](docs/policy/architecture.md)
- [Policy provenance](docs/policy/meta.md) - [Policy provenance](docs/policy/meta.md)
- [Future work and proposals](docs/roadmap/future.md)
## Future work and history
- [Deferred capabilities and proposals](docs/roadmap/deferred.md)
- [Completed planning and implementation records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT). CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).

65
app.go
View File

@@ -3,47 +3,60 @@ package commandkit
import ( import (
"fmt" "fmt"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" "go.maximumdirect.net/commandkit/internal/alias"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
// App owns one command registry and its application identity. // App owns one command registry and its application identity. Each App has an
// independent registry; command declarations may be reused across Apps.
type App struct { type App struct {
identity Identity identity Identity
commands map[string]*commandNode commands map[string]*commandNode
commandTree *internalcobra.Command routingRules []RoutingRule
configRegistered bool aliases *alias.Resolver
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
snapshot *routingSnapshot
}
type routingSnapshot struct {
metadata *commandmeta.Command
runners map[commandmeta.Key]commandPayload
aliases *alias.Resolver
canonical map[string]*canonicalNode
commandAliases []commandmeta.CommandAlias
flagAliases map[string]resolvedFlagAliases
discoveryFlags map[string]resolvedFlagAliases
completion bool
} }
type commandNode struct { type commandNode struct {
command Command command Command
reserved bool
} }
// New creates an application with the supplied identity and initial commands. // New creates an application with the supplied identity and initial canonical
// The identity name must not be empty. The root help and version names are // commands and routing rules. Use []Declaration to expand a mixed declaration
// reserved for generated commands. // collection; []Command cannot be expanded as ...Declaration without
func New(identity Identity, commands ...Command) (*App, error) { // converting its elements.
//
// The identity name must not be empty. New installs its generated help and
// version commands and caller declarations as one batch; an invalid, nil, or
// zero declaration returns an error without exposing an App. Factory issues
// retained in Command and RoutingRule values are reported during installation.
// Identical duplicate routing mappings are accepted; conflicting mappings
// fail the batch. The root help and version names are reserved for generated
// commands.
func New(identity Identity, declarations ...Declaration) (*App, error) {
if identity.Name == "" { if identity.Name == "" {
return nil, fmt.Errorf("application identity name must not be empty") return nil, fmt.Errorf("application identity name must not be empty")
} }
app := &App{ app := &App{identity: identity, commands: make(map[string]*commandNode)}
identity: identity, builtins := app.builtinCommands()
commands: map[string]*commandNode{ all := make([]Declaration, 0, len(builtins)+len(declarations))
"help": { all = append(all, builtins...)
command: Command{name: "help", summary: "Show help for commands."}, all = append(all, declarations...)
reserved: true, if err := app.installBatch(all...); err != nil {
},
"version": {
command: Command{name: "version", summary: "Show application version."},
reserved: true,
},
},
}
if err := app.installBuiltins(); err != nil {
return nil, err
}
if err := app.Register(commands...); err != nil {
return nil, err return nil, err
} }
return app, nil return app, nil

View File

@@ -1,167 +1,240 @@
package commandkit package commandkit
import ( import (
"errors"
"fmt" "fmt"
"sort"
"strconv" "strconv"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
// ValueFlag declares a singleton flag with a typed parser, optional default, // ValueFlag declares a singleton flag with a typed parser, optional default,
// and setter. Every supplied occurrence is parsed; the last parsed value is // and setter. The default value is captured at construction. Every supplied
// applied with Set=true. // occurrence is parsed before input construction; the last parsed value is
// applied with Set=true. A malformed earlier occurrence remains an error.
func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] { func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
var fallback V var fallback V
if defaultValue != nil { if defaultValue != nil {
fallback = *defaultValue fallback = *defaultValue
} }
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback)} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback)}
if parse == nil || set == nil { if parse == nil || set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[V]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
value := fallback value := fallback
supplied := len(values) > 0 supplied := len(values) > 0
for _, raw := range values { for index, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
value = parsed value = parsed
} }
result := Supplied[V]{Value: value, Set: supplied} result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
}
return Flag[I]{binding: binding}
}
// OccurrenceFlag declares a repeatable flag whose setter runs once for each
// supplied occurrence. Occurrence setters run in command-line order across
// participating bindings; an omitted flag does not call its setter.
func OccurrenceFlag[I, V any](meta FlagMeta, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, repeated: true}
if parse == nil || set == nil {
return Flag[I]{binding: binding}
}
binding.valid = true
binding.prepare = func(values []string) (preparedValue[I], error) {
result := preparedValue[I]{identity: binding.identity, supplied: len(values) > 0}
for index, raw := range values {
value, err := parse(raw)
if err != nil {
return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
}
ordinal := index
result.ordered = append(result.ordered, preparedOccurrence[I]{ordinal: &ordinal, apply: func(input *I) { set(input, Supplied[V]{Value: value, Set: true}) }})
}
return result, nil
}
return Flag[I]{binding: binding}
}
// RenameFlag returns an independent binding with a new long name and no short
// spelling. Parsing, defaults, completion, requiredness, and setters are retained.
func RenameFlag[I any](flag Flag[I], name string) Flag[I] {
binding := flag.binding
binding.meta.Name = name
binding.meta.Short = ""
binding.identity = newBindingIdentity()
if prepare := binding.prepare; prepare != nil {
identity := binding.identity
binding.prepare = func(values []string) (preparedValue[I], error) {
value, err := prepare(values)
value.identity = identity
return value, err
}
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as // BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
// "true"; explicit values are converted with strconv.ParseBool. // "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
// an omitted flag from an explicit false value.
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] { func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
if set == nil { if set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[bool]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
value := defaultValue value := defaultValue
supplied := len(values) > 0 supplied := len(values) > 0
for _, raw := range values { for index, raw := range values {
parsed, err := strconv.ParseBool(raw) parsed, err := strconv.ParseBool(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
value = parsed value = parsed
} }
result := Supplied[bool]{Value: value, Set: supplied} result := Supplied[bool]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// RepeatFlag declares a repeatable flag. Supplied occurrences replace defaults // RepeatFlag declares a repeatable flag. It captures a copy of defaults. Supplied
// and are parsed in argv order, with each occurrence treated as one value. // occurrences replace defaults and are parsed in argv order, with each
// occurrence treated as one value. The core-owned result slice is copied when
// applied to input.
func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] { func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] {
fallback := append([]V(nil), defaults...) fallback := append([]V(nil), defaults...)
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback), repeated: true} binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback), repeated: true}
if parse == nil || set == nil { if parse == nil || set == nil {
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) } binding.prepare = func(values []string) (preparedValue[I], error) {
binding.prepare = func(values []string) (func(*I), error) {
parsedValues := append([]V(nil), fallback...) parsedValues := append([]V(nil), fallback...)
supplied := len(values) > 0 supplied := len(values) > 0
if supplied { if supplied {
parsedValues = make([]V, 0, len(values)) parsedValues = make([]V, 0, len(values))
for _, raw := range values { for index, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, &flagOccurrenceError{index: index, cause: err}
} }
parsedValues = append(parsedValues, parsed) parsedValues = append(parsedValues, parsed)
} }
} }
return func(input *I) { return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied} result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied}
set(input, result) set(input, result)
}, nil }}, nil
} }
return Flag[I]{binding: binding} return Flag[I]{binding: binding}
} }
// ValueArgument declares an optional or required singleton positional // ValueArgument declares an optional or required singleton positional argument.
// argument. // Required cardinality is enforced for execution, while help can be rendered
// without a supplied operand. Parsing completes before input construction.
func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] { func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] {
min := 0 min := 0
if required { if required {
min = 1 min = 1
} }
binding := argumentBinding[I]{meta: meta, min: min, max: 1} binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: 1}
if parse == nil || set == nil { if parse == nil || set == nil {
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (preparedValue[I], error) {
if len(values) > 1 { if len(values) > 1 {
return nil, fmt.Errorf("received %d values; maximum is 1", len(values)) return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is 1", len(values))
} }
var value V var value V
supplied := len(values) == 1 supplied := len(values) == 1
if supplied { if supplied {
parsed, err := parse(values[0]) parsed, err := parse(values[0])
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, err
} }
value = parsed value = parsed
} }
result := Supplied[V]{Value: value, Set: supplied} result := Supplied[V]{Value: value, Set: supplied}
return func(input *I) { set(input, result) }, nil return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
} }
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
// RepeatArgument declares a positional argument that accepts a bounded number // RepeatArgument declares a positional argument that accepts at least min and
// of values. A maximum of -1 means unbounded. // at most max values. A maximum of -1 means unbounded. Parsing completes before
// input construction, and the core-owned result slice is copied when applied.
func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] { func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] {
binding := argumentBinding[I]{meta: meta, min: min, max: max} binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: max}
if parse == nil || set == nil { if parse == nil || set == nil {
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
binding.valid = true binding.valid = true
binding.prepare = func(values []string) (func(*I), error) { binding.prepare = func(values []string) (preparedValue[I], error) {
if max >= 0 && len(values) > max { if max >= 0 && len(values) > max {
return nil, fmt.Errorf("received %d values; maximum is %d", len(values), max) return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is %d", len(values), max)
} }
parsedValues := make([]V, 0, len(values)) parsedValues := make([]V, 0, len(values))
for _, raw := range values { for _, raw := range values {
parsed, err := parse(raw) parsed, err := parse(raw)
if err != nil { if err != nil {
return nil, err return preparedValue[I]{}, err
} }
parsedValues = append(parsedValues, parsed) parsedValues = append(parsedValues, parsed)
} }
return func(input *I) { return preparedValue[I]{identity: binding.identity, supplied: len(values) > 0, apply: func(input *I) {
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0} result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0}
set(input, result) set(input, result)
}, nil }}, nil
} }
return Argument[I]{binding: binding} return Argument[I]{binding: binding}
} }
type preparedInput[I any] struct { type preparedInput[I any] struct {
newInput func() I newInput func() I
setters []func(*I) values []preparedValue[I]
} }
type flagOccurrenceError struct {
index int
cause error
}
func (err *flagOccurrenceError) Error() string { return err.cause.Error() }
func (err *flagOccurrenceError) Unwrap() error { return err.cause }
type flagConversionError struct {
binding string
spelling string
occurrence int
cause error
}
func (err *flagConversionError) Error() string {
return fmt.Sprintf("flag %s occurrence %d: %v", err.spelling, err.occurrence, err.cause)
}
func (err *flagConversionError) Unwrap() error { return err.cause }
func (p preparedInput[I]) build() I { func (p preparedInput[I]) build() I {
var input I var input I
if p.newInput != nil { if p.newInput != nil {
input = p.newInput() input = p.newInput()
} }
for _, setter := range p.setters { for _, value := range p.values {
setter(&input) if value.apply != nil {
value.apply(&input)
}
} }
return input return input
} }
@@ -170,91 +243,164 @@ func (p preparedInput[I]) build() I {
// creates an input value. When enforceRequired is false, missing required flags // creates an input value. When enforceRequired is false, missing required flags
// and operands are allowed so metadata help can validate supplied syntax. // and operands are allowed so metadata help can validate supplied syntax.
func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) { func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
return prepareInputWithSpellings(spec, flagValues, nil, operands, enforceRequired)
}
func prepareInputWithSpellings[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, operands []string, enforceRequired bool) (preparedInput[I], error) {
return prepareInputWithOccurrences(spec, flagValues, flagSpellings, nil, operands, enforceRequired)
}
func prepareInputWithOccurrences[I any](spec CommandSpec[I], flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (preparedInput[I], error) {
if len(flagValues) != len(spec.Flags) { if len(flagValues) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags)) return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
} }
setters := make([]func(*I), 0, len(spec.Flags)+len(spec.Arguments)) if flagSpellings == nil {
flagSpellings = make([][]string, len(spec.Flags))
}
if len(flagSpellings) != len(spec.Flags) {
return preparedInput[I]{}, fmt.Errorf("received %d flag spelling sets for %d bindings", len(flagSpellings), len(spec.Flags))
}
preparedFlags := make([]preparedValue[I], len(spec.Flags))
ordinals := make(map[string][]int)
for index, occurrence := range occurrences {
ordinals[occurrence.Name] = append(ordinals[occurrence.Name], index)
}
var ordered []preparedOccurrence[I]
fallbackOrdinal := 0
missingRequired := ""
for i, flag := range spec.Flags { for i, flag := range spec.Flags {
binding := flag.binding binding := flag.binding
if enforceRequired && binding.meta.Required && len(flagValues[i]) == 0 { value, err := binding.prepare(flagValues[i])
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", binding.meta.Name)
}
setter, err := binding.prepare(flagValues[i])
if err != nil { if err != nil {
var occurrenceErr *flagOccurrenceError
if errors.As(err, &occurrenceErr) {
spelling := "--" + binding.meta.Name
if occurrenceErr.index >= 0 && occurrenceErr.index < len(flagSpellings[i]) && flagSpellings[i][occurrenceErr.index] != "" {
spelling = flagSpellings[i][occurrenceErr.index]
}
return preparedInput[I]{}, &flagConversionError{
binding: binding.meta.Name, spelling: spelling,
occurrence: occurrenceErr.index + 1, cause: occurrenceErr.cause,
}
}
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err) return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
} }
setters = append(setters, setter) preparedFlags[i] = value
for index, occurrence := range value.ordered {
if occurrences != nil {
positions := ordinals[binding.meta.Name]
if index >= len(positions) {
return preparedInput[I]{}, fmt.Errorf("missing occurrence position for flag --%s", binding.meta.Name)
}
*occurrence.ordinal = positions[index]
} else {
*occurrence.ordinal = fallbackOrdinal
fallbackOrdinal++
}
if occurrence.apply != nil {
ordered = append(ordered, occurrence)
}
}
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
missingRequired = binding.meta.Name
}
}
if missingRequired != "" {
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
} }
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired) argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
if err != nil { if err != nil {
return preparedInput[I]{}, err return preparedInput[I]{}, err
} }
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
for _, occurrence := range ordered {
preparedValues = append(preparedValues, preparedValue[I]{apply: occurrence.apply})
}
for i, argument := range spec.Arguments { for i, argument := range spec.Arguments {
binding := argument.binding binding := argument.binding
setter, err := binding.prepare(argumentValues[i]) value, err := binding.prepare(argumentValues[i])
if err != nil { if err != nil {
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err) return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
} }
setters = append(setters, setter) preparedValues = append(preparedValues, value)
} }
return preparedInput[I]{newInput: spec.NewInput, setters: setters}, nil return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
} }
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) { func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
arguments := make([]commandmeta.Argument, len(definitions))
for i, definition := range definitions {
arguments[i] = commandmeta.Argument{Name: definition.binding.meta.Name, Min: definition.binding.min, Max: definition.binding.max}
}
counts, err := allocateOperandCounts(len(operands), arguments, enforceRequired)
if err != nil {
return nil, err
}
allocated := make([][]string, len(definitions))
position := 0
for i, count := range counts {
allocated[i] = operands[position : position+count]
position += count
}
return allocated, nil
}
func allocateOperandCounts(total int, definitions []commandmeta.Argument, enforceRequired bool) ([]int, error) {
for _, argument := range definitions { for _, argument := range definitions {
binding := argument.binding if argument.Min < 0 || argument.Max < -1 || (argument.Max >= 0 && argument.Min > argument.Max) {
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) { return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", argument.Name, argument.Min, argument.Max)
return nil, fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
} }
} }
if enforceRequired { if enforceRequired {
minimum := 0 minimum := 0
for _, argument := range definitions { for _, argument := range definitions {
if argument.binding.min > len(operands)-minimum { if argument.Min > total-minimum {
return nil, fmt.Errorf("not enough positional arguments") return nil, fmt.Errorf("not enough positional arguments")
} }
minimum += argument.binding.min minimum += argument.Min
} }
} }
suffixMinimum := make([]int, len(definitions)+1) suffixMinimum := make([]int, len(definitions)+1)
for i := len(definitions) - 1; i >= 0; i-- { for i := len(definitions) - 1; i >= 0; i-- {
minimum := definitions[i].binding.min minimum := definitions[i].Min
remaining := len(operands) - suffixMinimum[i+1] remaining := total - suffixMinimum[i+1]
if minimum > remaining { if minimum > remaining {
suffixMinimum[i] = len(operands) suffixMinimum[i] = total
} else { } else {
suffixMinimum[i] = suffixMinimum[i+1] + minimum suffixMinimum[i] = suffixMinimum[i+1] + minimum
} }
} }
allocated := make([][]string, len(definitions)) counts := make([]int, len(definitions))
position := 0 position := 0
for i, argument := range definitions { for i, argument := range definitions {
binding := argument.binding remaining := total - position
remaining := len(operands) - position
available := remaining - suffixMinimum[i+1] available := remaining - suffixMinimum[i+1]
if available < 0 { if available < 0 {
available = 0 available = 0
} }
count := available count := available
if binding.max >= 0 && count > binding.max { if argument.Max >= 0 && count > argument.Max {
count = binding.max count = argument.Max
} }
if !enforceRequired && count < binding.min && remaining > 0 { if !enforceRequired && count < argument.Min && remaining > 0 {
count = remaining count = remaining
if binding.max >= 0 && count > binding.max { if argument.Max >= 0 && count > argument.Max {
count = binding.max count = argument.Max
} }
} }
if enforceRequired && count < binding.min { if enforceRequired && count < argument.Min {
return nil, fmt.Errorf("not enough values for argument %q", binding.meta.Name) return nil, fmt.Errorf("not enough values for argument %q", argument.Name)
} }
allocated[i] = operands[position : position+count] counts[i] = count
position += count position += count
} }
if position < len(operands) { if position < total {
return nil, fmt.Errorf("received %d extra positional arguments", len(operands)-position) return nil, fmt.Errorf("received %d extra positional arguments", total-position)
} }
return allocated, nil return counts, nil
} }

View File

@@ -1,7 +1,9 @@
package commandkit package commandkit
import ( import (
"context"
"errors" "errors"
"io"
"reflect" "reflect"
"strconv" "strconv"
"testing" "testing"
@@ -30,7 +32,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }), BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }),
}, },
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
defaultMessage = "changed after declaration" defaultMessage = "changed after declaration"
@@ -104,8 +106,7 @@ func TestBoolFlagRejectsExplicitEmptyValue(t *testing.T) {
func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) { func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
defaults := []string{"base"} defaults := []string{"base"}
var captured Supplied[[]string] flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(input *bindingInput, value Supplied[[]string]) { input.tags = value })
flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(_ *bindingInput, value Supplied[[]string]) { captured = value })
defaults[0] = "changed after declaration" defaults[0] = "changed after declaration"
applyDefault, err := flag.binding.prepare(nil) applyDefault, err := flag.binding.prepare(nil)
@@ -113,25 +114,25 @@ func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
first := &bindingInput{} first := &bindingInput{}
applyDefault(first) applyDefault.apply(first)
second := &bindingInput{} second := &bindingInput{}
applyDefault(second) applyDefault.apply(second)
if !reflect.DeepEqual(captured.Value, []string{"base"}) || captured.Set { if !reflect.DeepEqual(first.tags.Value, []string{"base"}) || first.tags.Set {
t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", captured) t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", first.tags)
} }
captured.Value[0] = "changed one input" first.tags.Value[0] = "changed one input"
applyDefault(second) if !reflect.DeepEqual(second.tags.Value, []string{"base"}) {
if !reflect.DeepEqual(captured.Value, []string{"base"}) { t.Fatalf("repeat default collection was reused: %#v", second.tags.Value)
t.Fatalf("repeat default collection was reused: %#v", captured.Value)
} }
applyValues, err := flag.binding.prepare([]string{"a,b", "c"}) applyValues, err := flag.binding.prepare([]string{"a,b", "c"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
applyValues(&bindingInput{}) third := &bindingInput{}
if !reflect.DeepEqual(captured.Value, []string{"a,b", "c"}) || !captured.Set { applyValues.apply(third)
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", captured) if !reflect.DeepEqual(third.tags.Value, []string{"a,b", "c"}) || !third.tags.Set {
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", third.tags)
} }
} }
@@ -149,6 +150,46 @@ func TestSingletonParsesEveryOccurrenceAndPreservesCallerCause(t *testing.T) {
} }
} }
func TestPreparedScalarConvertsEveryOccurrenceOnceBeforeInputCreation(t *testing.T) {
parseCalls := 0
var received Supplied[string]
flag := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) {
parseCalls++
return value, nil
}, func(input *bindingInput, value Supplied[string]) { input.message = value })
prepared, err := flag.binding.prepare([]string{"earlier", "last"})
if err != nil {
t.Fatal(err)
}
if parseCalls != 2 || prepared.identity != flag.binding.identity || !prepared.supplied {
t.Fatalf("prepared scalar = %#v, parse calls = %d", prepared, parseCalls)
}
copy := flag
lookalike := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*bindingInput, Supplied[string]) {})
if copy.binding.identity != flag.binding.identity || lookalike.binding.identity == flag.binding.identity {
t.Fatal("binding identity was not preserved by copying or distinct across constructors")
}
input := &bindingInput{}
prepared.apply(input)
received = input.message
if parseCalls != 2 || received.Value != "last" || !received.Set {
t.Fatalf("applied value = %#v, parse calls = %d", received, parseCalls)
}
}
func TestInvalidBindingConstructionIsReportedAtInstallation(t *testing.T) {
badFlag := ValueFlag[struct{}, string](FlagMeta{Name: "broken"}, nil, nil, func(*struct{}, Supplied[string]) {})
badArgument := RepeatArgument[struct{}, string](ArgumentMeta{Name: "items"}, 2, 1, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[[]string]) {})
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{badFlag}, Arguments: []Argument[struct{}]{badArgument}})
app, err := New(Identity{Name: "example"}, command)
if err == nil {
t.Fatal("installation accepted nil parser wiring and invalid argument cardinality")
}
if app != nil {
t.Fatal("failed installation returned a usable app")
}
}
func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) { func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
type operandsInput struct { type operandsInput struct {
prefix Supplied[[]string] prefix Supplied[[]string]
@@ -163,7 +204,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }), RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }),
}, },
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true) prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
@@ -193,7 +234,7 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})}, Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
} }
if _, err := NewCommand(spec); err != nil { if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil { if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
@@ -206,3 +247,90 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
t.Fatalf("preparation invoked NewInput %d times", factoryCalls) t.Fatalf("preparation invoked NewInput %d times", factoryCalls)
} }
} }
func TestExecuteBindsMixedFlagSpellingsWithFreshPresence(t *testing.T) {
type input struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
type observed struct {
count Supplied[int]
enabled Supplied[bool]
message Supplied[string]
tags Supplied[[]string]
required Supplied[string]
instance int
}
defaultCount := 9
defaultMessage := "fallback"
defaultTags := []string{"base"}
countParses, inputFactories := 0, 0
var calls []observed
command := NewCommand(CommandSpec[input]{
Name: "run",
NewInput: func() input {
inputFactories++
return input{instance: inputFactories}
},
Flags: []Flag[input]{
ValueFlag(FlagMeta{Name: "count"}, &defaultCount, func(value string) (int, error) {
countParses++
return strconv.Atoi(value)
}, func(target *input, value Supplied[int]) { target.count = value }),
BoolFlag(FlagMeta{Name: "enabled"}, true, func(target *input, value Supplied[bool]) { target.enabled = value }),
ValueFlag(FlagMeta{Name: "message"}, &defaultMessage, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.message = value }),
RepeatFlag(FlagMeta{Name: "tag"}, defaultTags, func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[[]string]) { target.tags = value }),
ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value Supplied[string]) { target.required = value }),
},
Run: func(_ Invocation, value input) Outcome {
value.tags.Value = append([]string(nil), value.tags.Value...)
calls = append(calls, observed(value))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command,
Alias("shortcut", "run"),
FlagAliasFor("run", "--number", "--count"),
FlagAliasFor("run", "--active", "--enabled"),
FlagAliasFor("run", "--text", "--message"),
FlagAliasFor("run", "--label", "--tag"),
)
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"shortcut", "--number=0", "--count=5", "--active=false", "--enabled=true", "--text=", "--message=last", "--label=a", "--tag=b", "--required=first"},
{"run", "--number=0", "--active=false", "--text=", "--label=c", "--required=second"},
} {
if outcome := app.Execute(context.Background(), args, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if countParses != 3 {
t.Fatalf("count conversions = %d, want every one of three occurrences", countParses)
}
if len(calls) != 2 || calls[0].count != (Supplied[int]{Value: 5, Set: true}) || calls[0].enabled != (Supplied[bool]{Value: true, Set: true}) || calls[0].message != (Supplied[string]{Value: "last", Set: true}) || !reflect.DeepEqual(calls[0].tags, Supplied[[]string]{Value: []string{"a", "b"}, Set: true}) || calls[0].required != (Supplied[string]{Value: "first", Set: true}) {
t.Fatalf("first mixed-spelling invocation = %#v", calls)
}
if calls[1].count != (Supplied[int]{Value: 0, Set: true}) || calls[1].enabled != (Supplied[bool]{Value: false, Set: true}) || calls[1].message != (Supplied[string]{Value: "", Set: true}) || !reflect.DeepEqual(calls[1].tags, Supplied[[]string]{Value: []string{"c"}, Set: true}) || calls[1].required != (Supplied[string]{Value: "second", Set: true}) || calls[0].instance == calls[1].instance {
t.Fatalf("second mixed-spelling invocation = %#v", calls[1])
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--count=7"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeUsage {
t.Fatalf("missing required flag outcome = %#v, want usage", outcome)
}
if inputFactories != 2 || len(calls) != 2 {
t.Fatalf("missing required flag created or ran input: factories=%d calls=%d", inputFactories, len(calls))
}
if outcome := app.Execute(context.Background(), []string{"shortcut", "--required=defaults"}, Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != OutcomeSuccess {
t.Fatalf("defaulted invocation = %#v, want success", outcome)
}
if len(calls) != 3 || calls[2].count != (Supplied[int]{Value: 9}) || calls[2].enabled != (Supplied[bool]{Value: true}) || calls[2].message != (Supplied[string]{Value: "fallback"}) || !reflect.DeepEqual(calls[2].tags, Supplied[[]string]{Value: []string{"base"}}) || calls[2].required != (Supplied[string]{Value: "defaults", Set: true}) || calls[2].instance == calls[1].instance {
t.Fatalf("omitted values reused prior invocation state: %#v", calls[2])
}
}

View File

@@ -1,18 +1,16 @@
package commandkit package commandkit
import ( import (
"bytes"
"errors"
"fmt" "fmt"
"io" "io"
"runtime/debug" "runtime/debug"
"strings" "text/template"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
yaml "go.yaml.in/yaml/v4" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
) )
const commandKitModulePath = "gitea.maximumdirect.net/eric/CommandKit" const commandKitModulePath = "go.maximumdirect.net/commandkit"
type helpInput struct { type helpInput struct {
Targets Supplied[[]string] Targets Supplied[[]string]
@@ -20,39 +18,34 @@ type helpInput struct {
type versionInput struct { type versionInput struct {
Scope string Scope string
Format string
} }
type versionDocument struct { type versionDocument struct {
Name string `yaml:"name"` Name string `json:"name"`
Version string `yaml:"version"` Version string `json:"version"`
Revision string `yaml:"revision"` Revision string `json:"revision"`
} }
func (a *App) installBuiltins() error { var versionTextTemplate = template.Must(ParseTextTemplate("version", "{{.Name}} {{.Version}}{{if .Revision}} ({{.Revision}}){{end}}\n", nil))
help, err := NewCommand(CommandSpec[helpInput]{
func (a *App) builtinCommands() []Declaration {
help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
Summary: "Show help for commands.", Summary: "Show help for commands.",
Description: "Describe registered commands and their inputs.", Description: "Describe registered commands and their inputs.",
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1, Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
func(value string) (string, error) { return value, nil }, func(value string) (string, error) { return value, nil },
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })}, func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
Run: func(invocation Invocation, input helpInput) Outcome { }, func(current commandExecution, input helpInput) Outcome {
return a.renderHelpTargets(input.Targets.Value, invocation.Streams) return current.renderHelpTargets(input.Targets.Value)
},
}) })
if err != nil {
return fmt.Errorf("create built-in help command: %w", err)
}
scopeDefault := "app" scopeDefault := "app"
formatDefault := "text" version := NewCommand(CommandSpec[versionInput]{
version, err := NewCommand(CommandSpec[versionInput]{
Name: "version", Name: "version",
Summary: "Show application or CommandKit version.", Summary: "Show application or CommandKit version.",
Description: "Report identity and build metadata without loading application configuration.", Description: "Report identity and build metadata without loading application configuration.",
Flags: []Flag[versionInput]{ Flags: []Flag[versionInput]{
ValueFlag(FlagMeta{Name: "scope", Usage: "version subject"}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }), ValueFlag(FlagMeta{Name: "scope", Usage: "version subject", Completion: CompleteChoices(CompletionChoice{Value: "app"}, CompletionChoice{Value: "commandkit"})}, &scopeDefault, parseVersionScope, func(input *versionInput, value Supplied[string]) { input.Scope = value.Value }),
ValueFlag(FlagMeta{Name: "format", Usage: "output format"}, &formatDefault, parseVersionFormat, func(input *versionInput, value Supplied[string]) { input.Format = value.Value }),
}, },
Run: func(invocation Invocation, input versionInput) Outcome { Run: func(invocation Invocation, input versionInput) Outcome {
identity := a.identity identity := a.identity
@@ -62,15 +55,10 @@ func (a *App) installBuiltins() error {
if identity.Version == "" { if identity.Version == "" {
identity.Version = "dev" identity.Version = "dev"
} }
return renderVersionOutcome(identity, input.Format, invocation.Streams) return renderVersionOutcome(identity, invocation.Reporter())
}, },
}) })
if err != nil { return []Declaration{help, version}
return fmt.Errorf("create built-in version command: %w", err)
}
a.commands["help"] = &commandNode{command: help, reserved: true}
a.commands["version"] = &commandNode{command: version, reserved: true}
return nil
} }
func parseVersionScope(value string) (string, error) { func parseVersionScope(value string) (string, error) {
@@ -80,98 +68,27 @@ func parseVersionScope(value string) (string, error) {
return value, nil return value, nil
} }
func parseVersionFormat(value string) (string, error) { func (a *App) renderHelpViewOutcomeWithSession(view commandmeta.HelpView, reporter *Reporter, session *internalcobra.Session) Outcome {
if value != "text" && value != "yaml" { content, failure := session.RenderHelpView(view)
return "", fmt.Errorf("format must be text or yaml, got %q", value)
}
return value, nil
}
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{})
return a.renderHelpOutcomeWithSession(path, streams, session)
}
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{})
return a.renderHelpTargetsWithSession(targets, streams, session)
}
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
target, err := a.helpCommandTarget(targets)
if err != nil {
return Usage(err)
}
return a.renderHelpOutcomeWithSession(target, streams, session)
}
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
if len(path) > 0 {
if _, ok := a.commandAt(path); !ok {
return Usage(fmt.Errorf("unknown help path %q", strings.Join(path, " ")))
}
}
content, failure := session.RenderHelp(path)
if failure != nil { if failure != nil {
return Failure(errors.New(failure.Message)) if failure.Usage {
return Usage(failure.Message)
} }
if err := writeReport(streams.Out, []byte(content)); err != nil { return Failure(failure.Message)
return incompleteReport(err)
}
return Success()
}
func renderVersionOutcome(identity Identity, format string, streams Streams) Outcome {
var content []byte
switch format {
case "text":
name := identity.Name
if identity.Version == "" {
identity.Version = "dev"
}
line := name + " " + identity.Version
if identity.Revision != "" {
line += " (" + identity.Revision + ")"
}
content = []byte(line + "\n")
case "yaml":
if identity.Version == "" {
identity.Version = "dev"
}
var buffer bytes.Buffer
if err := encodeYAML(&buffer, versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}); err != nil {
return Failure(err)
}
content = buffer.Bytes()
default:
return Usage(fmt.Errorf("unsupported version format %q", format))
}
if err := writeReport(streams.Out, content); err != nil {
return incompleteReport(err)
}
return Success()
}
func encodeYAML(writer io.Writer, document any) error {
encoder := yaml.NewEncoder(writer)
encoder.SetIndent(2)
return encoder.Encode(document)
}
func writeReport(writer io.Writer, content []byte) error {
checked := &checkedWriter{writer: writer}
_, err := checked.Write(content)
if err == nil {
err = checked.failure
} }
_ = reporter.RenderOutput(func(w io.Writer) error {
_, err := io.WriteString(w, content)
return err return err
})
return Success()
} }
func incompleteReport(err error) Outcome { func renderVersionOutcome(identity Identity, reporter *Reporter) Outcome {
outcome := Failure(err) if identity.Version == "" {
outcome.ReportIncomplete = true identity.Version = "dev"
outcome.DeliveryError = err }
return outcome _ = reporter.Report(Report{Value: versionDocument{Name: identity.Name, Version: identity.Version, Revision: identity.Revision}, TextTemplate: versionTextTemplate})
return Success()
} }
func readBuildInfo() *debug.BuildInfo { func readBuildInfo() *debug.BuildInfo {
@@ -210,13 +127,3 @@ func commandKitBuildIdentity(info *debug.BuildInfo) Identity {
} }
return identity return identity
} }
func (a *App) helpCommandTarget(targets []string) ([]string, error) {
if len(targets) == 0 {
return nil, nil
}
if _, ok := a.commandAt(targets); !ok {
return nil, fmt.Errorf("unknown help path %q", strings.Join(targets, " "))
}
return append([]string(nil), targets...), nil
}

110
builtins_reporting_test.go Normal file
View File

@@ -0,0 +1,110 @@
package commandkit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
)
func builtinReportingTestApp(t *testing.T) *App {
t.Helper()
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example", Version: "1.2.3"}, command,
Group("promptkit", "PromptKit", Group("list", "List", simpleRunnable("collections"))),
Alias("r", "run"), Alias("list collections", "promptkit list collections"),
)
if err != nil {
t.Fatal(err)
}
return app
}
func TestHelpAndVersionUseManagedOutput(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "--quiet"}, {"version", "--quiet"}, {"list", "--help", "--quiet"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || outcome.Status != 0 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if output.Len() != 0 || diagnostics.Len() != 0 {
t.Errorf("Execute(%q) wrote output %q or diagnostics %q", args, output.String(), diagnostics.String())
}
if outcome.Presentation.Output.State != DeliverySuppressed {
t.Errorf("Execute(%q) output presentation = %#v", args, outcome.Presentation.Output)
}
}
for _, test := range []struct {
args []string
want string
}{
{args: []string{"help", "run"}, want: "run"},
{args: []string{"help", "r"}, want: "Alias for: Example run"},
{args: []string{"list", "--help"}, want: "Reporting Flags"},
{args: []string{"version"}, want: "Example 1.2.3"},
{args: []string{"version", "--format=yaml"}, want: "version: 1.2.3"},
} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), test.args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || !strings.Contains(output.String(), test.want) {
t.Errorf("Execute(%q) = %#v, output %q", test.args, outcome, output.String())
}
}
}
func TestHelpAndVersionDestinationFailuresUseReporterObservations(t *testing.T) {
app := builtinReportingTestApp(t)
writeErr := errors.New("help/version destination failed")
for _, args := range [][]string{{"help", "run"}, {"version"}} {
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || outcome.Presentation.Output.State != DeliveryIncomplete {
t.Errorf("Execute(%q) presentation = %#v", args, outcome.Presentation)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestInvalidHelpAndVersionRequestsRemainUsageOutcomes(t *testing.T) {
app := builtinReportingTestApp(t)
for _, args := range [][]string{{"help", "unknown"}, {"version", "--scope=unknown"}, {"version", "--format=unknown"}} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Errorf("Execute(%q) = %#v", args, outcome)
}
if !strings.Contains(diagnostics.String(), "Usage: Example") {
t.Errorf("Execute(%q) diagnostics = %q", args, diagnostics.String())
}
}
}
func TestQuietBuiltinRenderingSuppressesDestinationWrites(t *testing.T) {
app := builtinReportingTestApp(t)
// A quiet successful run must not touch its supplied result destination.
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"version", "--quiet"}, Streams{
Out: executeReportingFailureWriter{err: io.ErrClosedPipe}, Err: &diagnostics,
})
if outcome.Kind != OutcomeSuccess || outcome.Presentation.Output.State != DeliverySuppressed {
t.Fatalf("quiet version outcome = %#v", outcome)
}
if diagnostics.Len() != 0 {
t.Fatalf("quiet version diagnostics = %q", diagnostics.String())
}
}

View File

@@ -13,7 +13,7 @@ func TestCommandKitBuildIdentityProjectsEffectiveModuleVersion(t *testing.T) {
}{ }{
{ {
name: "main module tag", name: "main module tag",
info: &debug.BuildInfo{Main: debug.Module{Path: commandKitModulePath, Version: "v1.2.3"}}, info: &debug.BuildInfo{Main: debug.Module{Path: "go.maximumdirect.net/commandkit", Version: "v1.2.3"}},
want: "v1.2.3", want: "v1.2.3",
}, },
{ {

View File

@@ -1,120 +0,0 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"fmt"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
"strings"
"testing"
)
func TestStandardConfigurationCancellationBoundaries(t *testing.T) {
for _, operation := range []string{"validate", "show", "sources", "explain"} {
for _, before := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/before=%t", operation, before), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if before {
cancel()
}
loads, contexts := 0, 0
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
loads++
cancel()
return configkit.Load(configkit.NoSource(), configkit.Options[struct{}]{})
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}, NewContext: func() struct{} { contexts++; return struct{}{} }}); err != nil {
t.Fatal(err)
}
args := []string{"config", operation}
if operation == "explain" {
args = append(args, "--path", "")
}
var out bytes.Buffer
result := app.Execute(ctx, args, commandkit.Streams{Out: &out})
want := 1
if before {
want = 0
}
if result.Kind != commandkit.OutcomeOperational || result.Status != 1 || !errors.Is(result.Cause, context.Canceled) || loads != want || contexts != want || out.Len() != 0 {
t.Fatalf("result=%#v loads=%d contexts=%d out=%q", result, loads, contexts, out.String())
}
})
}
}
}
func TestAggregateCancellationRetainsCompletedValidationAndDiagnostics(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
calls := 0
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
if calls == 2 {
cancel()
}
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}}, nil
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
}}); err != nil {
t.Fatal(err)
}
var out, diagnostics bytes.Buffer
result := app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &out, Err: &diagnostics})
if !errors.Is(result.Cause, context.Canceled) || !result.ReportIncomplete || calls != 2 || out.String() != "app: valid\n" || strings.Count(diagnostics.String(), "acquired warning") != 2 {
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, calls, out.String(), diagnostics.String())
}
}
func TestDefinitionInspectionCancellationBoundaries(t *testing.T) {
for _, resource := range []string{"prompt", "profile"} {
for _, before := range []bool{true, false} {
t.Run(fmt.Sprintf("%s/before=%t", resource, before), func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if before {
cancel()
}
calls, contexts := 0, 0
diagnostics := []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}
suite := commandkit.PromptKitSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} },
InspectPrompt: func(context.Context, commandkit.Selection, struct{}, string, string) (commandkit.Acquired[*promptkit.PromptInspection], error) {
calls++
cancel()
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{}, Diagnostics: diagnostics}, nil
},
InspectProfile: func(context.Context, commandkit.Selection, struct{}, string) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
calls++
cancel()
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{}, Diagnostics: diagnostics}, nil
},
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterPromptKit(app, suite); err != nil {
t.Fatal(err)
}
var out, errs bytes.Buffer
result := app.Execute(ctx, []string{"promptkit", "show", resource, "example"}, commandkit.Streams{Out: &out, Err: &errs})
want := 1
if before {
want = 0
}
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, context.Canceled) || calls != want || contexts != want || out.Len() != 0 || (!before && !strings.Contains(errs.String(), "acquired warning")) {
t.Fatalf("result=%#v calls=%d contexts=%d out=%q diagnostics=%q", result, calls, contexts, out.String(), errs.String())
}
})
}
}
}

76
cli_json.go Normal file
View File

@@ -0,0 +1,76 @@
package commandkit
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// ParseCLIJSON parses exactly one JSON value for use in CLI inputs. Integer
// tokens become int64, or uint64 for larger nonnegative integers; decimal and
// exponent tokens become float64. Nested values follow the same rules. Syntax
// errors and numbers outside the selected Go types' ranges are returned.
func ParseCLIJSON(input string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(input))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("unexpected additional JSON value")
}
return convertCLIJSON(value)
}
// CLI inputs use ordinary Go numbers for upstream consumers. Classification
// follows the token's syntax, so 1.0 and 1e0 remain float64. Unlike report
// serialization, this boundary deliberately accepts float64 approximation.
func convertCLIJSON(value any) (any, error) {
switch v := value.(type) {
case json.Number:
token := string(v)
if strings.ContainsAny(token, ".eE") {
result, err := strconv.ParseFloat(token, 64)
if err != nil {
return nil, fmt.Errorf("JSON number %q: %w", token, err)
}
return result, nil
}
result, err := strconv.ParseInt(token, 10, 64)
if err == nil {
return result, nil
}
if !strings.HasPrefix(token, "-") {
unsigned, unsignedErr := strconv.ParseUint(token, 10, 64)
if unsignedErr == nil {
return unsigned, nil
}
return nil, fmt.Errorf("JSON integer %q: %w", token, unsignedErr)
}
return nil, fmt.Errorf("JSON integer %q: %w", token, err)
case []any:
for index, item := range v {
converted, err := convertCLIJSON(item)
if err != nil {
return nil, err
}
v[index] = converted
}
case map[string]any:
for key, item := range v {
converted, err := convertCLIJSON(item)
if err != nil {
return nil, err
}
v[key] = converted
}
}
return value, nil
}

44
cli_json_test.go Normal file
View File

@@ -0,0 +1,44 @@
package commandkit
import (
"errors"
"math"
"reflect"
"strconv"
"testing"
)
func TestParseCLIJSONValues(t *testing.T) {
for _, tc := range []struct {
input string
want any
}{
{`{"a":[null,true,"x",-9223372036854775808,9223372036854775807,9223372036854775808,18446744073709551615],"b":{"n":1.0,"exp":1e3}}`, map[string]any{
"a": []any{nil, true, "x", int64(math.MinInt64), int64(math.MaxInt64), uint64(1 << 63), uint64(math.MaxUint64)},
"b": map[string]any{"n": float64(1), "exp": float64(1000)},
}},
{`0`, int64(0)}, {`-0`, int64(0)}, {`42.0`, float64(42)}, {`1e0`, float64(1)},
{`0.1`, float64(0.1)}, {`"1"`, "1"}, {`false`, false}, {`null`, nil},
} {
t.Run(tc.input, func(t *testing.T) {
got, err := ParseCLIJSON(tc.input)
if err != nil || !reflect.DeepEqual(got, tc.want) {
t.Fatalf("got %#v, %v; want %#v", got, err, tc.want)
}
})
}
}
func TestParseCLIJSONRejectsInvalidInput(t *testing.T) {
for _, input := range []string{"", "[", "01", `1 2`, `true false`, `{} garbage`, `-9223372036854775809`, `18446744073709551616`, `1e400`, `[-1e400]`} {
t.Run(input, func(t *testing.T) {
if _, err := ParseCLIJSON(input); err == nil {
t.Fatalf("accepted %q", input)
}
})
}
_, err := ParseCLIJSON(`18446744073709551616`)
if !errors.Is(err, strconv.ErrRange) {
t.Fatalf("range error = %v", err)
}
}

View File

@@ -1,24 +1,86 @@
package commandkit package commandkit
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
) )
// Command is an opaque command declaration. Use NewCommand or Group to create // Command is an opaque, reusable declaration of canonical commands and groups.
// declarations and WithChildren to compose them. // NewCommand, Group, RootGroup, and integration command factories return
type Command struct { // Command values. Capture copies declaration slices but retains callback and
// caller-owned object references. Installation validates the declaration
// before changing an App registry.
type Command struct{ record *commandRecord }
// Declaration is a captured canonical command or application routing rule
// accepted by New and App.Register. Use []Declaration when collecting both
// kinds. Group and RootGroup children remain Commands; install routing rules
// alongside the completed command tree through New or Register. Only
// CommandKit declaration types implement Declaration.
//
// A zero Command, zero RoutingRule, nil declaration, or typed nil declaration
// pointer is invalid. Use NoCommand to represent an intentionally absent
// optional command.
type Declaration interface {
commandkitDeclaration()
}
func (Command) commandkitDeclaration() {}
// RoutingRule is an opaque, reusable command or flag alias declaration. Alias,
// FlagAlias, and FlagAliasFor capture routing rules eagerly and retain any
// construction issues for New or App.Register to report. Identical duplicate
// mappings are accepted; competing targets for the same source are errors.
type RoutingRule struct{ record *routingRuleRecord }
func (RoutingRule) commandkitDeclaration() {}
type routingRuleKind uint8
const (
commandAliasRule routingRuleKind = iota + 1
globalFlagAliasRule
scopedFlagAliasRule
)
type capturedFlagSpelling struct {
name string
short bool
}
type routingRuleRecord struct {
kind routingRuleKind
sourcePath commandpath.Path
targetPath commandpath.Path
scopePath commandpath.Path
sourceFlag capturedFlagSpelling
targetFlag capturedFlagSpelling
sourceText string
targetText string
scopeText string
issues []error
}
type commandRecord struct {
name string name string
summary string summary string
description string description string
children []Command children []Command
payload commandPayload payload commandPayload
issues []error
rootOnly bool
completion bool
absent bool
invalid bool
} }
// Supplied carries a value and whether it was explicitly provided. // Supplied carries a value and whether it was explicitly provided. Defaults can
// have Set=false; an explicitly supplied empty, false, or zero value has Set=true.
type Supplied[T any] struct { type Supplied[T any] struct {
// Value is the associated value. // Value is the associated value.
Value T Value T
@@ -26,7 +88,8 @@ type Supplied[T any] struct {
Set bool Set bool
} }
// FlagMeta describes a command flag. // FlagMeta describes a command flag. Required applies when execution prepares
// the selected command; help reports the metadata without requiring the value.
type FlagMeta struct { type FlagMeta struct {
// Name is the flag name without leading dashes. // Name is the flag name without leading dashes.
Name string Name string
@@ -36,28 +99,51 @@ type FlagMeta struct {
Usage string Usage string
// Required reports whether execution requires the flag to be supplied. // Required reports whether execution requires the flag to be supplied.
Required bool Required bool
// Completion describes optional value suggestions.
Completion CompletionHint
} }
// ArgumentMeta describes a positional command argument. // ArgumentMeta describes a positional command argument. Its name and usage are
// used in generated help.
type ArgumentMeta struct { type ArgumentMeta struct {
// Name is the argument name shown in command help. // Name is the argument name shown in command help.
Name string Name string
// Usage briefly describes the argument. // Usage briefly describes the argument.
Usage string Usage string
// Completion describes optional value suggestions.
Completion CompletionHint
} }
// Flag represents a typed binding for a command flag. // Flag represents a typed binding for a command flag. Copies retain binding
// identity; separately constructed flags are distinct even when their metadata
// and callbacks are equivalent.
type Flag[I any] struct { type Flag[I any] struct {
binding flagBinding[I] binding flagBinding[I]
} }
type bindingIdentity struct{ marker byte }
type preparedValue[I any] struct {
identity *bindingIdentity
supplied bool
apply func(*I)
ordered []preparedOccurrence[I]
}
type preparedOccurrence[I any] struct {
// Captured bindings share this position with the outer prepared occurrence.
ordinal *int
apply func(*I)
}
type flagBinding[I any] struct { type flagBinding[I any] struct {
identity *bindingIdentity
meta FlagMeta meta FlagMeta
deferredRequired bool
defaultText string defaultText string
repeated bool repeated bool
boolean bool boolean bool
prepare func([]string) (func(*I), error) prepare func([]string) (preparedValue[I], error)
clear func(*I)
valid bool valid bool
} }
@@ -67,14 +153,19 @@ type Argument[I any] struct {
} }
type argumentBinding[I any] struct { type argumentBinding[I any] struct {
identity *bindingIdentity
meta ArgumentMeta meta ArgumentMeta
min int min int
max int max int
prepare func([]string) (func(*I), error) prepare func([]string) (preparedValue[I], error)
valid bool valid bool
} }
// CommandSpec declares one command using typed input and handler callbacks. // CommandSpec declares one command using typed input and handler callbacks.
// Capture copies its Flags, Arguments, and Children slices. NewInput runs only
// when execution reaches input construction; a nil factory yields a fresh zero
// value. CheckInput runs after supplied values are converted and applied, before
// Run. Run may be nil for a parent that only groups child commands.
type CommandSpec[I any] struct { type CommandSpec[I any] struct {
// Name is one command path component. // Name is one command path component.
Name string Name string
@@ -82,6 +173,8 @@ type CommandSpec[I any] struct {
Summary string Summary string
// Description provides additional command detail. // Description provides additional command detail.
Description string Description string
// Children are relative child declarations captured with this command.
Children []Command
// NewInput creates an input value for execution. A nil factory uses a fresh // NewInput creates an input value for execution. A nil factory uses a fresh
// zero value. // zero value.
NewInput func() I NewInput func() I
@@ -98,15 +191,14 @@ type CommandSpec[I any] struct {
type commandPayload interface { type commandPayload interface {
validate() error validate() error
hasRun() bool hasRun() bool
bridgeFlags() []internalcobra.Flag bridgeFlags() []commandmeta.Flag
bridgeArguments() []internalcobra.Argument bridgeArguments() []commandmeta.Argument
prepare([][]string, []string, bool) (func() any, error) prepare([][]string, [][]string, []commandmeta.FlagOccurrence, []string, bool) (func(*execution) Outcome, error)
checkInput(any) error
run(Invocation, any) Outcome
} }
type typedCommand[I any] struct { type typedCommand[I any] struct {
spec CommandSpec[I] spec CommandSpec[I]
privateRun func(commandExecution, I) Outcome
} }
func (c typedCommand[I]) validate() error { func (c typedCommand[I]) validate() error {
@@ -114,14 +206,14 @@ func (c typedCommand[I]) validate() error {
} }
func (c typedCommand[I]) hasRun() bool { func (c typedCommand[I]) hasRun() bool {
return c.spec.Run != nil return c.spec.Run != nil || c.privateRun != nil
} }
func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag { func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
flags := make([]internalcobra.Flag, len(c.spec.Flags)) flags := make([]commandmeta.Flag, len(c.spec.Flags))
for index, flag := range c.spec.Flags { for index, flag := range c.spec.Flags {
binding := flag.binding binding := flag.binding
flags[index] = internalcobra.Flag{ flags[index] = commandmeta.Flag{
Name: binding.meta.Name, Name: binding.meta.Name,
Short: binding.meta.Short, Short: binding.meta.Short,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
@@ -129,143 +221,257 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
Boolean: binding.boolean, Boolean: binding.boolean,
Repeated: binding.repeated, Repeated: binding.repeated,
Required: binding.meta.Required, Required: binding.meta.Required,
Completion: bridgeCompletion(binding.meta.Completion),
} }
} }
return flags return flags
} }
func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument { func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
arguments := make([]internalcobra.Argument, len(c.spec.Arguments)) arguments := make([]commandmeta.Argument, len(c.spec.Arguments))
for index, argument := range c.spec.Arguments { for index, argument := range c.spec.Arguments {
binding := argument.binding binding := argument.binding
arguments[index] = internalcobra.Argument{ arguments[index] = commandmeta.Argument{
Name: binding.meta.Name, Name: binding.meta.Name,
Usage: binding.meta.Usage, Usage: binding.meta.Usage,
Min: binding.min, Min: binding.min,
Max: binding.max, Max: binding.max,
Completion: bridgeCompletion(binding.meta.Completion),
} }
} }
return arguments return arguments
} }
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) { func (c typedCommand[I]) prepare(flagValues, flagSpellings [][]string, occurrences []commandmeta.FlagOccurrence, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired) prepared, err := prepareInputWithOccurrences(c.spec, flagValues, flagSpellings, occurrences, operands, enforceRequired)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return func() any { return func(current *execution) Outcome {
input := prepared.build() input := prepared.build()
return &input if c.spec.CheckInput != nil {
if err := c.spec.CheckInput(input); err != nil {
return classifyInputError(err)
}
}
if c.privateRun != nil {
return c.privateRun(current.commandContext(), input)
}
return c.spec.Run(current.invocation(), input)
}, nil }, nil
} }
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome { // NewCommand captures a typed canonical command declaration without
return c.spec.Run(invocation, *input.(*I)) // constructing its input or running callbacks. It copies the spec's flags,
// arguments, and children slices. Construction issues are retained and
// reported when the declaration is installed; NewCommand itself does not
// return an error. The returned declaration can be reused across Apps.
func NewCommand[I any](spec CommandSpec[I]) Command {
return captureCommand(spec, nil)
} }
func (c typedCommand[I]) checkInput(input any) error { func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command {
if c.spec.CheckInput == nil {
return nil
}
return c.spec.CheckInput(*input.(*I))
}
// NewCommand validates and creates a typed command declaration. It retains
// callbacks without invoking them and copies the declarative flag and argument
// slices.
func NewCommand[I any](spec CommandSpec[I]) (Command, error) {
if err := validateCommandName(spec.Name); err != nil {
return Command{}, err
}
copySpec := spec copySpec := spec
copySpec.Flags = append([]Flag[I](nil), spec.Flags...) copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...) copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
payload := typedCommand[I]{spec: copySpec} copySpec.Children = append([]Command(nil), spec.Children...)
payload := typedCommand[I]{spec: copySpec, privateRun: privateRun}
record := &commandRecord{
name: spec.Name, summary: spec.Summary, description: spec.Description,
children: copySpec.Children, payload: payload,
}
if err := payload.validate(); err != nil { if err := payload.validate(); err != nil {
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err) record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
} }
return Command{ captureComposition(record)
name: spec.Name, return Command{record: record}
summary: spec.Summary,
description: spec.Description,
payload: payload,
}, nil
} }
// Group creates a non-runnable command group with the supplied children. // Group captures a non-runnable canonical command group and a copy of its
func Group(name, summary string, children ...Command) (Command, error) { // relative Command child slice. Child declarations remain reusable and are not
if err := validateCommandName(name); err != nil { // recursively copied. Routing rules cannot be children; install them alongside
return Command{}, err // the root command tree through New or App.Register.
} func Group(name, summary string, children ...Command) Command {
group := Command{name: name, summary: summary, children: cloneCommands(children)} record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
if err := validateCommandDeclaration(group); err != nil { captureComposition(record)
return Command{}, err return Command{record: record}
}
return group, nil
} }
// WithChildren returns a copy of parent with the supplied relative child // RootGroup captures a group that may only be installed at the application
// declarations. The parent's optional handler and input bindings are retained. // root. Installation rejects it below any other command, including through
func WithChildren(parent Command, children ...Command) (Command, error) { // wrappers. Its children are canonical Commands; install routing rules at the
if err := validateCommandDeclaration(parent); err != nil { // application root separately.
return Command{}, fmt.Errorf("parent command: %w", err) func RootGroup(name, summary string, children ...Command) Command {
} command := Group(name, summary, children...)
combined := make([]Command, 0, len(parent.children)+len(children)) command.record.rootOnly = true
combined = append(combined, parent.children...)
combined = append(combined, children...)
result := parent
result.children = cloneCommands(combined)
if err := validateCommandDeclaration(result); err != nil {
return Command{}, err
}
return result, nil
}
func cloneCommands(commands []Command) []Command {
if len(commands) == 0 {
return nil
}
cloned := make([]Command, len(commands))
for i, command := range commands {
cloned[i] = cloneCommand(command)
}
return cloned
}
func cloneCommand(command Command) Command {
command.children = cloneCommands(command.children)
return command return command
} }
func validateCommandDeclaration(command Command) error { // WithError returns a new Command declaration that also retains a non-nil err
if err := validateCommandName(command.name); err != nil { // for installation. Earlier declaration and child errors remain available,
return err // and wrapped error identity is preserved. A nil err returns command unchanged.
} func WithError(command Command, err error) Command {
if command.payload != nil { if err == nil {
if err := command.payload.validate(); err != nil { return command
return fmt.Errorf("command %q: %w", command.name, err)
} }
var record commandRecord
if command.record == nil {
record.invalid = true
} else {
record = *command.record
record.issues = append([]error(nil), record.issues...)
} }
record.issues = append(record.issues, err)
return Command{record: &record}
}
children := make(map[string]struct{}, len(command.children)) // NoCommand explicitly represents an absent optional command declaration.
for _, child := range command.children { // Installation skips it, but reports errors attached to it; a zero Command is
if err := validateCommandDeclaration(child); err != nil { // invalid and is not an absence marker. Routing rules have no absence marker.
return err func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
// Alias captures a command-path alias from path to target. Paths are
// whitespace-separated sequences of command names relative to the application
// root. The target must resolve to a canonical command or group; aliases do not
// add operands or transform flag values. Construction issues are retained for
// New or App.Register to report. Identical mappings are accepted, while
// different targets for the same source path are installation errors.
func Alias(path, target string) RoutingRule {
record := &routingRuleRecord{
kind: commandAliasRule,
sourceText: path,
targetText: target,
} }
if _, exists := children[child.name]; exists { record.sourcePath = captureRoutingPath("source", path, &record.issues)
return fmt.Errorf("duplicate child command %q under %q", child.name, command.name) record.targetPath = captureRoutingPath("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAlias captures an application-wide flag spelling alias. Spellings must
// be one long flag name or one individual short spelling. The mapping applies
// wherever its canonical target flag is available and contributes nothing on
// commands without that flag. Identical mappings are accepted; different
// targets for the same source spelling in this scope are installation errors.
func FlagAlias(spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: globalFlagAliasRule,
sourceText: spelling,
targetText: target,
} }
children[child.name] = struct{}{} record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
// FlagAliasFor captures a flag spelling alias scoped to a canonical command
// path, not an alias path. The command and canonical target flag must be
// available in the complete installation. Scoped mappings take precedence over
// application-wide mappings with the same source spelling. Identical mappings
// are accepted; competing targets in the same scope are installation errors.
func FlagAliasFor(commandPath, spelling, target string) RoutingRule {
record := &routingRuleRecord{
kind: scopedFlagAliasRule,
sourceText: spelling,
targetText: target,
scopeText: commandPath,
}
record.scopePath = captureRoutingPath("command scope", commandPath, &record.issues)
record.sourceFlag = captureFlagSpelling("source", spelling, &record.issues)
record.targetFlag = captureFlagSpelling("target", target, &record.issues)
return RoutingRule{record: record}
}
func captureRoutingPath(label, value string, issues *[]error) commandpath.Path {
path, err := commandpath.ParseDeclaration(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s path: %w", label, err))
}
return path
}
func captureFlagSpelling(label, value string, issues *[]error) capturedFlagSpelling {
spelling, err := parseFlagSpelling(value)
if err != nil {
*issues = append(*issues, fmt.Errorf("invalid %s flag spelling %q: %w", label, value, err))
}
return spelling
}
func parseFlagSpelling(value string) (capturedFlagSpelling, error) {
if strings.HasPrefix(value, "--") {
name := value[2:]
if validFlagName(name) {
return capturedFlagSpelling{name: name}, nil
}
}
if len(value) == 2 && value[0] == '-' && value[1] != '-' {
return capturedFlagSpelling{name: value[1:], short: true}, nil
}
return capturedFlagSpelling{}, fmt.Errorf("must be a long flag or one-byte short flag spelling")
}
func captureComposition(record *commandRecord) {
if err := validateCommandName(record.name); err != nil {
record.issues = append(record.issues, err)
}
seen := make(map[string]struct{}, len(record.children))
for _, child := range record.children {
if child.record == nil {
record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name))
continue
}
if child.record.absent {
continue
}
name := child.record.name
if _, exists := seen[name]; exists {
record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name))
}
seen[name] = struct{}{}
}
}
func validateCommandDeclaration(command Command) error {
return validateCommandPlacement(command)
}
func validateCommandPlacement(command Command) error {
var issues []error
collectDeclarationIssues(command, nil, true, &issues)
return errors.Join(issues...)
}
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
if command.record == nil {
*issues = append(*issues, errors.New("invalid zero command declaration"))
return
}
record := command.record
path := append(append([]string(nil), parents...), record.name)
label := commandpath.FromTokens(path).String()
for _, issue := range record.issues {
if label != "" {
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
} else {
*issues = append(*issues, issue)
}
}
if record.rootOnly && !root {
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent {
return
}
for _, child := range record.children {
if child.record == nil {
continue
}
collectDeclarationIssues(child, path, false, issues)
} }
return nil
} }
func validateCommandName(name string) error { func validateCommandName(name string) error {
fields := strings.Fields(name) return commandpath.ValidateComponent(name)
if len(fields) != 1 || fields[0] != name || strings.HasPrefix(name, "-") || name == "--" {
return fmt.Errorf("command name %q must be one non-option token", name)
}
return nil
} }
func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error { func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
@@ -279,6 +485,9 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.meta.Name == "help" { if binding.meta.Name == "help" {
return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name) return fmt.Errorf("flag name %q is reserved for help", binding.meta.Name)
} }
if commandmeta.IsReportingFlag(binding.meta.Name) {
return fmt.Errorf("flag name %q is reserved for reporting", binding.meta.Name)
}
if _, exists := flagNames[binding.meta.Name]; exists { if _, exists := flagNames[binding.meta.Name]; exists {
return fmt.Errorf("duplicate flag name %q", binding.meta.Name) return fmt.Errorf("duplicate flag name %q", binding.meta.Name)
} }
@@ -295,7 +504,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
} }
shortNames[binding.meta.Short] = struct{}{} shortNames[binding.meta.Short] = struct{}{}
} }
if !binding.valid || binding.prepare == nil { if binding.identity == nil || !binding.valid || binding.prepare == nil {
return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name) return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name)
} }
} }
@@ -308,7 +517,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) { if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max) return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
} }
if !binding.valid || binding.prepare == nil { if binding.identity == nil || !binding.valid || binding.prepare == nil {
return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name) return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name)
} }
} }

119
completion.go Normal file
View File

@@ -0,0 +1,119 @@
package commandkit
import (
"fmt"
"io"
"strings"
"unicode"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
// Completion declares opt-in shell script generation for the executable
// basename used to invoke the application. Install it at the application root.
// Generated shell identifiers support letters, digits, underscores, dots,
// hyphens, and colons; a leading hyphen is not supported.
func Completion(executable string) Command {
children := make([]Command, 0, 3)
for _, shell := range []string{"bash", "zsh", "fish"} {
shell := shell
children = append(children, NewCommand(CommandSpec[struct{}]{
Name: shell, Summary: "Generate " + shell + " completion script.",
Run: func(invocation Invocation, _ struct{}) Outcome {
_ = invocation.Reporter().RenderOutput(func(writer io.Writer) error {
return internalcobra.CompletionScript(writer, executable, shell)
})
return Success()
},
}))
}
command := RootGroup("completion", "Generate shell completion scripts.", children...)
command.record.completion = true
if !validCompletionExecutable(executable) {
return WithError(command, fmt.Errorf("unsupported completion executable basename %q", executable))
}
return command
}
// Cobra emits the basename into shell identifiers and unquoted command words.
func validCompletionExecutable(name string) bool {
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, "-") {
return false
}
for _, ch := range name {
if !(unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '_' || ch == '-' || ch == ':' || ch == '.') {
return false
}
}
return true
}
// CompletionChoice is a literal suggested value and its optional description.
type CompletionChoice struct {
// Value is inserted as data; it is not interpreted as shell source.
Value string
// Description is optional context displayed by shells that support it.
Description string
}
// CompletionHint selects suggestions for a flag or positional value. Its zero
// value requests no value suggestions; hints do not constrain accepted input.
type CompletionHint struct {
kind completionHintKind
choices *completionChoicesData
}
// Keep the public metadata structs comparable without exposing mutable choices.
type completionChoicesData struct {
values []CompletionChoice
}
type completionHintKind uint8
const (
completionNone completionHintKind = iota
completionChoices
completionFiles
completionDirectories
)
// CompleteChoices suggests literal values matching the current value prefix.
// The supplied slice is copied. An empty list produces no suggestions. Empty
// values and values containing a newline, tab, or NUL cannot be presented as
// suggestions, but remain valid input if the command's converter accepts them.
func CompleteChoices(choices ...CompletionChoice) CompletionHint {
hint := CompletionHint{kind: completionChoices}
if len(choices) != 0 {
hint.choices = &completionChoicesData{values: append([]CompletionChoice(nil), choices...)}
}
return hint
}
// CompleteFiles requests shell filename completion.
func CompleteFiles() CompletionHint { return CompletionHint{kind: completionFiles} }
// CompleteDirectories requests shell directory completion.
func CompleteDirectories() CompletionHint { return CompletionHint{kind: completionDirectories} }
// NoCompletion requests no value suggestions.
func NoCompletion() CompletionHint { return CompletionHint{} }
func bridgeCompletion(hint CompletionHint) commandmeta.CompletionHint {
result := commandmeta.CompletionHint{}
switch hint.kind {
case completionChoices:
result.Kind = commandmeta.CompletionChoices
if hint.choices == nil {
break
}
for _, choice := range hint.choices.values {
result.Choices = append(result.Choices, commandmeta.CompletionChoice{Value: choice.Value, Description: choice.Description})
}
case completionFiles:
result.Kind = commandmeta.CompletionFiles
case completionDirectories:
result.Kind = commandmeta.CompletionDirectories
}
return result
}

78
completion_context.go Normal file
View File

@@ -0,0 +1,78 @@
package commandkit
import (
"strings"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
)
type completionContext struct {
route routeResult
command *commandmeta.Command
aliases []commandmeta.FlagAlias
completed []string
tail []string
current string
commandPosition bool
}
func resolveCompletionContext(snapshot *routingSnapshot, completed []string, current string) completionContext {
context := completionContext{completed: append([]string(nil), completed...), current: current}
if snapshot == nil {
return context
}
context.route = resolveEnteredRoute(completed, snapshot)
switch context.route.kind {
case routeCanonical:
context.command = completionCommand(snapshot, context.route.canonicalPath)
if context.command == nil || len(context.route.args) < len(context.route.canonicalPath) {
context.command = nil
return context
}
context.tail = append([]string(nil), context.route.args[len(context.route.canonicalPath):]...)
context.aliases = snapshot.flagAliases[context.route.canonicalPath.Key()].lookupRecords()
context.commandPosition = len(context.tail) == 0
case routeDiscovery:
context.command = &commandmeta.Command{CoreFlags: commandmeta.ReportingFlags()}
context.tail = append([]string(nil), context.route.args...)
context.aliases = snapshot.discoveryFlags[context.route.discoveryPath.Key()].lookupRecords()
}
return context
}
func completionCommand(snapshot *routingSnapshot, path commandpath.Path) *commandmeta.Command {
if len(path) == 0 {
return snapshot.metadata
}
children := snapshot.canonical
var node *canonicalNode
for _, name := range path {
node = children[name]
if node == nil {
return nil
}
children = node.children
}
return node.metadata
}
func completionChildren(snapshot *routingSnapshot, context completionContext) []commandmeta.CompletionChoice {
if snapshot == nil || context.command == nil || !context.commandPosition {
return nil
}
var choices []commandmeta.CompletionChoice
for _, child := range context.command.Children {
if !strings.HasPrefix(child.Name, context.current) || (len(context.route.canonicalPath) == 0 && child.Name == internalcobra.CompletionQueryToken) {
continue
}
entered := append(append([]string(nil), context.completed...), child.Name)
route := resolveEnteredRoute(entered, snapshot)
intended := append(context.route.canonicalPath.Clone(), child.Name)
if route.kind == routeCanonical && route.canonicalPath.Equal(intended) {
choices = append(choices, commandmeta.CompletionChoice{Value: child.Name, Description: child.Summary})
}
}
return choices
}

View File

@@ -0,0 +1,89 @@
package commandkit
import (
"cmp"
"reflect"
"slices"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestCompletionCommandContext(t *testing.T) {
parse := func(value string) (string, error) { return value, nil }
mixed := NewCommand(CommandSpec[struct{}]{
Name: "mixed", Children: []Command{simpleRunnable("child")},
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "item"}, false, parse, func(*struct{}, Supplied[string]) {})},
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "example"},
Group("workspace", "", Group("list", "", simpleRunnable("shadow"), simpleRunnable("show"))),
simpleRunnable("other"), mixed, Completion("example"),
Alias("ws", "workspace"), Alias("w", "ws"), Alias("ws list shadow", "other"),
Alias("legacy inspect", "workspace list"), FlagAlias("--silent", "--quiet"),
FlagAliasFor("workspace list", "--detail", "--verbose"),
)
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
for _, tc := range []struct {
name string
completed []string
current string
path string
position bool
choices []string
kind routeKind
}{
{"root", nil, "work", "", true, []string{"workspace"}, routeCanonical},
{"group", []string{"workspace"}, "li", "workspace", true, []string{"list"}, routeCanonical},
{"entered alias", []string{"ws", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
{"alias chain", []string{"w", "list"}, "sh", "workspace list", true, []string{"show"}, routeCanonical},
{"discovery", []string{"legacy"}, "in", "", false, nil, routeDiscovery},
{"complete alias", []string{"legacy", "inspect"}, "sh", "workspace list", true, []string{"shadow", "show"}, routeCanonical},
{"runnable child", []string{"mixed"}, "ch", "mixed", true, []string{"child"}, routeCanonical},
{"unmatched child", []string{"mixed"}, "other", "mixed", true, nil, routeCanonical},
{"runnable operand", []string{"mixed", "value"}, "ch", "mixed", false, nil, routeCanonical},
{"flag ends path", []string{"mixed", "--quiet"}, "ch", "mixed", false, nil, routeCanonical},
{"flag before child", []string{"workspace", "--quiet"}, "li", "workspace", false, nil, routeCanonical},
{"unknown", []string{"missing"}, "x", "", false, nil, routeUnknown},
{"hidden endpoint", nil, "__comp", "", true, nil, routeCanonical},
} {
t.Run(tc.name, func(t *testing.T) {
original := append([]string(nil), tc.completed...)
context := resolveCompletionContext(snapshot, tc.completed, tc.current)
if context.route.kind != tc.kind || context.route.canonicalPath.String() != tc.path || context.commandPosition != tc.position || !reflect.DeepEqual(tc.completed, original) {
t.Fatalf("context=%#v completed=%q", context, tc.completed)
}
var names []string
for _, choice := range completionChildren(snapshot, context) {
names = append(names, choice.Value)
}
if !reflect.DeepEqual(names, tc.choices) {
t.Fatalf("children=%q, want %q", names, tc.choices)
}
if tc.kind == routeDiscovery && !reflect.DeepEqual(context.aliases, []commandmeta.FlagAlias{{Source: "--silent", Target: "--quiet"}}) {
t.Fatalf("discovery aliases=%#v", context.aliases)
}
if (tc.name == "runnable child" || tc.name == "unmatched child") && (context.command == nil || len(context.command.Arguments) != 1) {
t.Fatalf("runnable operand metadata missing: %#v", context.command)
}
if tc.name == "entered alias" {
actual := slices.Clone(context.aliases)
expected := []commandmeta.FlagAlias{{Source: "--detail", Target: "--verbose"}, {Source: "--silent", Target: "--quiet"}}
compare := func(a, b commandmeta.FlagAlias) int {
if result := cmp.Compare(a.Source, b.Source); result != 0 {
return result
}
return cmp.Compare(a.Target, b.Target)
}
slices.SortFunc(actual, compare)
slices.SortFunc(expected, compare)
if !slices.Equal(actual, expected) {
t.Fatalf("canonical aliases=%#v, want %#v", actual, expected)
}
}
})
}
}

217
completion_flags.go Normal file
View File

@@ -0,0 +1,217 @@
package commandkit
import (
"strings"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
type completionTargetKind uint8
const (
completionTargetNone completionTargetKind = iota
completionTargetCommands
completionTargetFlags
completionTargetValue
completionTargetOperand
)
type completionTarget struct {
kind completionTargetKind
choices []commandmeta.CompletionChoice
hint commandmeta.CompletionHint
prefix string
valuePrefix string
}
type completionFlagState struct {
supplied map[string]bool
operands []string
pending *commandmeta.Flag
ended bool
invalid bool
}
func completionFlags(context completionContext) []commandmeta.Flag {
flags := make([]commandmeta.Flag, 0, len(context.command.Flags)+len(context.command.CoreFlags)+1)
flags = append(flags, commandmeta.HelpFlag())
flags = append(flags, context.command.CoreFlags...)
flags = append(flags, context.command.Flags...)
return flags
}
func walkCompletionFlags(tokens []string, lookup commandmeta.FlagLookup) completionFlagState {
state := completionFlagState{supplied: make(map[string]bool)}
for index := 0; index < len(tokens); index++ {
token := tokens[index]
if state.ended {
state.operands = append(state.operands, token)
continue
}
switch commandmeta.ClassifyFlagToken(token) {
case commandmeta.TerminatorToken:
state.ended = true
case commandmeta.OperandToken:
state.operands = append(state.operands, token)
case commandmeta.LongFlagToken:
spelling, _, attached := commandmeta.SplitLongFlag(token)
flag, ok := lookup.Find(spelling)
if !ok {
state.invalid = true
return state
}
state.supplied[flag.Name] = true
if !flag.Boolean && !attached {
if index+1 == len(tokens) {
state.pending = &flag
return state
}
index++
}
case commandmeta.ShortFlagToken:
for offset := 1; offset < len(token); offset++ {
flag, ok := lookup.Find("-" + token[offset:offset+1])
if !ok {
state.invalid = true
return state
}
state.supplied[flag.Name] = true
suffix := token[offset+1:]
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
break
}
continue
}
if suffix == "" {
if index+1 == len(tokens) {
state.pending = &flag
return state
}
index++
}
break
}
}
}
return state
}
func completionTargetFor(snapshot *routingSnapshot, context completionContext) completionTarget {
if context.command == nil {
return completionTarget{}
}
flags := completionFlags(context)
lookup := commandmeta.NewFlagLookup(flags, context.aliases)
state := walkCompletionFlags(context.tail, lookup)
if state.invalid {
return completionTarget{}
}
if state.pending != nil {
return completionTarget{kind: completionTargetValue, hint: state.pending.Completion, valuePrefix: context.current}
}
if !state.ended && context.commandPosition {
if children := completionChildren(snapshot, context); len(children) > 0 {
return completionTarget{kind: completionTargetCommands, choices: children}
}
}
if !state.ended && strings.HasPrefix(context.current, "-") {
return completionOptionTarget(context.current, flags, lookup, state.supplied)
}
if hint, ok := completionOperandHint(context.command.Arguments, len(state.operands)); ok {
return completionTarget{kind: completionTargetOperand, hint: hint, valuePrefix: context.current}
}
if !state.ended && context.current == "" {
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, state.supplied, "")}
}
return completionTarget{}
}
func completionOptionTarget(current string, flags []commandmeta.Flag, lookup commandmeta.FlagLookup, supplied map[string]bool) completionTarget {
if current == "-" {
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
}
if strings.HasPrefix(current, "--") {
spelling, value, attached := commandmeta.SplitLongFlag(current)
if attached {
if flag, ok := lookup.Find(spelling); ok {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: spelling + "=", valuePrefix: value}
}
return completionTarget{}
}
return completionTarget{kind: completionTargetFlags, choices: completionFlagNames(flags, supplied, current)}
}
used := make(map[string]bool, len(supplied))
for name, value := range supplied {
used[name] = value
}
for offset := 1; offset < len(current); offset++ {
flag, ok := lookup.Find("-" + current[offset:offset+1])
if !ok {
return completionTarget{}
}
used[flag.Name] = true
suffix := current[offset+1:]
if flag.Boolean {
if strings.HasPrefix(suffix, "=") {
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: current[:offset+2], valuePrefix: suffix[1:]}
}
continue
}
prefix := current[:offset+1]
if strings.HasPrefix(suffix, "=") {
prefix += "="
suffix = suffix[1:]
}
return completionTarget{kind: completionTargetValue, hint: flag.Completion, prefix: prefix, valuePrefix: suffix}
}
var choices []commandmeta.CompletionChoice
for _, flag := range flags {
if flag.Short != "" && completionFlagEligible(flag, used) {
choices = append(choices, commandmeta.CompletionChoice{Value: current + flag.Short, Description: flag.Usage})
}
}
return completionTarget{kind: completionTargetFlags, choices: choices}
}
func completionFlagNames(flags []commandmeta.Flag, supplied map[string]bool, prefix string) []commandmeta.CompletionChoice {
var choices []commandmeta.CompletionChoice
for _, flag := range flags {
if !completionFlagEligible(flag, supplied) {
continue
}
for _, spelling := range []string{"--" + flag.Name, shortCompletionSpelling(flag.Short)} {
if spelling != "" && strings.HasPrefix(spelling, prefix) {
choices = append(choices, commandmeta.CompletionChoice{Value: spelling, Description: flag.Usage})
}
}
}
return choices
}
func completionFlagEligible(flag commandmeta.Flag, supplied map[string]bool) bool {
return !supplied[flag.Name] || flag.Repeated || commandmeta.IsReportingFlag(flag.Name)
}
func shortCompletionSpelling(short string) string {
if short == "" {
return ""
}
return "-" + short
}
func completionOperandHint(arguments []commandmeta.Argument, completed int) (commandmeta.CompletionHint, bool) {
total := completed + 1
counts, err := allocateOperandCounts(total, arguments, false)
if err != nil {
return commandmeta.CompletionHint{}, false
}
position := 0
for index, count := range counts {
position += count
if position >= total {
return arguments[index].Completion, true
}
}
return commandmeta.CompletionHint{}, false
}

146
completion_flags_test.go Normal file
View File

@@ -0,0 +1,146 @@
package commandkit
import (
"reflect"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestCompletionFlagAndValuePositions(t *testing.T) {
parse := func(value string) (string, error) { return value, nil }
choice := CompleteChoices(CompletionChoice{Value: "blue"}, CompletionChoice{Value: "green"})
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Flags: []Flag[struct{}]{
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: choice}, (*string)(nil), parse, func(*struct{}, Supplied[string]) {}),
RepeatFlag(FlagMeta{Name: "tag", Short: "t", Completion: choice}, nil, parse, func(*struct{}, Supplied[[]string]) {}),
BoolFlag(FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, Supplied[bool]) {}),
},
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "example"}, command,
FlagAliasFor("run", "--colour", "--color"), FlagAliasFor("run", "-C", "--color"), FlagAlias("-v", "--verbose"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
for _, tc := range []struct {
name, current, prefix, value string
completed []string
kind completionTargetKind
hint commandmeta.CompletionKind
}{
{"pending takes option", "--quiet", "", "--quiet", []string{"run", "--color"}, completionTargetValue, commandmeta.CompletionChoices},
{"terminator consumed as value", "-x", "", "-x", []string{"run", "--color", "--", "--tag"}, completionTargetValue, commandmeta.CompletionChoices},
{"repeated singleton value", "--color=gr", "--color=", "gr", []string{"run", "--color=blue"}, completionTargetValue, commandmeta.CompletionChoices},
{"long alias value", "--colour=bl", "--colour=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"long empty value", "--color=", "--color=", "", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short alias value", "-sCbl", "-sC", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short equals", "-sC=bl", "-sC=", "bl", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"reporting boolean", "--verbose=f", "--verbose=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"short boolean alias", "-v=f", "-v=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionChoices},
{"application boolean", "--switch=f", "--switch=", "f", []string{"run"}, completionTargetValue, commandmeta.CompletionNone},
{"scoped alias unavailable at root", "--colour=bl", "", "", nil, completionTargetNone, commandmeta.CompletionNone},
{"unknown completed", "--c", "", "", []string{"run", "--unknown"}, completionTargetNone, commandmeta.CompletionNone},
{"unknown attached", "--unknown=x", "", "", []string{"run"}, completionTargetNone, commandmeta.CompletionNone},
{"terminator", "--color", "", "", []string{"run", "--"}, completionTargetNone, commandmeta.CompletionNone},
} {
t.Run(tc.name, func(t *testing.T) {
target := completionTargetFor(snapshot, resolveCompletionContext(snapshot, tc.completed, tc.current))
if target.kind != tc.kind || target.hint.Kind != tc.hint || target.prefix != tc.prefix || target.valuePrefix != tc.value {
t.Fatalf("target=%#v", target)
}
})
}
context := resolveCompletionContext(snapshot, []string{"run", "--colour=blue", "--tag=one", "--verbose"}, "--")
target := completionTargetFor(snapshot, context)
names := completionChoiceNames(target.choices)
if containsCompletionName(names, "--color") || !containsCompletionName(names, "--tag") || !containsCompletionName(names, "--verbose") {
t.Fatalf("flag suggestions=%q", names)
}
cluster := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-sv"))
if cluster.kind != completionTargetFlags || !containsCompletionName(completionChoiceNames(cluster.choices), "-svc") || containsCompletionName(completionChoiceNames(cluster.choices), "-svs") {
t.Fatalf("short cluster=%#v", cluster)
}
dash := completionTargetFor(snapshot, resolveCompletionContext(snapshot, []string{"run"}, "-"))
if !containsCompletionName(completionChoiceNames(dash.choices), "--color") || !containsCompletionName(completionChoiceNames(dash.choices), "-c") {
t.Fatalf("dash suggestions=%#v", dash)
}
}
func TestCompletionOperandAllocation(t *testing.T) {
arguments := []commandmeta.Argument{
{Name: "optional", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}},
{Name: "repeated", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}},
{Name: "required", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()},
}
for _, tc := range []struct {
completed int
want commandmeta.CompletionKind
}{
{0, commandmeta.CompletionChoices},
{1, commandmeta.CompletionChoices},
{2, commandmeta.CompletionChoices},
} {
hint, ok := completionOperandHint(arguments, tc.completed)
if !ok || hint.Kind != tc.want {
t.Fatalf("%d operands: %#v, %v", tc.completed, hint, ok)
}
}
bounded := []commandmeta.Argument{{Name: "only", Max: 1, Completion: commandmeta.BooleanCompletion()}}
if _, ok := completionOperandHint(bounded, 1); ok {
t.Fatal("excess operand received a hint")
}
trailing := []commandmeta.Argument{{Name: "first", Min: 1, Max: 1, Completion: commandmeta.BooleanCompletion()}, {Name: "rest", Min: 0, Max: -1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionDirectories}}}
if hint, ok := completionOperandHint(trailing, 1); !ok || hint.Kind != commandmeta.CompletionDirectories {
t.Fatalf("repeated trailing operand: %#v, %v", hint, ok)
}
if got, want := completionChoiceNames(commandmeta.BooleanCompletion().Choices), []string{"true", "false"}; !reflect.DeepEqual(got, want) {
t.Fatalf("boolean choices=%q", got)
}
}
func TestCompletionOperandAndFlagContexts(t *testing.T) {
command := &commandmeta.Command{
Arguments: []commandmeta.Argument{{Name: "item", Min: 0, Max: 1, Completion: commandmeta.CompletionHint{Kind: commandmeta.CompletionFiles}}},
Flags: []commandmeta.Flag{{Name: "name", Completion: commandmeta.BooleanCompletion()}},
}
for _, tc := range []struct {
name, current string
tail []string
kind completionTargetKind
hint commandmeta.CompletionKind
}{
{"empty chooses operand", "", nil, completionTargetOperand, commandmeta.CompletionFiles},
{"dash chooses flag", "--n", nil, completionTargetFlags, commandmeta.CompletionNone},
{"after terminator", "--n", []string{"--"}, completionTargetOperand, commandmeta.CompletionFiles},
{"interspersed flag value", "value", []string{"--name", "--quiet"}, completionTargetOperand, commandmeta.CompletionFiles},
{"excess operand", "value", []string{"already"}, completionTargetNone, commandmeta.CompletionNone},
} {
t.Run(tc.name, func(t *testing.T) {
target := completionTargetFor(nil, completionContext{command: command, tail: tc.tail, current: tc.current})
if target.kind != tc.kind || target.hint.Kind != tc.hint {
t.Fatalf("target=%#v", target)
}
})
}
}
func completionChoiceNames(choices []commandmeta.CompletionChoice) []string {
var names []string
for _, choice := range choices {
names = append(names, choice.Value)
}
return names
}
func containsCompletionName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}

108
completion_install_test.go Normal file
View File

@@ -0,0 +1,108 @@
package commandkit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
)
func TestCompletionDeclarationGeneratesScripts(t *testing.T) {
for _, name := range []string{"app", "my-app", "tool.exe", "café"} {
if !validCompletionExecutable(name) {
t.Fatalf("supported executable rejected: %q", name)
}
}
app, err := New(Identity{Name: "Human Readable Name"}, Completion("my-app"))
if err != nil {
t.Fatal(err)
}
if !app.routeSnapshot().completion {
t.Fatal("completion not present in snapshot")
}
for _, shell := range []string{"bash", "zsh", "fish"} {
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"completion", shell}, Streams{Out: &output, Err: &diagnostics})
if outcome.Status != 0 || !strings.Contains(output.String(), "my-app") || strings.Contains(output.String(), "Human Readable Name") {
t.Fatalf("%s outcome=%#v output=%q diagnostics=%q", shell, outcome, output.String(), diagnostics.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"completion", shell, "--quiet"}, Streams{Out: &output, Err: &diagnostics})
if outcome.Status != 0 || output.Len() != 0 {
t.Fatalf("quiet %s outcome=%#v output=%q", shell, outcome, output.String())
}
}
var help bytes.Buffer
outcome := app.Execute(context.Background(), []string{"--help"}, Streams{Out: &help})
if outcome.Status != 0 || !strings.Contains(help.String(), "completion") || strings.Contains(help.String(), "__complete") {
t.Fatalf("help outcome=%#v output=%q", outcome, help.String())
}
writeErr := errors.New("destination failed")
outcome = app.Execute(context.Background(), []string{"completion", "bash"}, Streams{Out: completionErrorWriter{writeErr}, Err: io.Discard})
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("script delivery failure: %#v", outcome)
}
}
type completionErrorWriter struct{ err error }
func (w completionErrorWriter) Write([]byte) (int, error) { return 0, w.err }
func TestCompletionInstallationCollisionsAndRollback(t *testing.T) {
run := NewCommand(CommandSpec[struct{}]{Name: "run", Run: func(Invocation, struct{}) Outcome { return Success() }})
query := NewCommand(CommandSpec[struct{}]{Name: "__complete", Run: func(Invocation, struct{}) Outcome { return Success() }})
for _, declarations := range [][]Declaration{
{Completion("app"), query},
{query, Completion("app")},
{Completion("app"), run, Alias("__complete nested", "run")},
{run, Alias("__complete nested", "run"), Completion("app")},
{Completion("app"), Group("completion", "collision")},
{Group("outer", "", Completion("app"))},
{Completion("dir/app")},
{Completion("two words")},
} {
if _, err := New(Identity{Name: "example"}, declarations...); err == nil {
t.Fatalf("accepted conflicting declarations: %#v", declarations)
}
}
app, err := New(Identity{Name: "example"}, run)
if err != nil {
t.Fatal(err)
}
before := app.routeSnapshot()
if err := app.Register(Completion("app"), query); err == nil || app.routeSnapshot() != before {
t.Fatalf("failed batch changed snapshot: %v", err)
}
if err := app.Register(Completion("app")); err != nil {
t.Fatal(err)
}
if app.routeSnapshot() == before || !app.routeSnapshot().completion {
t.Fatal("late completion installation did not refresh snapshot")
}
if err := app.Register(query); err == nil {
t.Fatal("query command accepted after completion installation")
}
if err := app.Register(Alias("__complete nested", "run")); err == nil {
t.Fatal("query alias accepted after completion installation")
}
withoutCompletion, err := New(Identity{Name: "example"}, query)
if err != nil || withoutCompletion.routeSnapshot().completion {
t.Fatalf("ordinary query-named command: %v", err)
}
if outcome := withoutCompletion.Execute(context.Background(), []string{"__complete"}, Streams{}); outcome.Status != 0 {
t.Fatalf("ordinary command outcome: %#v", outcome)
}
if err := withoutCompletion.Register(Completion("app")); err == nil || withoutCompletion.routeSnapshot().completion {
t.Fatal("completion accepted an existing query command")
}
aliased, err := New(Identity{Name: "example"}, run, Alias("__complete nested", "run"))
if err != nil {
t.Fatal(err)
}
if err := aliased.Register(Completion("app")); err == nil || aliased.routeSnapshot().completion {
t.Fatal("completion accepted an existing query alias")
}
}

53
completion_query.go Normal file
View File

@@ -0,0 +1,53 @@
package commandkit
import (
"errors"
"io"
"strings"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func completeRequest(snapshot *routingSnapshot, words []string) (commandmeta.CompletionResult, error) {
if len(words) == 0 {
return commandmeta.CompletionResult{}, errors.New("completion request has no current word")
}
context := resolveCompletionContext(snapshot, words[:len(words)-1], words[len(words)-1])
target := completionTargetFor(snapshot, context)
result := commandmeta.CompletionResult{Prefix: target.prefix}
switch target.kind {
case completionTargetCommands, completionTargetFlags:
result.Kind = commandmeta.CompletionChoices
result.Choices = target.choices
case completionTargetValue, completionTargetOperand:
result.Kind = target.hint.Kind
if target.hint.Kind == commandmeta.CompletionChoices {
for _, choice := range target.hint.Choices {
if strings.HasPrefix(choice.Value, target.valuePrefix) {
result.Choices = append(result.Choices, choice)
}
}
}
}
return result, nil
}
func (current *execution) complete(words []string) Outcome {
result, err := completeRequest(current.snapshot, words)
var reply string
if err == nil {
reply, err = internalcobra.CompletionReply(result)
}
if err != nil {
reply = internalcobra.CompletionErrorReply()
}
_ = current.reporter.RenderOutput(func(writer io.Writer) error {
_, writeErr := io.WriteString(writer, reply)
return writeErr
})
if err != nil {
return current.reporter.finalize(Failure("Could not complete the shell request.").WithCause(err), nil)
}
return current.reporter.finalize(Success(), nil)
}

122
completion_query_test.go Normal file
View File

@@ -0,0 +1,122 @@
package commandkit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestCompletionQueryDispatch(t *testing.T) {
var factories, converters, handlers int
command := NewCommand(CommandSpec[struct{}]{
Name: "run", Summary: "Run a command.",
NewInput: func() struct{} { factories++; return struct{}{} },
Flags: []Flag[struct{}]{
ValueFlag(FlagMeta{Name: "color", Short: "c", Completion: CompleteChoices(CompletionChoice{Value: "blue", Description: "cool\ncolor"}, CompletionChoice{Value: "green"}, CompletionChoice{Value: "foo=bar"})}, (*string)(nil),
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
ValueFlag(FlagMeta{Name: "file", Short: "f", Completion: CompleteFiles()}, (*string)(nil),
func(value string) (string, error) { converters++; return value, nil }, func(*struct{}, Supplied[string]) {}),
},
Run: func(Invocation, struct{}) Outcome { handlers++; return Success() },
})
app, err := New(Identity{Name: "example"}, command, Completion("example"), Alias("shortcut", "run"), FlagAliasFor("run", "--colour", "--color"))
if err != nil {
t.Fatal(err)
}
emptyReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
args []string
want string
empty bool
}{
{"empty word", []string{"__complete", ""}, "run\tRun a command.", false},
{"alias value", []string{"__complete", "shortcut", "--colour=bl"}, "blue\tcool color\n", false},
{"quiet is data", []string{"__complete", "run", "--quiet", "--color=bl"}, "blue\tcool color\n", false},
{"debug is data", []string{"__complete", "run", "--debug", "--color=bl"}, "blue\tcool color\n", false},
{"format value", []string{"__complete", "run", "--format", "j"}, "json\n", false},
{"short attached choice", []string{"__complete", "run", "-cfoo=ba"}, "foo=bar\n", false},
{"unknown route", []string{"__complete", "missing", ""}, emptyReply, true},
} {
t.Run(tc.name, func(t *testing.T) {
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), tc.args, Streams{Out: &output, Err: &diagnostic})
if outcome.Status != 0 || diagnostic.Len() != 0 || (tc.empty && output.String() != tc.want) || (!tc.empty && !strings.Contains(output.String(), tc.want)) {
t.Fatalf("outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
}
})
}
fileReply, err := internalcobra.CompletionReply(commandmeta.CompletionResult{Kind: commandmeta.CompletionFiles, Prefix: "-f"})
if err != nil {
t.Fatal(err)
}
var fileOutput bytes.Buffer
fileOutcome := app.Execute(context.Background(), []string{"__complete", "run", "-f./part"}, Streams{Out: &fileOutput})
if fileOutcome.Status != 0 || fileOutput.String() != fileReply {
t.Fatalf("file query outcome=%#v reply=%q", fileOutcome, fileOutput.String())
}
if factories != 0 || converters != 0 || handlers != 0 {
t.Fatalf("query invoked application callbacks: %d %d %d", factories, converters, handlers)
}
if err := app.Register(simpleRunnable("later")); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if outcome := app.Execute(context.Background(), []string{"__complete", "lat"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "later") {
t.Fatalf("later command outcome=%#v reply=%q", outcome, output.String())
}
output.Reset()
if outcome := app.Execute(context.Background(), []string{"__complete", "later", "--qui"}, Streams{Out: &output}); outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") {
t.Fatalf("later command flag outcome=%#v reply=%q", outcome, output.String())
}
}
func TestCompletionQueryErrorsAndDelivery(t *testing.T) {
app, err := New(Identity{Name: "example"}, Completion("example"))
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"__complete"}, Streams{Out: &output, Err: &diagnostics})
if outcome.Status != 1 || outcome.Cause == nil || output.String() != internalcobra.CompletionErrorReply() || diagnostics.Len() == 0 {
t.Fatalf("malformed query outcome=%#v reply=%q diagnostics=%q", outcome, output.String(), diagnostics.String())
}
writeErr := errors.New("reply destination failed")
writer := &completionCountingWriter{err: writeErr}
outcome = app.Execute(context.Background(), []string{"__complete", ""}, Streams{Out: writer, Err: io.Discard})
if outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) || writer.calls != 1 {
t.Fatalf("reply write failure: %#v", outcome)
}
}
type completionCountingWriter struct {
err error
calls int
}
func (writer *completionCountingWriter) Write([]byte) (int, error) {
writer.calls++
return 0, writer.err
}
func TestCompletionQueryDiscovery(t *testing.T) {
app, err := New(Identity{Name: "example"}, Completion("example"),
Group("workspace", "", simpleRunnable("inspect")), Alias("legacy inspect", "workspace inspect"), FlagAlias("--silent", "--quiet"))
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"__complete", "legacy", "--"}, Streams{Out: &output})
if outcome.Status != 0 || !strings.Contains(output.String(), "--quiet") || strings.Contains(output.String(), "inspect") || strings.Contains(output.String(), "--silent") {
t.Fatalf("discovery outcome=%#v reply=%q", outcome, output.String())
}
}

93
completion_shell_test.go Normal file
View File

@@ -0,0 +1,93 @@
package commandkit
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestCompletionConsumerInShells(t *testing.T) {
if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" {
t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks")
}
dir := t.TempDir()
app := filepath.Join(dir, "probe")
build := exec.Command("go", "build", "-o", app, "./testdata/completion")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build completion fixture: %v\n%s", err, output)
}
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
t.Fatal(err)
}
tests := []struct {
name, line string
want []string
directory bool
}{
{"canonical command", "probe ru", []string{"run"}, false},
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
{"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false},
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
}
for _, shell := range []string{"bash", "zsh", "fish"} {
generator := exec.Command(app, "completion", shell)
script, err := generator.CombinedOutput()
if err != nil {
t.Fatalf("generate %s script: %v\n%s", shell, err, script)
}
scriptPath := filepath.Join(dir, shell+".completion")
if err := os.WriteFile(scriptPath, script, 0o600); err != nil {
t.Fatal(err)
}
for _, tc := range tests {
t.Run(shell+"/"+tc.name, func(t *testing.T) {
log := filepath.Join(dir, "log")
if err := os.WriteFile(log, nil, 0o600); err != nil {
t.Fatal(err)
}
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line)
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir)
if output, err := terminal.CombinedOutput(); err != nil {
t.Fatalf("shell completion: %v\n%s", err, output)
}
content, err := os.ReadFile(log)
if err != nil {
t.Fatal(err)
}
var got []string
if err := json.Unmarshal(content, &got); err != nil {
t.Fatalf("fixture output %q: %v", content, err)
}
if tc.directory && len(got) > 0 {
got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/")
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("edited arguments=%q, want %q", got, tc.want)
}
})
}
t.Run(shell+"/descriptions", func(t *testing.T) {
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
output, err := terminal.CombinedOutput()
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
t.Fatalf("completion descriptions: %v\n%s", err, output)
}
})
}
}

81
completion_test.go Normal file
View File

@@ -0,0 +1,81 @@
package commandkit
import (
"context"
"reflect"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
// Existing consumer code can compare these metadata values or use them as keys.
var (
_ map[FlagMeta]struct{}
_ map[ArgumentMeta]struct{}
_ = (FlagMeta{}) == (FlagMeta{})
_ = (ArgumentMeta{}) == (ArgumentMeta{})
)
func TestCompletionHintsFollowCapturedBindings(t *testing.T) {
type inner struct{ values Supplied[[]string] }
type outer struct{ flags FlagValues[inner] }
choices := []CompletionChoice{{Value: "alpha", Description: "first"}}
hint := CompleteChoices(choices...)
choices[0] = CompletionChoice{Value: "changed"}
parse := func(value string) (string, error) { return value, nil }
repeated := RepeatFlag(FlagMeta{Name: "tag", Completion: hint}, nil, parse,
func(input *inner, value Supplied[[]string]) { input.values = value })
captured := CaptureFlags([]Flag[inner]{repeated}, func(input *outer) *FlagValues[inner] { return &input.flags })
command := NewCommand(CommandSpec[outer]{
Name: "run",
Flags: append(captured,
ValueFlag(FlagMeta{Name: "file", Completion: CompleteFiles()}, (*string)(nil), parse, func(*outer, Supplied[string]) {}),
BoolFlag(FlagMeta{Name: "enabled"}, false, func(*outer, Supplied[bool]) {}),
ValueFlag(FlagMeta{Name: "empty", Completion: CompleteChoices()}, (*string)(nil), parse, func(*outer, Supplied[string]) {})),
Arguments: []Argument[outer]{
ValueArgument(ArgumentMeta{Name: "directory", Completion: CompleteDirectories()}, false, parse, func(*outer, Supplied[string]) {}),
RepeatArgument(ArgumentMeta{Name: "values", Completion: hint}, 0, -1, parse, func(*outer, Supplied[[]string]) {}),
},
})
flags := command.record.payload.bridgeFlags()
arguments := command.record.payload.bridgeArguments()
want := commandmeta.CompletionHint{Kind: commandmeta.CompletionChoices, Choices: []commandmeta.CompletionChoice{{Value: "alpha", Description: "first"}}}
if !flags[0].Repeated || !reflect.DeepEqual(flags[0].Completion, want) || !reflect.DeepEqual(arguments[1].Completion, want) {
t.Fatalf("captured hints: flags=%#v arguments=%#v", flags, arguments)
}
if flags[1].Completion.Kind != commandmeta.CompletionFiles || flags[2].Completion.Kind != commandmeta.CompletionNone || flags[3].Completion.Kind != commandmeta.CompletionChoices || len(flags[3].Completion.Choices) != 0 || arguments[0].Completion.Kind != commandmeta.CompletionDirectories {
t.Fatalf("hint strategies: flags=%#v arguments=%#v", flags, arguments)
}
flags[0].Completion.Choices[0].Value = "mutated"
if got := command.record.payload.bridgeFlags()[0].Completion; !reflect.DeepEqual(got, want) {
t.Fatalf("bridged choices changed captured hint: %#v", got)
}
if got := bridgeCompletion(NoCompletion()); got.Kind != commandmeta.CompletionNone || !reflect.DeepEqual(got, bridgeCompletion(CompletionHint{})) {
t.Fatalf("no completion: %#v", got)
}
for _, flag := range append(commandmeta.ReportingFlags(), commandmeta.HelpFlag()) {
if flag.Boolean && !reflect.DeepEqual(flag.Completion.Choices, []commandmeta.CompletionChoice{{Value: "true"}, {Value: "false"}}) {
t.Fatalf("reporting flag choices: %#v", flag)
}
}
}
func TestCompletionChoicesDoNotRestrictExecution(t *testing.T) {
type input struct{ mode Supplied[string] }
var received input
command := NewCommand(CommandSpec[input]{
Name: "run",
Flags: []Flag[input]{ValueFlag(FlagMeta{Name: "mode", Completion: CompleteChoices(CompletionChoice{Value: "suggested"})}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(i *input, value Supplied[string]) { i.mode = value })},
Run: func(_ Invocation, value input) Outcome { received = value; return Success() },
})
app, err := New(Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome := app.Execute(context.Background(), []string{"run", "--mode=other"}, Streams{})
if outcome.Status != 0 || !received.mode.Set || received.mode.Value != "other" {
t.Fatalf("outcome=%#v input=%#v", outcome, received)
}
}

461
config.go
View File

@@ -1,461 +0,0 @@
package commandkit
import (
"context"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
// Selection identifies the application configuration inputs supplied to a
// configuration scope.
type Selection struct {
Config Supplied[string]
Overlay Supplied[string]
}
// ConfigOperation identifies a supported configuration inspection operation.
type ConfigOperation string
const (
ConfigValidate ConfigOperation = "validate"
ConfigShow ConfigOperation = "show"
ConfigSources ConfigOperation = "sources"
ConfigExplain ConfigOperation = "explain"
)
// ConfigRequest describes one configuration operation and its selected inputs.
type ConfigRequest struct {
Operation ConfigOperation
Selection Selection
}
// EffectiveWriter writes an owning library's native effective configuration.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the sources used to construct a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader writes an owning library's path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// ConfigView contains optional views relevant to supported operations.
type ConfigView struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
}
// Acquired contains a configuration view and diagnostics from its acquisition.
type Acquired[T any] struct {
Value T
Diagnostics []Diagnostic
}
// ConfigScope declares one named configuration and its supported operations.
type ConfigScope[C any] struct {
Name string
Summary string
Operations []ConfigOperation
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
}
// Selector binds one typed command flag to selected configuration scopes.
// A required binding is required only when a selected scope uses it.
type Selector[C any] struct {
Binding Flag[C]
Scopes []string
}
// ConfigSuite declares selectors and configuration scopes for an App.
type ConfigSuite[C any] struct {
NewContext func() C
Selectors []Selector[C]
CheckInput func(Selection, C) error
Scopes []ConfigScope[C]
}
type configScopeInfo struct {
Name string `yaml:"name"`
Operations []ConfigOperation `yaml:"operations"`
}
type scopesInventory struct {
Scopes []configScopeInfo `yaml:"scopes"`
}
type preparedConfigSuite[C any] struct {
suite ConfigSuite[C]
ordered []ConfigScope[C]
byName map[string]ConfigScope[C]
installed []ConfigOperation
inventory scopesInventory
}
type configScopesInput struct {
Format string
}
type selectorState[C any] struct {
name string
required bool
scopes []string
supplied bool
apply func(*C)
clear func(*C)
}
type configInput[C any] struct {
Selection Selection
Scope string
Format string
Path Supplied[string]
SelectorContext C
selectors []selectorState[C]
}
// RegisterConfig installs the available config handlers and the scope discovery
// command. Its registration and discovery paths do not invoke scope loaders.
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
if app == nil {
return fmt.Errorf("cannot register configuration on a nil app")
}
if app.configRegistered {
return fmt.Errorf("configuration suite is already registered")
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
return err
}
if _, exists := app.commands["config"]; exists {
return fmt.Errorf("command path %q is already registered", "config")
}
prepared := prepareConfigSuite(normalized)
commands := make([]Command, 0, 5)
for _, operation := range prepared.installed {
var command Command
switch operation {
case ConfigValidate:
command, err = newConfigValidateCommand(prepared)
case ConfigShow:
command, err = newConfigShowCommand(prepared)
case ConfigSources:
command, err = newConfigSourcesCommand(prepared)
case ConfigExplain:
command, err = newConfigExplainCommand(prepared)
}
if err != nil {
return err
}
commands = append(commands, command)
}
command, err := newConfigScopesCommandWithPrepared(prepared)
if err != nil {
return err
}
commands = append(commands, command)
root, err := Group("config", "Inspect configuration.", commands...)
if err != nil {
return err
}
if err := app.Register(root); err != nil {
return err
}
app.configRegistered = true
return nil
}
// NewConfigKitScope adapts a ConfigKit loader into a scope with validation,
// effective display, source listing, and path explanation capabilities. The
// loader runs once per selected operation. Successful loads expose the result's
// public facets directly; ConfigKit LoadError records become CommandKit
// diagnostics while the original error is returned.
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
if load == nil {
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")
}
return ConfigScope[C]{
Name: name,
Summary: summary,
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) {
result, err := load(ctx, request.Selection, contextValue)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
}
return Acquired[ConfigView]{}, err
}
if result == nil {
return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
return Acquired[ConfigView]{
Value: ConfigView{Effective: result, Sources: result, Explanation: result},
Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()),
}, nil
},
}, nil
}
// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend.
func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic {
if len(records) == 0 {
return nil
}
severity := configkit.SeverityWarning
for _, record := range records {
if record.Severity == configkit.SeverityError {
severity = configkit.SeverityError
break
}
}
return []Diagnostic{{
Severity: severity,
Write: func(writer io.Writer) error {
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
},
}}
}
func writeDiagnosticText(writer io.Writer, value string) error {
written, err := io.WriteString(writer, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
normalized := suite
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
scopeNames := make(map[string]struct{}, len(suite.Scopes))
for _, scope := range suite.Scopes {
if scope.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
}
if scope.Name == "all" {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
}
if _, exists := scopeNames[scope.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
}
scopeNames[scope.Name] = struct{}{}
operations, err := normalizeConfigOperations(scope.Operations)
if err != nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
}
if len(operations) > 0 && scope.Acquire == nil {
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
}
scope.Operations = operations
normalized.Scopes = append(normalized.Scopes, scope)
}
seenNames := make(map[string]struct{}, len(normalized.Selectors))
seenShorts := make(map[string]struct{}, len(normalized.Selectors))
for index := range normalized.Selectors {
selector := &normalized.Selectors[index]
binding := selector.Binding.binding
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
}
if !validFlagName(binding.meta.Name) {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
}
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
}
if binding.meta.Short == "h" {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
}
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
}
seen := make(map[string]struct{}, len(selector.Scopes))
unique := make([]string, 0, len(selector.Scopes))
for _, scopeName := range selector.Scopes {
if _, exists := scopeNames[scopeName]; !exists {
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
}
if _, exists := seen[scopeName]; exists {
continue
}
seen[scopeName] = struct{}{}
unique = append(unique, scopeName)
}
selector.Scopes = append([]string(nil), unique...)
if _, exists := seenNames[binding.meta.Name]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
if binding.meta.Short != "" {
if _, exists := seenShorts[binding.meta.Short]; exists {
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
}
seenShorts[binding.meta.Short] = struct{}{}
}
seenNames[binding.meta.Name] = struct{}{}
}
return normalized, nil
}
func prepareConfigSuite[C any](suite ConfigSuite[C]) *preparedConfigSuite[C] {
prepared := &preparedConfigSuite[C]{
suite: suite,
ordered: orderedConfigScopes(suite.Scopes),
byName: make(map[string]ConfigScope[C], len(suite.Scopes)),
installed: make([]ConfigOperation, 0, 4),
inventory: scopesInventory{Scopes: make([]configScopeInfo, 0, len(suite.Scopes))},
}
for _, scope := range suite.Scopes {
prepared.byName[scope.Name] = scope
}
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if configOperationDeclared(suite.Scopes, operation) {
prepared.installed = append(prepared.installed, operation)
}
}
for _, scope := range prepared.ordered {
operations := make([]ConfigOperation, 0, len(scope.Operations))
for _, operation := range scope.Operations {
if containsOperation(prepared.installed, operation) {
operations = append(operations, operation)
}
}
prepared.inventory.Scopes = append(prepared.inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
}
return prepared
}
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
seen := make(map[ConfigOperation]struct{}, len(operations))
for _, operation := range operations {
switch operation {
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
default:
return nil, fmt.Errorf("unknown operation %q", operation)
}
seen[operation] = struct{}{}
}
ordered := make([]ConfigOperation, 0, len(seen))
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
if _, exists := seen[operation]; exists {
ordered = append(ordered, operation)
}
}
return ordered, nil
}
func newConfigScopesCommandWithPrepared[C any](prepared *preparedConfigSuite[C]) (Command, error) {
formatDefault := "text"
spec := CommandSpec[configScopesInput]{
Name: "scopes",
Summary: "List configuration scopes and supported operations.",
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
Name: "format", Usage: "Output format: text or yaml.",
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
input.Format = value.Value
})},
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
Run: func(invocation Invocation, input configScopesInput) Outcome {
var output []byte
var err error
if input.Format == "yaml" {
output, err = yaml.Marshal(prepared.inventory)
} else {
output = []byte(formatScopeInventory(prepared.inventory))
}
if err != nil {
return Failure(err)
}
if err := writeReport(invocation.Streams.Out, output); err != nil {
return incompleteReport(err)
}
return Success()
},
}
return NewCommand(spec)
}
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
ordered := append([]ConfigScope[C](nil), scopes...)
sort.SliceStable(ordered, func(i, j int) bool {
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
})
return ordered
}
func configScopeOrder(name string) string {
switch name {
case "app":
return "\x00"
case "promptkit":
return "\x01"
default:
return "\x02" + name
}
}
func formatScopeInventory(inventory scopesInventory) string {
var output strings.Builder
for _, scope := range inventory.Scopes {
output.WriteString(scope.Name)
output.WriteString(":")
if len(scope.Operations) > 0 {
output.WriteString(" ")
for index, operation := range scope.Operations {
if index > 0 {
output.WriteString(", ")
}
output.WriteString(string(operation))
}
}
output.WriteByte('\n')
}
return output.String()
}
func parseConfigFormat(value string) (string, error) {
if err := checkConfigFormat(value); err != nil {
return "", err
}
return value, nil
}
func checkConfigFormat(value string) error {
if value != "text" && value != "yaml" {
return fmt.Errorf("format must be text or yaml")
}
return nil
}

View File

@@ -1,359 +0,0 @@
package commandkit
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
)
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
newContextCalls := 0
acquireCalls := 0
suite := ConfigSuite[struct{}]{
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
Scopes: []ConfigScope[struct{}]{
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
acquireCalls++
return Acquired[ConfigView]{}, nil
}},
{Name: "metadata", Summary: "No provider is required."},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("text inventory outcome = %#v", outcome)
}
wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show, explain\n"
if textOutput.String() != wantText {
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
}
var yamlOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("YAML inventory outcome = %#v", outcome)
}
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
t.Fatalf("YAML inventory = %q", got)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
if outcome.Kind != OutcomeSuccess {
t.Fatalf("scope help outcome = %#v", outcome)
}
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
if outcome.Kind != OutcomeUsage {
t.Fatalf("invalid format outcome = %#v", outcome)
}
if newContextCalls != 0 || acquireCalls != 0 {
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
}
}
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
t.Fatalf("configuration defaults = %#v", input)
}
}
func TestRegisterConfigOwnsPreparedScopeAndSelectorMetadata(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
var acquired selectorInput
suite := ConfigSuite[selectorInput]{
Selectors: []Selector[selectorInput]{{
Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }),
Scopes: []string{"app"},
}},
Scopes: []ConfigScope[selectorInput]{{
Name: "app",
Operations: []ConfigOperation{ConfigValidate},
Acquire: func(_ context.Context, _ ConfigRequest, input selectorInput) (Acquired[ConfigView], error) {
acquired = input
return Acquired[ConfigView]{}, nil
},
}},
}
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
suite.Scopes[0].Name = "renamed"
suite.Scopes[0].Operations[0] = ConfigShow
suite.Selectors[0].Scopes[0] = "renamed"
var inventory bytes.Buffer
if result := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &inventory}); result.Kind != OutcomeSuccess {
t.Fatalf("scope inventory outcome = %#v", result)
}
if got := inventory.String(); got != "app: validate\n" {
t.Fatalf("scope inventory after caller mutation = %q", got)
}
if result := app.Execute(context.Background(), []string{"config", "validate", "--scope=app", "--region=west"}, Streams{}); result.Kind != OutcomeSuccess {
t.Fatalf("registered scope execution after caller mutation = %#v", result)
}
if acquired.Region != (Supplied[string]{Value: "west", Set: true}) {
t.Fatalf("acquired selector after caller mutation = %#v", acquired)
}
}
func TestRegisterConfigRejectsDuplicateSelectorNamesAndShorts(t *testing.T) {
type selectorInput struct{ First, Second string }
tests := []struct {
name string
firstMeta FlagMeta
secondMeta FlagMeta
}{
{name: "name", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "region", Short: "g"}},
{name: "short", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "group", Short: "r"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, err := New(Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
suite := ConfigSuite[selectorInput]{Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](test.firstMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.First = value.Value })},
{Binding: ValueFlag[selectorInput](test.secondMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Second = value.Value })},
}}
if err := RegisterConfig(app, suite); err == nil || !strings.Contains(err.Error(), "duplicate configuration selector flag") {
t.Fatalf("RegisterConfig error = %v", err)
}
})
}
}
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
tests := []struct {
name string
scope ConfigScope[struct{}]
want string
}{
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, _ := New(Identity{Name: "example"})
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
}
})
}
app, _ := New(Identity{Name: "example"})
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
if err != nil {
t.Fatal(err)
}
if err := app.Register(custom); err != nil {
t.Fatal(err)
}
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
t.Fatal("replaced a pre-existing config command")
}
}
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
type selectorInput struct {
Count Supplied[int]
Enabled Supplied[bool]
Other Supplied[int]
Runtime string
}
baseContextCalls := 0
checkCalls := 0
var checked selectorInput
countDefault := 9
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
Selectors: []Selector[selectorInput]{
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
},
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
preparedSuite := prepareConfigSuite(normalized)
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
input := prepared.build()
scopeNames, err := preflightConfig(preparedSuite, input, ConfigValidate)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
}
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
t.Fatalf("configuration selection presence = %#v", input.Selection)
}
if baseContextCalls != 0 {
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
}
contexts := contextsForScopes(preparedSuite, input, scopeNames)
if baseContextCalls != 1 {
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
}
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
t.Fatalf("app selectors = %#v", contexts["app"])
}
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
}
}
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
type selectorInput struct{ Region Supplied[string] }
newContextCalls := 0
suite := ConfigSuite[selectorInput]{
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
Scopes: []ConfigScope[selectorInput]{
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
},
}
normalized, err := normalizeConfigSuite(suite)
if err != nil {
t.Fatal(err)
}
preparedSuite := prepareConfigSuite(normalized)
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
t.Fatalf("unsupported aggregate error = %v", err)
}
if newContextCalls != 0 {
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
}
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
if err != nil {
t.Fatal(err)
}
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
t.Fatalf("inapplicable selector error = %v", err)
}
}
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
return Acquired[ConfigView]{}, nil
}
func TestRequiredConfigSelectorOnlyAppliesToSelectedScopes(t *testing.T) {
app, _ := New(Identity{Name: "example"})
calls := 0
acquire := func(context.Context, ConfigRequest, string) (Acquired[ConfigView], error) {
calls++
return Acquired[ConfigView]{}, nil
}
err := RegisterConfig(app, ConfigSuite[string]{
Selectors: []Selector[string]{{Scopes: []string{"app"}, Binding: ValueFlag[string](FlagMeta{Name: "campaign", Required: true}, nil, parseString, func(c *string, v Supplied[string]) { *c = v.Value })}},
Scopes: []ConfigScope[string]{{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}, {Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}},
})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
kind OutcomeKind
calls int
}{
{[]string{"--scope", "promptkit"}, OutcomeSuccess, 1},
{nil, OutcomeUsage, 0},
{[]string{"--scope", "all"}, OutcomeUsage, 0},
{[]string{"--scope", "all", "--campaign", "spring"}, OutcomeSuccess, 2},
{[]string{"--scope", "promptkit", "--campaign", "spring"}, OutcomeUsage, 0},
{[]string{"--help"}, OutcomeSuccess, 0},
} {
calls = 0
var out bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.args...), Streams{Out: &out})
if result.Kind != tc.kind || calls != tc.calls {
t.Fatalf("%v: result=%#v calls=%d", tc.args, result, calls)
}
if len(tc.args) > 0 && tc.args[0] == "--help" && !strings.Contains(out.String(), "required where applicable") {
t.Fatalf("help=%q", out.String())
}
}
}
func TestUnknownConfigScopeReportsChoicesWithoutRuntimeWork(t *testing.T) {
for _, tc := range []struct {
names []string
scope []string
choices string
}{
{[]string{"promptkit"}, nil, "available: promptkit"},
{[]string{"zeta", "alpha", "promptkit", "app"}, []string{"--scope", "unknown"}, "available: app, promptkit, alpha, zeta"},
} {
runtimeCalls := 0
suite := ConfigSuite[struct{}]{NewContext: func() struct{} { runtimeCalls++; return struct{}{} }}
for _, name := range tc.names {
suite.Scopes = append(suite.Scopes, ConfigScope[struct{}]{Name: name, Operations: []ConfigOperation{ConfigValidate}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
runtimeCalls++
return Acquired[ConfigView]{}, nil
}})
}
app, _ := New(Identity{Name: "example"})
if err := RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var out, diagnostics bytes.Buffer
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.scope...), Streams{Out: &out, Err: &diagnostics})
if result.Kind != OutcomeUsage || runtimeCalls != 0 || out.Len() != 0 || !strings.Contains(diagnostics.String(), tc.choices) {
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, runtimeCalls, out.String(), diagnostics.String())
}
}
}

View File

@@ -1,720 +0,0 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"testing/fstest"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"go.yaml.in/yaml/v4"
)
type effectiveYAML string
func (document effectiveYAML) WriteEffective(writer io.Writer) error {
_, err := io.WriteString(writer, string(document))
return err
}
type capturedSourceReader struct {
entries []configkit.SourceInfo
calls int
}
func TestConfigExplainPreservesConfigKitProvenanceAndRedaction(t *testing.T) {
type entry struct {
Enabled bool `yaml:"enabled"`
Credential configkit.Secret `yaml:"credential"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
Odd map[string]string `yaml:"odd"`
Value string `yaml:"value"`
Dependency string `yaml:"dependency"`
Secret configkit.Secret `yaml:"secret"`
}
const secretValue = "EXPLAIN_TEST_SECRET_VALUE"
t.Setenv("EXPLAIN_TEST_SECRET", secretValue)
input := []byte("entries:\n parent:\n enabled: false\n credential: EXPLAIN_TEST_SECRET\n child:\n extends: parent\nodd:\n \"key/name~\": escaped\nvalue: original\ndependency: source\nsecret: EXPLAIN_TEST_SECRET\n")
result, err := configkit.Load[configuration](configkit.Bytes("explanation.yml", input), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("value"), configkit.ChangeNormalization, "upper case", configkit.Keys("dependency"))
value.Value = strings.ToUpper(value.Value)
return nil
},
}})
if err != nil {
t.Fatal(err)
}
loads, contexts := 0, 0
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
loads++
return result, nil
})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} }, Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
runYAML := func(pointer string) (map[string]any, commandkit.Outcome, string) {
var output bytes.Buffer
argv := []string{"config", "explain", "--path", pointer, "--format", "yaml"}
if pointer == "" {
argv = []string{"config", "explain", "--path=", "--format", "yaml"}
}
outcome := app.Execute(context.Background(), argv, commandkit.Streams{Out: &output, Err: io.Discard})
var report map[string]any
if outcome.Kind == commandkit.OutcomeSuccess {
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatalf("decode explanation YAML: %v; output=%q", err, output.String())
}
}
return report, outcome, output.String()
}
path := "/entries/child/enabled"
var native bytes.Buffer
if err := result.WriteExplanation(&native, configkit.Keys("entries", "child", "enabled")); err != nil {
t.Fatal(err)
}
var textOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || textOutput.String() != native.String() {
t.Fatalf("native text outcome=%#v output=%q want=%q", outcome, textOutput.String(), native.String())
}
var inherited map[string]any
inherited, outcome, _ = runYAML(path)
if outcome.Kind != commandkit.OutcomeSuccess || inherited["path"] != path {
t.Fatalf("inherited explanation outcome=%#v report=%#v", outcome, inherited)
}
effective := inherited["effective"].(map[string]any)
if effective["present"] != true || effective["yaml"] != "false\n" {
t.Fatalf("inherited effective snapshot = %#v", effective)
}
origin := inherited["origin"].(map[string]any)
steps := origin["inheritance"].([]any)
step := steps[0].(map[string]any)
if step["from"] != "/entries/parent/enabled" || step["to"] != path || step["reference"] != "/entries/child/extends" {
t.Fatalf("inheritance paths = %#v", step)
}
escaped, outcome, _ := runYAML("/odd/key~1name~0")
if outcome.Kind != commandkit.OutcomeSuccess || escaped["path"] != "/odd/key~1name~0" || escaped["effective"].(map[string]any)["yaml"] != "escaped\n" {
t.Fatalf("escaped path outcome=%#v report=%#v", outcome, escaped)
}
historical, outcome, _ := runYAML("/entries/child/extends")
if outcome.Kind != commandkit.OutcomeSuccess || historical["effective"].(map[string]any)["present"] != false || len(historical["history"].([]any)) == 0 {
t.Fatalf("historical path outcome=%#v report=%#v", outcome, historical)
}
resolved, outcome, _ := runYAML("/value")
if outcome.Kind != commandkit.OutcomeSuccess || resolved["effective"].(map[string]any)["yaml"] != "ORIGINAL\n" {
t.Fatalf("resolved snapshot outcome=%#v report=%#v", outcome, resolved)
}
changes := resolved["history"].([]any)
last := changes[len(changes)-1].(map[string]any)
if last["reasons"].([]any)[0] != "upper case" || last["dependencies"].([]any)[0] != "/dependency" {
t.Fatalf("resolution change = %#v", last)
}
secretReport, outcome, secretText := runYAML("/entries/child/credential")
secretEffective := secretReport["effective"].(map[string]any)
if outcome.Kind != commandkit.OutcomeSuccess || secretEffective["redacted"] != true || strings.Contains(secretText, secretValue) {
t.Fatalf("secret report outcome=%#v report=%#v", outcome, secretReport)
}
for _, change := range secretReport["history"].([]any) {
entry := change.(map[string]any)
for _, key := range []string{"before", "after"} {
snapshot := entry[key].(map[string]any)
if snapshot["present"] == true && snapshot["redacted"] != true {
t.Fatalf("secret history was not redacted: %#v", entry)
}
}
}
directSecret, outcome, directSecretText := runYAML("/secret")
secretRef, refPresent := directSecret["secret_reference"].(map[string]any)
if !refPresent {
t.Fatalf("secret reference was absent: %#v", directSecret)
}
if outcome.Kind != commandkit.OutcomeSuccess || secretRef["name"] != "EXPLAIN_TEST_SECRET" || strings.Contains(directSecretText, secretValue) {
t.Fatalf("direct secret reference outcome=%#v report=%#v", outcome, directSecret)
}
root, outcome, _ := runYAML("")
if outcome.Kind != commandkit.OutcomeSuccess || root["path"] != "" {
t.Fatalf("root path outcome=%#v report=%#v", outcome, root)
}
loadsBeforeInvalid, contextsBeforeInvalid := loads, contexts
outcome = app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("missing path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2path"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
t.Fatalf("malformed path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/does-not-exist"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 {
t.Fatalf("unknown path outcome = %#v", outcome)
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
t.Fatalf("explanation writer failure = %#v", outcome)
}
}
func (reader *capturedSourceReader) Sources() []configkit.SourceInfo {
reader.calls++
return reader.entries
}
func TestConfigSourcesReportsCapturedConfigKitSourceInventory(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
Shared string `yaml:"shared"`
Synthetic string `yaml:"synthetic"`
}
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nvalue: base\n")},
"root/shared.yml": &fstest.MapFile{Data: []byte("shared: imported\n")},
"root/overlays/dev.yml": &fstest.MapFile{Data: []byte("value: overlay\n")},
})
filesystemContext := &configkit.FilesystemContext{Root: "root", FS: &filesystem}
overlayFilesystemContext := &configkit.FilesystemContext{Root: "root", BaseDir: "root/config", FS: &filesystem}
loaderCalls := 0
reader := &capturedSourceReader{}
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
loaderCalls++
result, err := configkit.LoadLayered(configkit.Input{
Source: filesystem.File("root/config/main.yml"),
Imports: true,
Filesystem: filesystemContext,
}, configkit.LayeredOptions[configuration]{
Definition: configkit.Definition[configuration]{},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("synthetic source", map[string]any{"synthetic": "underlay"})}}}},
Overlay: "dev",
OverlayFilesystem: overlayFilesystemContext,
})
if err != nil {
return nil, err
}
reader.entries = result.Sources()
return result, nil
})
if err != nil {
t.Fatal(err)
}
// Replace the result's reader after the owning loader has captured its source inventory.
originalAcquire := scope.Acquire
scope.Acquire = func(ctx context.Context, request commandkit.ConfigRequest, input struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
acquired, err := originalAcquire(ctx, request, input)
if err == nil {
acquired.Value.Sources = reader
}
return acquired, err
}
// Register the wrapped scope so the command reads the captured public facet once.
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || loaderCalls != 1 || reader.calls != 1 {
var details bytes.Buffer
for _, diagnostic := range outcome.Diagnostics {
_ = diagnostic.Write(&details)
}
t.Fatalf("sources outcome=%#v cause=%v details=%q loader calls=%d reader calls=%d", outcome, outcome.Cause, details.String(), loaderCalls, reader.calls)
}
var report struct {
Scope string `yaml:"scope"`
Sources []struct {
ID string `yaml:"id"`
Label string `yaml:"label"`
Path string `yaml:"path"`
Layer string `yaml:"layer"`
Kind string `yaml:"kind"`
UnderlayIndex int `yaml:"underlay_index"`
ImportedFrom *struct {
SourceID string `yaml:"source_id"`
Line int `yaml:"line"`
Column int `yaml:"column"`
} `yaml:"imported_from"`
} `yaml:"sources"`
}
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Scope != "app" || len(report.Sources) != 4 {
t.Fatalf("source report = %#v", report)
}
if report.Sources[0].Path != "root/config/main.yml" || report.Sources[0].ID != "source-1" {
t.Fatalf("main source = %#v", report.Sources[0])
}
if report.Sources[0].ImportedFrom != nil {
t.Fatalf("root source imported_from = %#v, want null", report.Sources[0].ImportedFrom)
}
imported := report.Sources[1]
if imported.Path != "root/shared.yml" || imported.ImportedFrom == nil || imported.ImportedFrom.SourceID != report.Sources[0].ID || imported.ImportedFrom.Line == 0 {
t.Fatalf("import source correlation = %#v", imported)
}
synthetic := report.Sources[2]
if synthetic.Kind != string(configkit.SourceKindMapping) || synthetic.Path != "" || synthetic.Layer != string(configkit.LayerUnderlay) || synthetic.UnderlayIndex != 1 {
t.Fatalf("synthetic underlay source = %#v", synthetic)
}
if report.Sources[3].Layer != string(configkit.LayerOverlay) || report.Sources[3].Path != "root/overlays/dev.yml" {
t.Fatalf("overlay source = %#v", report.Sources[3])
}
}
func TestConfigSourcesHandlesEmptyInventoryAllPreflightAndWriterFailure(t *testing.T) {
reader := &capturedSourceReader{}
newContextCalls, acquireCalls := 0, 0
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
suite := commandkit.ConfigSuite[string]{
NewContext: func() string { newContextCalls++; return "context" },
Scopes: []commandkit.ConfigScope[string]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigSources}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
acquireCalls++
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Sources: reader}}, nil
}}},
}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "sources", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || newContextCalls != 0 || acquireCalls != 0 || output.Len() != 0 {
t.Fatalf("all preflight outcome=%#v contexts=%d acquires=%d output=%q", outcome, newContextCalls, acquireCalls, output.String())
}
outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "scope: app\nsources: []\n" || reader.calls != 1 {
t.Fatalf("empty source report outcome=%#v output=%q reader calls=%d", outcome, output.String(), reader.calls)
}
reader.entries = []configkit.SourceInfo{{ID: "source-1", Label: "label with \"quotes\"", Path: "path with spaces.yml", Layer: configkit.LayerBase, Kind: configkit.SourceKindFile}}
var textOutput bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(textOutput.String(), `label: "label with \"quotes\""`) || !strings.Contains(textOutput.String(), `path: "path with spaces.yml"`) {
t.Fatalf("quoted text source output outcome=%#v output=%q", outcome, textOutput.String())
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
t.Fatalf("source writer failure = %#v", outcome)
}
}
func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) {
const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n"
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var calls []string
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "zeta")
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("%YAML 1.1\n# zeta comment\nvalue: zeta\n")}}, nil
}},
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "app")
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML(native)}}, nil
}},
}}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var single bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &single, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || single.String() != native {
t.Fatalf("single show outcome=%#v output=%q", outcome, single.String())
}
calls = nil
outcome = app.Execute(context.Background(), []string{"config", "show", "--format", "text"}, commandkit.Streams{Out: &single, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 {
t.Fatalf("text format outcome=%#v acquisitions=%v", outcome, calls)
}
calls = nil
var aggregate bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &aggregate, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(calls, []string{"app", "zeta"}) {
t.Fatalf("aggregate outcome=%#v acquisitions=%v", outcome, calls)
}
if !strings.Contains(aggregate.String(), "%YAML 1.1\n# authored comment\n---\n# scope: app\n") || !strings.Contains(aggregate.String(), "...\n%YAML 1.1\n# zeta comment\n---\n# scope: zeta\nvalue: zeta\n") {
t.Fatalf("aggregate framing = %q", aggregate.String())
}
decoder := yaml.NewDecoder(bytes.NewReader(aggregate.Bytes()))
var first, second map[string]any
if err := decoder.Decode(&first); err != nil {
t.Fatal(err)
}
if err := decoder.Decode(&second); err != nil {
t.Fatal(err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
t.Fatalf("third YAML document decode error = %v, value=%#v", err, extra)
}
if first["value"] != nil || first["items"] == nil || first["missing"] == nil || second["value"] != "zeta" {
t.Fatalf("decoded documents = %#v and %#v", first, second)
}
if _, exists := first["absent"]; exists {
t.Fatalf("absent field was added to native document: %#v", first)
}
}
func TestConfigShowAcquiresAllBeforeOutputAndTracksWriterFailures(t *testing.T) {
failure := errors.New("second scope failed")
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
calls := 0
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
}},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
calls++
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "provider warning")}}, failure
}},
}}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !errors.Is(outcome.Cause, failure) || outcome.ReportIncomplete || output.Len() != 0 || calls != 2 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != configkit.SeverityWarning {
t.Fatalf("acquisition failure outcome=%#v output=%q calls=%d", outcome, output.String(), calls)
}
app, _ = commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
}}}}); err != nil {
t.Fatal(err)
}
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: partial, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrClosedPipe) || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) || len(partial.written) == 0 {
t.Fatalf("writer failure outcome = %#v", outcome)
}
}
type configKitShowPartialWriter struct {
written []byte
failure error
}
func (writer *configKitShowPartialWriter) Write(data []byte) (int, error) {
n := len(data)
if n > 4 {
n = 4
}
writer.written = append(writer.written, data[:n]...)
return n, writer.failure
}
func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) {
type configuration struct {
Mode string `yaml:"mode"`
Defaulted string `yaml:"defaulted"`
}
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")},
"config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")},
})
definition := configkit.Definition[configuration]{
Defaults: func() configuration { return configuration{Defaulted: "from default"} },
Resolve: func(input *configuration, resolution *configkit.Resolution) error {
resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use")
input.Mode = strings.ToUpper(input.Mode)
return nil
},
}
var received commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
received = selection
return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{
Definition: definition,
Overlay: selection.Overlay.Value,
OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
})
})
if err != nil {
t.Fatal(err)
}
selection := commandkit.Selection{
Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "dev", Set: true},
}
acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(received, selection) {
t.Fatalf("loader selection = %#v, want %#v", received, selection)
}
if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil {
t.Fatalf("ConfigKit result facets = %#v", acquired.Value)
}
if got := acquired.Value.Sources.Sources(); len(got) < 2 {
t.Fatalf("sources = %#v, want base and overlay", got)
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") {
t.Fatalf("effective configuration = %q", effective.String())
}
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 {
t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok)
}
}
func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
}
var seen []commandkit.Selection
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
seen = append(seen, selection)
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}})
})
if err != nil {
t.Fatal(err)
}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
if err != nil {
t.Fatal(err)
}
empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}}
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{})
if err != nil {
t.Fatal(err)
}
want := []commandkit.Selection{{}, empty}
if !reflect.DeepEqual(seen, want) {
t.Fatalf("loader selections = %#v, want %#v", seen, want)
}
}
func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) {
if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil {
t.Fatal("accepted a nil ConfigKit loader")
}
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil result error = %v", err)
}
}
func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
var loadError error
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
_, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}})
return nil, loadError
})
if err != nil {
t.Fatal(err)
}
acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
var publicLoadError *configkit.LoadError
if acquireError != loadError || !errors.As(acquireError, &publicLoadError) {
t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError {
t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics)
}
var rendered bytes.Buffer
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
t.Fatalf("grouped diagnostics = %q", rendered.String())
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 {
t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
}
}
func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) {
app, err := commandkit.New(commandkit.Identity{Name: "example"})
if err != nil {
t.Fatal(err)
}
var calls []string
newContextCalls := 0
failure := errors.New("promptkit could not load")
suite := commandkit.ConfigSuite[string]{
NewContext: func() string { newContextCalls++; return "runtime" },
Scopes: []commandkit.ConfigScope[string]{
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "zeta")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "promptkit")
return commandkit.Acquired[commandkit.ConfigView]{}, failure
}},
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
calls = append(calls, "app")
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}},
},
}
if err := commandkit.RegisterConfig(app, suite); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete {
t.Fatalf("validation outcome = %#v", outcome)
}
if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 {
t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls)
}
if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
}
func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) {
type configuration struct {
Name string `yaml:"name"`
}
warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Validate: func(_ *configuration, validation *configkit.Validation) {
validation.Warning(configkit.Keys("name"), "uses a demonstration name")
},
}})
})
if err != nil {
t.Fatal(err)
}
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil {
t.Fatal(err)
}
var output, diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("warning validation outcome = %#v", outcome)
}
if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") {
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
}
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
t.Fatalf("report short write outcome = %#v", outcome)
}
var delivered bytes.Buffer
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}})
if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
t.Fatalf("diagnostic short write outcome = %#v", outcome)
}
}
type configKitPartialWriter struct{}
func (configKitPartialWriter) Write(data []byte) (int, error) {
if len(data) == 0 {
return 0, nil
}
return len(data) - 1, nil
}
func TestValidationReportDeliveryErrorsAtEachWriteBoundary(t *testing.T) {
reportErr := errors.New("report failed")
diagnosticErr := errors.New("diagnostic failed")
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
}}); err != nil {
t.Fatal(err)
}
for _, format := range []string{"text", "yaml"} {
writes := 2
if format == "yaml" {
writes = 3
}
for at := 1; at <= writes; at++ {
for _, brokenErr := range []bool{false, true} {
out := &validationBoundaryWriter{failAt: at, err: reportErr}
errStream := io.Writer(io.Discard)
if brokenErr {
errStream = failingWriter{diagnosticErr}
}
result := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--format", format}, commandkit.Streams{Out: out, Err: errStream})
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || (brokenErr && !errors.Is(result.DeliveryError, diagnosticErr)) {
t.Fatalf("format=%s at=%d brokenErr=%t result=%#v", format, at, brokenErr, result)
}
if at > 1 && out.written.Len() == 0 {
t.Fatal("lost completed prefix")
}
}
}
}
failedApp, _ := commandkit.New(commandkit.Identity{Name: "example"})
if err := commandkit.RegisterConfig(failedApp, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
return commandkit.Acquired[commandkit.ConfigView]{}, reportErr
}}}}); err != nil {
t.Fatal(err)
}
result := failedApp.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{})
if !errors.Is(result.Cause, reportErr) || result.DeliveryError != nil {
t.Fatalf("acquisition failure=%#v", result)
}
}
type validationBoundaryWriter struct {
failAt, calls int
err error
written bytes.Buffer
}
func (w *validationBoundaryWriter) Write(p []byte) (int, error) {
w.calls++
if w.calls == w.failAt {
return 0, w.err
}
return w.written.Write(p)
}

118
debug_events_test.go Normal file
View File

@@ -0,0 +1,118 @@
package commandkit
import (
"bytes"
"context"
"errors"
"strings"
"testing"
)
func TestDebugEventsFollowModeAndFinalizationOrder(t *testing.T) {
cause := errors.New("private operation cause")
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
if err := inv.Reporter().AtEnd().Info("deferred summary"); err != nil {
t.Errorf("queue summary: %v", err)
}
return Failure("operation failed").WithCause(cause)
},
})
app, err := New(Identity{Name: "Example"}, command, Alias("shortcut", "run"), FlagAlias("--trace", "--debug"))
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"shortcut", "--verbose", "--trace"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, cause) {
t.Fatalf("outcome = %#v", outcome)
}
got := diagnostics.String()
for _, fragment := range []string{
"resolved command alias \"shortcut\" to \"run\"",
"selected debug reporting mode using --trace (alias for --debug)",
"deferred summary",
"handler completed for \"run\" with operational outcome and status 1",
"ERROR: operation failed",
} {
if !strings.Contains(got, fragment) {
t.Errorf("diagnostics %q omit %q", got, fragment)
}
}
if strings.Contains(got, cause.Error()) {
t.Fatalf("diagnostics exposed the operation cause: %q", got)
}
ordered := []string{
"resolved command alias",
"selected debug reporting mode",
"deferred summary",
"handler completed",
"ERROR: operation failed",
}
previous := -1
for _, fragment := range ordered {
index := strings.Index(got, fragment)
if index <= previous {
t.Fatalf("diagnostic event %q out of order in %q", fragment, got)
}
previous = index
}
}
func TestDebugEventsRespectFinalQuietSelection(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run", "--debug", "--quiet"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeSuccess || diagnostics.Len() != 0 {
t.Fatalf("outcome = %#v, diagnostics = %q", outcome, diagnostics.String())
}
}
func TestDebugCompletionRequiresAHandler(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
app.Execute(context.Background(), []string{"unknown", "--debug"}, Streams{Err: &diagnostics})
if strings.Contains(diagnostics.String(), "handler completed") {
t.Fatalf("no-handler diagnostics include completion: %q", diagnostics.String())
}
}
func TestDebugDeliveryFailureAffectsInvocationOutcome(t *testing.T) {
ran := false
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome {
ran = true
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("debug destination failed")
outcome := app.Execute(context.Background(), []string{"run", "--debug"}, Streams{
Err: executeReportingFailureWriter{err: writeErr},
})
if !ran {
t.Fatal("debug delivery failure prevented handler execution")
}
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) {
t.Fatalf("outcome = %#v", outcome)
}
}

View File

@@ -0,0 +1,241 @@
package commandkit_test
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
)
func TestPublicInstallationAcceptsMixedDeclarations(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "show",
Summary: "Show a value.",
Flags: []commandkit.Flag[struct{}]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, commandkit.Supplied[string]) {}),
},
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
})
declarations := []commandkit.Declaration{
command,
commandkit.Alias("inspect", "show"),
commandkit.FlagAlias("--fmt", "--style"),
commandkit.FlagAliasFor("show", "-f", "--style"),
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if app == nil {
t.Fatal("New() returned a nil app")
}
}
func TestPublicAliasesPreserveCommandsAcrossDeclarationOrders(t *testing.T) {
for _, test := range []struct {
name string
rulesFirst bool
}{
{name: "commands first"},
{name: "routing rules first", rulesFirst: true},
} {
t.Run(test.name, func(t *testing.T) {
type runInput struct{ words commandkit.Supplied[[]string] }
type inspectInput struct{ id commandkit.Supplied[string] }
type showInput struct {
profile commandkit.Supplied[string]
format commandkit.Supplied[string]
}
var runParses, inspectParses, optionalParses int
var runValues [][]string
var inspectValues []string
var showValues []string
run := commandkit.NewCommand(commandkit.CommandSpec[runInput]{
Name: "run",
Arguments: []commandkit.Argument[runInput]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "words"}, 0, -1,
func(value string) (string, error) { runParses++; return value, nil },
func(target *runInput, value commandkit.Supplied[[]string]) { target.words = value },
)},
Run: func(_ commandkit.Invocation, value runInput) commandkit.Outcome {
runValues = append(runValues, append([]string(nil), value.words.Value...))
return commandkit.Success()
},
})
inspect := commandkit.NewCommand(commandkit.CommandSpec[inspectInput]{
Name: "inspect",
Arguments: []commandkit.Argument[inspectInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id"}, true,
func(value string) (string, error) { inspectParses++; return value, nil },
func(target *inspectInput, value commandkit.Supplied[string]) { target.id = value },
)},
Run: func(_ commandkit.Invocation, value inspectInput) commandkit.Outcome {
inspectValues = append(inspectValues, value.id.Value)
return commandkit.Success()
},
})
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Arguments: []commandkit.Argument[showInput]{commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "profile"}, false,
func(value string) (string, error) { optionalParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.profile = value },
)},
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
showValues = append(showValues, value.profile.Value+":"+value.format.Value)
return commandkit.Success()
},
})
commands := []commandkit.Declaration{run, inspect, show}
rules := []commandkit.Declaration{
commandkit.Alias("r", "run"),
commandkit.Alias("r full", "inspect"),
commandkit.Alias("inspect-shortcut", "inspect"),
commandkit.Alias("i", "inspect-shortcut"),
commandkit.FlagAlias("--fmt", "--style"),
}
declarations := append([]commandkit.Declaration(nil), commands...)
if test.rulesFirst {
declarations = append(append([]commandkit.Declaration(nil), rules...), commands...)
} else {
declarations = append(declarations, rules...)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"}, declarations...)
if err != nil {
t.Fatal(err)
}
if runParses != 0 || inspectParses != 0 || optionalParses != 0 {
t.Fatalf("installation invoked operand converters: %d/%d/%d", runParses, inspectParses, optionalParses)
}
for _, args := range [][]string{{"run", "full"}, {"r", "full", "item-1"}, {"i", "item-2"}, {"show", "profile", "--fmt=json"}} {
if outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v, want success", args, outcome)
}
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}}) {
t.Fatalf("canonical run operands = %#v, want [[full]]", runValues)
}
if !reflect.DeepEqual(inspectValues, []string{"item-1", "item-2"}) {
t.Fatalf("routed required operands = %#v", inspectValues)
}
if !reflect.DeepEqual(showValues, []string{"profile:json"}) || optionalParses != 1 {
t.Fatalf("optional operand/flag values = %#v, converter calls %d", showValues, optionalParses)
}
parsesBeforeReuse := [3]int{runParses, inspectParses, optionalParses}
secondApp, err := commandkit.New(commandkit.Identity{Name: "Second"}, declarations...)
if err != nil {
t.Fatalf("reusing declarations: %v", err)
}
if got := [3]int{runParses, inspectParses, optionalParses}; got != parsesBeforeReuse {
t.Fatalf("reinstalling declarations invoked operand converters: %v -> %v", parsesBeforeReuse, got)
}
if outcome := secondApp.Execute(context.Background(), []string{"run", "again"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("reused declaration execution = %#v", outcome)
}
if !reflect.DeepEqual(runValues, [][]string{{"full"}, {"again"}}) {
t.Fatalf("reused command declaration values = %#v", runValues)
}
})
}
}
func TestPublicRootGroupRegistrationAndFailedBatchRecovery(t *testing.T) {
type showInput struct{ format commandkit.Supplied[string] }
var formatParses, lateRuns int
var formats []string
show := commandkit.NewCommand(commandkit.CommandSpec[showInput]{
Name: "show",
Flags: []commandkit.Flag[showInput]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "style"}, (*string)(nil),
func(value string) (string, error) { formatParses++; return value, nil },
func(target *showInput, value commandkit.Supplied[string]) { target.format = value },
)},
Run: func(_ commandkit.Invocation, value showInput) commandkit.Outcome {
formats = append(formats, value.format.Value)
return commandkit.Success()
},
})
family := commandkit.RootGroup("config", "Configuration", show)
constructionFailure := errors.New("family construction failed")
if _, err := commandkit.New(commandkit.Identity{Name: "Broken"}, commandkit.WithError(family, fmt.Errorf("adapter: %w", constructionFailure))); !errors.Is(err, constructionFailure) {
t.Fatalf("New() error = %v, want retained family construction failure", err)
}
app, err := commandkit.New(commandkit.Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
var before bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &before, Err: io.Discard})
if strings.Contains(before.String(), "config") {
t.Fatalf("root help showed an unregistered family: %q", before.String())
}
if err := app.Register(family,
commandkit.Alias("cfg-show", "config show"),
commandkit.Alias("cs", "cfg-show"),
commandkit.FlagAlias("--fmt", "--style"),
); err != nil {
t.Fatal(err)
}
if formatParses != 0 {
t.Fatalf("registration invoked flag converter %d times", formatParses)
}
var after bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &after, Err: io.Discard})
for _, fragment := range []string{"config", "Aliases:", "cfg-show", "cs"} {
if !strings.Contains(after.String(), fragment) {
t.Errorf("updated root help %q does not contain %q", after.String(), fragment)
}
}
if outcome := app.Execute(context.Background(), []string{"cs", "--fmt=yaml"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("registered alias execution = %#v", outcome)
}
if !reflect.DeepEqual(formats, []string{"yaml"}) || formatParses != 1 {
t.Fatalf("registered family values = %#v, converter calls %d", formats, formatParses)
}
if err := app.Register(commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "cfg-show", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})); err == nil {
t.Fatal("registration accepted a canonical command that conflicts with an existing alias")
}
var afterConflict bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterConflict, Err: io.Discard})
if !strings.Contains(afterConflict.String(), "cfg-show") {
t.Fatalf("failed relationship validation changed registered aliases: %q", afterConflict.String())
}
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "late", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
lateRuns++
return commandkit.Success()
}})
registrationFailure := errors.New("retained registration failure")
err = app.Register(commandkit.WithError(late, fmt.Errorf("family: %w", registrationFailure)))
if !errors.Is(err, registrationFailure) {
t.Fatalf("Register() error = %v, want retained failure", err)
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "missing")); err == nil {
t.Fatal("registration accepted a batch whose alias target is absent")
}
var afterFailure bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &afterFailure, Err: io.Discard})
if strings.Contains(afterFailure.String(), "late-shortcut") || strings.Contains(afterFailure.String(), " late ") {
t.Fatalf("failed batch changed root help: %q", afterFailure.String())
}
if err := app.Register(late, commandkit.Alias("late-shortcut", "late")); err != nil {
t.Fatalf("registration after failed batch: %v", err)
}
var recovered bytes.Buffer
app.Execute(context.Background(), []string{"--help"}, commandkit.Streams{Out: &recovered, Err: io.Discard})
if !strings.Contains(recovered.String(), "late-shortcut") {
t.Fatalf("recovered root help omitted alias: %q", recovered.String())
}
if outcome := app.Execute(context.Background(), []string{"late-shortcut"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}); outcome.Kind != commandkit.OutcomeSuccess || lateRuns != 1 {
t.Fatalf("recovered alias execution = %#v, runs %d", outcome, lateRuns)
}
}

9
doc.go
View File

@@ -1,3 +1,8 @@
// Package commandkit provides an instance-owned CLI dispatcher with typed // Package commandkit provides the core instance-owned CLI dispatcher, typed
// command declarations, configuration scopes, and PromptKit inspection. // command declarations, application-owned command and flag aliases, input
// bindings, invocation-scoped reporting, and outcomes with separate operation
// causes and presentation observations. New and App.Register accept canonical
// Command declarations and RoutingRule declarations together. Optional
// integration modules provide ConfigKit and PromptKit-specific commands;
// importing those modules does not install their commands.
package commandkit package commandkit

View File

@@ -1,4 +1,4 @@
# ADR 0001: Own dispatch through an explicit registry and typed inputs # ADR 0001: Own dispatch through captured declarations and typed inputs
## Status ## Status
@@ -6,200 +6,73 @@ Accepted.
## Date ## Date
2026-10-06 2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-10.
## Context ## Context
Several downstream consumers of the ConfigKit and PromptKit libraries include Applications repeat routing, flag parsing, help, and inspection commands while
duplicated or near-duplicated code relating to configuration inspection, also needing their own domain commands. Sharing only isolated command handlers
routing, parsing, help, and error handling. However, their command vocabulary would leave much of that infrastructure and its inconsistent behavior with each
and syntax have diverged. CommandKit aims to provide a single, unified CLI application. Optional integrations also need to assemble commands through the
implementation that will reduce duplication, provide consistent vocabulary and same public mechanisms as applications.
grammar, and reduce the amount of code each individual application must own.
Applications have genuine domain commands, such as Narratio's `session restore`,
that remain outside the standard suite. Merely forwarding standard families from
separate application dispatchers would leave custom-command mechanics and root
help dependent on each application's implementation.
Input handling also needs a common boundary. Raw argv handlers or string-based
flag retrieval leave repeated parsing and input assembly with applications.
Presence matters for configuration overrides: omitted values differ from
explicitly supplied empty strings, false, and zero.
## Decision ## Decision
### Ownership and namespace structure CommandKit owns dispatch, shared parsing, presence tracking, help, syntax
failures, and standard command mechanics. Applications own their domain commands,
validation, effects, runtime construction, and executable lifecycle. CommandKit
accepts caller context and streams and returns outcomes rather than terminating
the process.
CommandKit owns the dispatcher, shared parsing and presence tracking, help, Use an explicitly constructed, per-instance registry. Applications and
syntax diagnostics, and standard-family handlers and outcomes. Applications own integrations supply captured declarations containing routing, parsing, help
custom command names, domain meaning, validation, effects, artifacts, and metadata, and callback references. One declaration supplies those views of the
operational outcomes. command. Captured metadata is insulated from later edits to its source;
application objects referenced by callbacks remain caller-owned.
Use namespace-first syntax for the standard suite, which provides a clear Declarations share one installation boundary. Construction issues are retained
family ownership boundary. The initial standard command families are `config` for installation, and each installation batch is validated before registry
for ConfigKit inspection and validation, and `promptkit` for PromptKit inspection mutation. Collisions are errors rather than order-dependent replacement.
and utilities. CommandKit integrates these libraries through their supported public Optional standard families claim their namespaces when installed, not when
semantics. imported. Their canonical operations remain integration-owned; application
commands live outside installed standard families.
CommandKit accepts custom commands outside its reserved standard families and Handlers receive typed inputs through explicit flag and operand bindings.
reports registration errors for attempts to replace standard handlers or add Presence is separate from value so omission differs from supplied empty, false,
private operations beneath them. Its input extension mechanism accepts context or zero values. Domain parsers and validation remain application-owned. Core
selectors and typed overrides for standard operations. provides typed composition for integrations without exposing parser state or
requiring a second argument parser.
CommandKit accepts caller-supplied context, streams, handlers, and inspection Parsed state is independent between invocations. Integration inputs for selected
providers and returns invocation outcomes. It leaves process termination to the scopes or comparison sides are distinct from invocation-wide acquisition state.
caller. Executable startup, signals, runtime construction, schemas, acquisition, Applications own the objects and caches returned by their factories. CommandKit
and domain resolution remain application choices. coordinates its own preparation and calls without promising arbitrary deep copies
or concurrent dispatch.
### Explicit registry and capability installation Construction, installation, help, version, and capability discovery use metadata
without acquiring application state. The selected operation acquires what it
Each explicitly constructed CommandKit instance owns a registry. Its API accepts needs after applicable input checks. Caller startup and callback effects retain
individual commands and composable groups. Registration uses explicit instance their own lifecycle.
state with caller-controlled construction and composition.
Definitions provide paths, descriptions, flags and defaults, repetition and
presence metadata, operand requirements, and execution handlers. One definition
supplies routing, parsing, and help. Duplicate paths and attempts to replace
reserved commands return registration errors; order does not select a winner.
Help and version are installed automatically using supplied application identity
and build metadata. CommandKit assembles standard families from declared provider
capabilities, installing their supported handlers and metadata together. Each
family defines its own resource model, supported verbs, and per-operation data
contracts. Configuration scopes declare their available inspection operations.
The API allows supported capabilities to be integrated independently. Adding
comparison, catalogs, or other inspection features extends the installed surface
according to the supplied providers. Shared verbs have consistent purposes across
families, while the available subsets reflect their resources and capabilities.
Help and discovery expose those subsets. Registration validates the wiring of
declared capabilities and reports unsupported selections before acquisition.
CommandKit registration validates declarations with acquisition callbacks left
uninvoked. Its construction, help, and version paths use metadata. The selected
handler requests dependencies when its operation needs them. Invalid configuration
returned by an acquisition callback is an invocation-time failure. Caller code
executed before registration and effects inside callbacks remain caller-owned.
CommandKit installs handlers for supplied capabilities and describes actual
availability. Each enabled operation retains its standard meaning and output
contract. The exact operation inventory and remaining command contracts are
separate design decisions.
### Typed inputs with explicit bindings
The public binding contract accepts application-defined input structs for custom
commands and uses CommandKit-defined inputs for standard handlers. Explicit
registration binds flags and operands to fields and supplies parsing and help
metadata. CommandKit delivers parsed typed inputs to registered handlers.
Bindings support ordinary typed fields and presence-aware fields where omission
has a separate meaning. CommandKit records supplied state independently of values
and defaults, preserving explicitly supplied empty, false, and zero values.
CommandKit keeps parsed values, collections, and presence from one invocation out
of subsequent invocations. Storage allocation and reuse are implementation
choices. Handler closures and application state retain their own lifecycle;
concurrent dispatch is a separate contract.
The invocation API supplies context and streams separately from CLI inputs.
CommandKit creates its declarations and bindings from metadata and defers its
acquisition calls to the selected handler. The binding system accepts domain value
parsers; domain interpretation and validation belong to registered callbacks.
The public factory, binding, registration, and handler signatures and presence
type names remain API design choices. No illustrative declaration establishes
an exact public API.
### Outcomes and domain behavior
Resolve the command and handle help/version before loading application state.
For execution, prepare independent invocation inputs, apply shared parsing and
operand checks, then invoke the typed handler. Return its invocation outcome to
the application.
CommandKit classifies shared usage errors and standard-family outcomes. Its
handler result contract accepts application-specific outcomes and preserves their
supplied classification. Scriptorium output validation and Notarius artifact
outcomes, for example, remain domain results. CommandKit translates results from
explicit classification and emits usage guidance for usage failures.
Common mechanics do not require a common success-report format for custom
commands. The application owns its artifact and operational output contracts.
Exact result/error interfaces and unresolved grammar choices are separate design
work, not approvals implied by this decision.
### Contract scope and consumer guidance
This decision governs CommandKit's public contracts and implemented behavior.
Each operation specifies the inputs, result semantics, callback lifetimes, and
metadata its provider supplies. Consumers choose how to implement those callbacks
and how to structure loading, execution, startup, and process status mapping.
Sharing acquisition and resolution between inspection and execution, and keeping
acquisition focused on producing inspection views, are recommended practices.
CommandKit supports those patterns through reusable inputs and lazy provider
hooks. Its API preserves declared results and coordinates its own calls; arbitrary
application internals remain application-owned.
Cobra isolation applies to CommandKit's exports and internal adapter. Independent
consumer use of another CLI library remains an application choice. Additional
families and source models can use capability contracts appropriate to their
resources, with standard meanings preserved for operations that are installed.
## Alternatives considered ## Alternatives considered
### Dispatcher and ownership - Application dispatchers forwarding standard families would share handlers but
leave custom-command parsing, root help, and diagnostics duplicated.
| Alternative | Analysis | - Raw-argument handlers or string-keyed input access would leave typed input
| --- | --- | assembly and presence handling with every consumer.
| Application-owned dispatcher forwarding standard families | Small integration surface and convenient migration, but custom syntax, root help, and errors still need cross-dispatcher coordination; removes less infrastructure. | - Reflection, struct tags, or generated bindings could shorten declarations but
| Standard definitions mounted into an application's framework | Reuses routing infrastructure, but application configuration can preserve inconsistent behavior outside mounted families. Internal framework reuse remains compatible with this decision. | add another declaration language or toolchain.
| Shared metadata/help with separate routing | Unifies discovery but adds synchronization among metadata, routing, and parsing. One dispatcher avoids it. | - Import-time registries or a separate integration registration framework would
| Both full dispatch and embeddable family handlers | Serves established external CLI architectures but adds integration surfaces and maintenance. Add embedding only for a concrete need. | introduce hidden state or parallel assembly conventions.
| Raw-argument handlers for custom commands | Eases migration but leaves parsing and help responsibilities with applications. A future transition adapter would need to describe its separate parsing and help contract. |
| Universal application runtime framework | Could absorb startup and operational services, but expands beyond the demonstrated CLI boundary and domain ownership. |
| Verb-first standard paths | Groups actions, but distributes namespace ownership among individual routes. Namespace/resource-first paths keep family ownership clear. |
| Reserve root `prompt` and `profile` for PromptKit | Provides short resource paths but consumes application namespaces. A single `promptkit` family gives the library one ownership boundary. |
### Registration
| Alternative | Analysis |
| --- | --- |
| Declarative tree passed to construction | Makes assembly visible together; larger applications still need group composition. May become a convenience over the same registry. |
| Constructor options for every command/group | Compact setup, but excessive options can obscure commands versus application settings. Option conventions remain API design work. |
| Global registry populated by `init` | Less entry-point code, but hidden import effects, shared mutable state, and unclear ownership. Explicit instances avoid those costs. |
### Handler inputs
| Alternative | Analysis |
| --- | --- |
| Parsed-value accessor object | Uniform handlers, but string-based lookup, runtime type errors, and repeated assembly remain. |
| Individual typed parameters | Clear for small commands; grows into long signatures and adapter machinery. Typed structs scale more naturally. |
| Reflection and struct tags | Compact declarations, but adds a tag language, field-discovery rules, and runtime declaration validation. Explicit bindings keep declarations visible. |
| Typed command objects | Can organize complex handlers. CommandKit's invocation-state contract remains separate from caller-owned object lifetimes; objects are an application option. |
| Generated bindings/adapters | Can combine compact authoring and typing, but adds tooling and generated-file maintenance without demonstrated need. |
| Raw arguments | Simple routing integration, but returns common parsing and help to applications. |
## Consequences ## Consequences
One command system removes repeated routing, parser setup, help assembly, and Applications use one command system while retaining domain ownership. Explicit
shared error plumbing as well as standard inspection handlers. Domain handlers bindings and captured declarations require deliberate assembly, but integrations
and their declarations remain recognizable application code. can supply complete families through the same public contracts.
The public registration surface is larger than family delegation, and explicit [GoDoc](../../command.go) owns declaration, installation, and callback contracts;
bindings require connecting declarations to the correct fields. Those costs the [consumer guide](../consumers/guide.md) explains their use. Exact grammar,
support consistent mechanics without requiring reflection or generated code. commands, and outcomes belong in the [CLI reference](../cli.md). Binding identity,
absence handling, factory defaults, and operation-specific preparation timing
Shared parsing defects can affect every adopter. Consumer and backend integration can evolve within this architecture through deliberate API changes.
evidence must protect selected semantics, including ownership conflicts, help
without configuration, presence, independent invocation inputs, cancellation,
and domain outcomes.
CommandKit releases document intentional compatibility changes. Coordinated
application documentation and subprocess-caller updates are recommended migration
practices.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0002](0002-cobra-backend.md) selects the backend, and
[ADR 0003](0003-public-facade.md) defines its public/internal boundary.

View File

@@ -1,78 +0,0 @@
# ADR 0002: Use Cobra and pflag as the initial internal CLI backend
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit's selected dispatcher model needs nested routing, flags and operands,
generated help, usage diagnostics, and invocation execution. Reimplementing this
generic infrastructure would add work beyond the suite-specific inspection and
consistency behavior that motivates the library.
## Decision
Use Cobra with pflag as the sole initial CLI backend. CommandKit translates its
registrations into backend command trees, flag bindings, and execution callbacks.
Reuse their routing, option parsing, help, context, and stream mechanisms and add
adaptation where selected CommandKit semantics require it.
CommandKit retains its own public registration API, typed inputs, standard
families, and observable contracts. Its public registration and handler APIs
use CommandKit-owned definitions and typed inputs. Cobra commands and pflag
getters stay within CommandKit's internal adapter. Independent consumer framework
use remains application-owned. [ADR 0003](0003-public-facade.md) records the
adapter and root-owned public contracts.
Invocations do not inherit parsed values or presence from previous invocations.
Rebuilding the backend command tree and input storage from registered definitions
is the initial implementation strategy; safe reuse remains an implementation
choice. CommandKit's tree construction uses registered metadata and leaves
acquisition callbacks uninvoked. Application startup and callback effects retain
their own lifecycle.
Implement one concrete backend. There is no initial public backend selector or
pluggable framework. A future replacement can change internal implementation
while preserving the public API and selected behavioral contracts.
## Alternatives considered
| Alternative | Advantages | Reason not selected |
| --- | --- | --- |
| urfave/cli v3 | Destination bindings for flags and named operands, presence queries, and duplicate controls fit the chosen input model closely. | Credible alternative; Cobra's broader adoption is the preferred maintenance tradeoff. |
| pflag alone | Established parsing with a smaller integration surface. | Leaves more routing, help integration, and command infrastructure to implement. |
| Kong | Compact typed-struct and tag-driven declarations. | Its main declaration model differs from the explicit-binding API; adaptation reduces its benefit. |
| Standard `flag` alone | Familiar variable binding and custom value parsing. | First-operand stopping requires adaptation for the intended syntax, with more custom command infrastructure. |
| Expose Cobra as the public API | Less adapter code and the full framework surface. | Couples consumers to backend structures and framework-specific configuration. |
| Interchangeable backends immediately | Future integration flexibility. | Adds abstraction and behavior surfaces without a current requirement. |
## Consequences
The framework supplies established generic CLI machinery. CommandKit still owns
registration conflicts, reserved families, typed bindings and presence, standard
help and diagnostics, provider integration, and selected reports and outcomes.
The adapter must accommodate explicit empty/false/zero values, independent parsed
state, selected repetition and operand rules, and help without configuration.
Backend features do not expand the standard automatically: for example,
completion commands remain deferred unless deliberately selected.
Backend error handling must preserve application outcomes, avoid duplicate
diagnostics, and return control to the application's entry point.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher and input
model.
Reviewed primary references from the selection:
- [Cobra API](https://pkg.go.dev/github.com/spf13/cobra).
- [Cobra command and help guide](https://github.com/spf13/cobra/blob/main/site/content/user_guide.md).
- [Cobra flag bindings](https://cobra.dev/docs/how-to-guides/working-with-flags/).
- [pflag syntax and extension points](https://github.com/spf13/pflag).
- [urfave/cli v3 API](https://pkg.go.dev/github.com/urfave/cli/v3).

View File

@@ -0,0 +1,68 @@
# ADR 0002: Own public contracts behind an internal CLI backend
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-09.
## Context
CommandKit needs established routing, parsing, and help machinery while retaining
control of its consumer contracts. Exposing a backend framework directly would
couple applications and integration handlers to that framework's objects and
configuration model.
## Decision
Use the root `commandkit` package as the core module's public facade. Optional
integrations own their public contracts in separate modules under
[ADR 0004](0004-optional-integration-modules.md).
Use Cobra with pflag as the internal CLI backend. Confine their types and
operations to an internal adapter that translates CommandKit declarations and
selected semantics into backend machinery. Registry logic, shared definitions,
and standard handlers remain independent of backend structures. Do not mirror
the complete backend API through renamed wrappers.
Public types and handles expose deliberate contracts and keep implementation
state private. Exports must not reveal internal or backend types through aliases,
embedding, signatures, method sets, loosely typed values, or backend-owned
syntax errors. Translate those syntax failures into CommandKit diagnostics and
outcomes. Caller-supplied objects and errors retain their documented handling;
this boundary does not require transforming arbitrary application content.
Standard Go contracts remain appropriate. Deliberate upstream public types may
be exposed by integrations that own those dependencies. Implementation may live
in the owning public package; introduce internal layers and conversions where
they simplify the code or protect the backend boundary.
There is one concrete backend, without a public backend-selection framework.
A replacement must preserve public contracts or accompany deliberate
compatibility changes. Backend defaults do not independently define CommandKit
behavior.
## Alternatives considered
- Exposing Cobra directly would reduce adapter work but make the framework part
of the consumer contract.
- A forwarding-only facade with duplicate internal representations would enforce
layering at the cost of conversions without a demonstrated need.
- pflag alone or the standard flag package would leave more routing and help
machinery to implement. Other full CLI frameworks are credible alternatives;
Cobra's adoption was the preferred maintenance tradeoff.
- Interchangeable backends would add public assembly and behavior contracts
before a concrete need exists.
## Consequences
Consumers depend on CommandKit's selected contracts while internal implementation
can evolve. The adapter still needs to preserve typed inputs, presence, public
error identity, and invocation lifetimes; package placement alone is insufficient.
The [architecture policy](../policy/architecture.md) owns dependency constraints,
GoDoc owns exact exports, and the [internal overview](../internal/overview.md)
owns package mechanics. Backend construction and reuse strategies are
implementation choices within the invocation-isolation contract.

View File

@@ -0,0 +1,61 @@
# ADR 0003: Expose configuration scopes through declared capabilities
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-06.
## Context
An application may expose its own configuration and configurations belonging to
integrated libraries. Repeating a configuration command tree for each one would
duplicate operator mechanics. Requiring identical inspection capabilities would
exclude useful integrations whose upstream APIs provide only a subset.
## Decision
Expose independently meaningful configurations as named scopes through a shared
configuration command family. A scope identifies the configuration being
inspected; a command family defines operations on its resources. An integration
may expose either or both without requiring the other.
Each scope declares its supported operations and providers. Assemble standard
commands from the available capabilities and describe actual availability through
metadata-driven help and discovery. An unavailable capability does not prevent
using the scope's other operations.
Validate required provider wiring against declared capabilities. Check selected
operation availability before acquisition. Aggregate inspection must account for
its requested coverage rather than silently dropping unsupported scopes.
Use narrow integration contracts, reusing upstream public types where useful.
Applications supply acquisition and domain context; integrations adapt supported
inspection APIs under [ADR 0004](0004-optional-integration-modules.md). Scope and
comparison inputs follow the input/state separation in
[ADR 0001](0001-command-dispatch-and-inputs.md).
## Alternatives considered
- A separate command tree per configuration would make the target part of the
path but repeat shared operations and help.
- Requiring every operation for every scope would make one missing capability
block otherwise useful inspection.
- One universal operation set across library families would assign operations
to resources for which they have little meaning.
## Consequences
Operators use a common configuration family while providers can expose supported
features incrementally. Capability declarations and help must describe actual
coverage, and provider abstractions must remain proportionate to integration
needs.
The [CLI reference](../cli.md#configuration-scopes) owns command inventory, scope
names and defaults, selector syntax, aggregate applicability, and report ordering.
The [consumer guide](../consumers/guide.md#configkit-scopes) and
[GoDoc](../../integrations/ckconfig/contracts.go) own provider wiring and precise
input/acquisition lifetimes. Those details do not require separate ADRs merely
because they are public contracts.

View File

@@ -1,100 +0,0 @@
# ADR 0003: Own public contracts through a root facade and internal CLI adapter
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit needs room to change its internal implementation details while
consumers retain access to a stable external API. Exposing backend assembly or
extension structures would let applications ossify around implementation
details that maintainers may later decide to replace.
ConfigKit, PromptKit, and ReleaseKit demonstrate facades that own public types
and delegate through private state and explicit conversion. ConfigKit also
translates upstream YAML parser structures into an independent internal document
model.
The selection of Cobra as an internal dependency is compatible with this
decision. Public API independence needs a genuine contract boundary, and
keeping backend structures out of shared internal representations also makes
future replacement easier.
## Decision
Use the module root `commandkit` as the sole initial supported public facade.
Define CommandKit's public named types there and expose only selected operations,
extension contracts, and documented methods. Handles keep implementation state in
private fields.
Straightforward implementation may live in the root package without expanding
its exports. Introduce internal packages, interfaces, and conversions where they
simplify implementation or protect the backend boundary. Package organization and
the amount of internal layering follow implementation needs. Additional public
packages require a deliberate decision based on a durable consumer responsibility.
Do not alias public types to internal or backend types, anonymously embed those
types to promote methods, or expose implementation bridge methods. Exported
fields, signatures, variables, callbacks, interface methods, constraints, and
method sets must not reveal internal or Cobra/pflag structures. CommandKit does
not expose its internal backend objects or backend-specific syntax errors through
its public API, including through loosely typed values or returned errors.
Caller-supplied values and errors retain their documented handling; this boundary
does not require inspecting or transforming arbitrary application objects.
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
Public types from integration libraries, such as ConfigKit and PromptKit, may
be selected as deliberate dependencies. Application input types remain
application-owned. Go declarations and GoDoc own exact exported contracts.
Confine Cobra/pflag types and operations to an internal adapter. Registry logic,
standard handlers, and shared definitions remain independent of backend structures.
The adapter translates paths, descriptions, bindings, presence, callbacks, and
selected help/outcome semantics into backend machinery. Do not reproduce Cobra's
complete API through renamed wrappers.
Preserve independent parsed state per invocation under
[ADR 0001](0001-command-dispatch-and-inputs.md). Factories, callbacks, and shared
representations remain API and implementation design choices.
Translate backend-owned syntax failures into CommandKit diagnostics and outcomes.
Preserve useful public identity for application handler errors and cancellation
according to the invocation contract. Any conversions must preserve value
ownership, callback lifetime, presence, and supported error semantics.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Keep all machinery in the public root | Less forwarding, but harder consumer navigation and easier accidental public expansion. |
| Require a forwarding-only root and separate internal representations | Enforces a uniform layering scheme. It can add duplicate types and callback plumbing before those mechanisms simplify implementation. |
| Alias or embed implementation types | Less conversion code, but couples public type identity, fields, or method sets to internal choices. |
| Hide Cobra only behind the public facade | Hides types from applications but allows backend structures to spread through internal logic, complicating replacement. |
| Mirror Cobra's API with wrappers | Removes dependency names while preserving its conceptual coupling and compatibility surface. |
| Public packages for registry, bindings, backend, and other mechanisms | Adds supported imports and assembly contracts without a distinct consumer responsibility. |
| Selectable/pluggable backends immediately | Adds selection and extension machinery before a concrete need exists. |
## Consequences
Consumers depend on deliberate root contracts while internal algorithms,
collaborators, and package boundaries can evolve. Useful application registration
capabilities still need a proportionate API; minimizing accidental exports must
not recreate repetitive consumer infrastructure.
Forwarding and conversion add maintenance where used. Proportionate checks should
protect the public surface, backend isolation, and consumer behavior through the
real adapter. The implementation determines which checks provide useful evidence;
package relocation alone does not establish those properties.
Backend replacement can preserve the public Go API but must also preserve selected
CLI behavior or follow the release policy for intentional incompatibilities.
Backend defaults do not independently establish new CommandKit contracts.
The [architecture policy](../policy/architecture.md) owns normative boundaries.
[ADR 0001](0001-command-dispatch-and-inputs.md) records the dispatcher/input model,
and [ADR 0002](0002-cobra-backend.md) selects the initial backend.

View File

@@ -0,0 +1,71 @@
# ADR 0004: Separate optional CLI integrations from the core
## Status
Accepted.
## Date
2026-11-07; consolidates decisions made on 2026-10-09 and 2026-10-11.
## Context
Applications need CommandKit with different combinations of library integrations,
including none. A core-only consumer should not acquire optional libraries in its
module graph. Integrations also need a clear boundary between exposing operator
tasks and taking over the upstream libraries' domain functionality.
## Decision
Keep the core and optional integrations in separate Go modules with their own
public package contracts. Core owns shared CLI infrastructure. Integrations own
standard operator-facing commands, provider contracts, content projections, and
adapters for their libraries. They are not convenience libraries for
application-internal use of upstream APIs.
Dependencies point from integrations toward core. Core's API, implementation,
tests, examples, tools, and module requirements remain independent of optional
integration libraries. An integration may depend on another integration when
its responsibility requires it; document that dependency for consumers.
Applications explicitly install integration declarations through public core
contracts. Importing a module installs nothing. Integrations supply convenient
complete-family assembly and supported composition without accessing private
core state or backend structures. Add narrow public core capabilities for actual
integration needs rather than a universal provider or plugin framework.
The integrated libraries own configuration resolution, prompt preparation,
comparison, validation, and other domain algorithms. CommandKit invokes their
supported public APIs and preserves returned meaning, supported error identity,
and safe-disclosure rules. The adopted upstream version governs those semantics;
CommandKit does not maintain a parallel domain specification or reconstruct
comparison from rendered text or redacted snapshots.
Integrations choose report content from suitable inspection evidence, preserving
upstream confidentiality and presence distinctions. CommandKit may summarize or
project that safe evidence for operator needs. Applications retain ownership of
schemas, loading, engine construction, credentials, domain execution, and startup.
Arbitrary application content is not implicitly safe for disclosure.
## Alternatives considered
- Keeping integrations in core, or in packages within its module, would retain
their dependencies in the core module graph.
- Requiring applications to implement standard integration handlers would repeat
command and reporting infrastructure.
- Universal core provider types would introduce abstraction and conversion work
before concrete integrations justify it.
- Reimplementing upstream algorithms would duplicate domain ownership and could
lose evidence already withheld from safe inspection values.
## Consequences
Consumers can adopt core independently and add integrations as needed. Separate
modules require coordinated dependency, validation, and release work. Integrations
remain maintained parts of CommandKit with shared CLI conventions.
Upstream changes require adapter and documentation updates against the adopted
public contract. The [CLI reference](../cli.md) owns operator behavior, GoDoc owns
provider contracts, and [ADR 0006](0006-invocation-reporting.md) owns the shared
presentation boundary. Report formats, comparison statuses, and individual
operation inventories can evolve without changing domain ownership.

View File

@@ -1,79 +0,0 @@
# ADR 0004: Define standard inspection operations by family and capability
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit serves applications with shared ConfigKit and PromptKit inspection
needs. A defined operator surface establishes the infrastructure CommandKit
provides. Different integrations expose different resources and inspection
capabilities, while shared verbs benefit from consistent meanings.
## Decision
CommandKit supplies help and version reporting and defines ConfigKit and
PromptKit inspection operations. Each family defines its own supported set of
operations. Registration installs handlers for the capabilities supplied by the
integration and exposes their availability through help and discovery metadata.
| Standard operation | Responsibility |
| --- | --- |
| `help` | Describe registered standard and application commands. |
| `version` | Report application version by default; report embedded CommandKit version when explicitly selected. |
| `config scopes` | List registered configuration scopes and supported operations. |
| `config validate` | Validate the selected configuration and its configured definitions. |
| `config show` | Display final effective configuration. |
| `config sources` | Describe the acquired and synthetic input source inventory. |
| `config explain` | Explain effective or historical values and provenance at a selected path. |
| `config diff` | Compare effective configuration selections semantically. |
The optional PromptKit suite supplies `promptkit list prompts`,
`promptkit list profiles`, `promptkit show prompt`, and `promptkit show profile`.
These operations expose captured definition catalogs, prompt metadata, and
backend-completed named execution targets. PromptKit definition capabilities and
its configuration scope are registered according to their available provider
operations. Either can be integrated independently.
Adoption can use the supported subset under
[ADR 0001](0001-command-dispatch-and-inputs.md). Missing comparison or catalog
capabilities leave other inspection operations available.
Shared verbs keep consistent purposes: `list` enumerates resources, `show`
displays a selected resource, and `diff` compares supported representations. Each
family specializes those purposes for its own resources.
Version subjects are independent of configuration scopes and loading providers.
CommandKit owns vocabulary and observable contracts within its standard families.
Custom execution, rendering, serving, sessions, and artifact workflows retain
their application-owned meanings.
The [CLI reference](../cli.md) owns exact flags, operands, defaults, and report
schemas for implemented operations.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Initial operations limited to validation and display | Accelerates first integrations. Source explanation, comparison, and discovery remain repeated application infrastructure. |
| Require every operation for every integration | Gives a uniform inventory. One unavailable capability blocks other useful operations and future resource families. |
| Definition inspection through configuration operations alone | Source values and provenance share one path. Captured catalogs and completed execution targets require additional interpretation. |
| Standardize execution and mutation alongside inspection | Expands shared vocabulary to more workflows. Additional domain and state-management contracts require their own evidence and design. |
| Display dependency identities in ordinary application version output | Makes dependency information immediately visible. Application identity becomes coupled to dependency reporting. |
## Consequences
CommandKit supplies shared handlers with predictable meanings and discovers their
availability from registration. Each enabled operation needs its own data
contract: comparison needs safe snapshots, catalogs need enumeration, and profile
display needs safe execution-target inspection.
The [scope model](0009-configuration-scopes.md) and
[comparison policy](0008-semantic-configuration-diff.md) describe related
capability and behavioral decisions.

View File

@@ -0,0 +1,65 @@
# ADR 0005: Separate application alias routing from canonical command ownership
## Status
Accepted.
## Date
2026-11-07; original decision made on 2026-10-19.
## Context
Applications need alternate command paths and flag spellings that suit their
operator vocabulary. These conveniences should reuse the behavior supplied by
application commands and optional integrations. Canonical command families
already establish ownership of handlers, namespaces, inputs, and invocation
behavior.
A routing layer allows applications to choose alternate spellings while
preserving those ownership boundaries. Commands and routing rules have distinct
roles, and their relationships require validation during application assembly.
## Decision
CommandKit core owns alias declaration, resolution, and coordination with parsing
and help. Applications choose their alias mappings. Alias infrastructure is
available independently of optional integrations under
[ADR 0004](0004-optional-integration-modules.md).
Aliases belong to an application routing layer that directs invocations to
canonical commands and flags. Canonical definitions retain ownership of their
handlers, inputs, and namespaces. An alternate path within an integration's
namespace remains an application routing rule; the integration continues to own
the canonical command family and its operations.
Canonical commands and routing rules are distinct declaration kinds installed
through the common assembly boundary in [ADR 0001](0001-command-dispatch-and-inputs.md).
Installation validates their relationships together and preserves canonical
ownership. Declaration capture, retained errors, and batch validation follow that
shared model.
An invocation through an alias uses the canonical target's parsing, typed
bindings, validation, and ordinary invocation lifecycle. Execution behavior
remains with the canonical command and its owning application or integration.
Core coordinates alternate routing and presentation through the same command
infrastructure.
## Alternatives considered
- Wrapper commands would introduce additional nodes and handler delegation into
namespace ownership and invocation lifetimes.
- Application argument rewriting would repeat routing infrastructure and separate
alias handling from target validation, parsing, and help.
- A separate alias assembly API would split validation of commands and their
alternate routes across installation boundaries.
## Consequences
Applications can tailor vocabulary while reusing canonical behavior. Core must
coordinate routing, parsing, and help across those layers, while optional
integrations retain ownership of their command families.
GoDoc and the [CLI reference](../cli.md#application-owned-aliases) own exact API
shapes, resolution rules, and presentation choices. Those details can evolve
within this architectural separation.

View File

@@ -1,78 +0,0 @@
# ADR 0005: Return explicit outcomes and deliver reports through defined channels
## Status
Accepted.
## Date
2026-10-06
## Context
An invocation crosses routing, parsing, provider acquisition, a standard or
application handler, report writing, and the application entry point. Operators
and subprocess consumers need consistent classifications for those stages.
Applications also have meaningful domain outcomes of their own.
## Decision
CommandKit returns an invocation outcome with an explicit classification and
recommended process status. The public model distinguishes successful operation,
usage error, operational failure, and application domain outcome. Failures retain
their consumer-facing cause and supported `errors.Is`/`errors.As` identity.
Exact Go declarations remain part of facade API design under
[ADR 0003](0003-public-facade.md).
Use these mappings for CommandKit-owned commands:
| Status | Meaning |
| --- | --- |
| `0` | The requested operation completed successfully. |
| `1` | A syntactically valid operation failed during loading, validation, lookup, inspection, cancellation, diagnostics delivery, or report writing. |
| `2` | The invocation failed command, flag, scope, format, operand, or input-combination validation. |
CommandKit's handler result contract accepts domain classifications and suggested
process statuses supplied by the caller. CommandKit preserves that classification
when returning the handler's result. A custom domain status and a shared usage
status may have the same numeric value; their explicit classifications retain
their distinct meanings. The application entry point controls final process
termination and status mapping.
CommandKit writes standard reports to the supplied report stream, conventionally
stdout, and its warnings and errors to the supplied diagnostic stream,
conventionally stderr. Callers choose the streams. CommandKit assigns responsibility
for presenting its diagnostics, avoids duplicate presentation through its own
paths, and reports write failures. Caller callbacks retain control of their own
output. CommandKit adapts backend syntax failures into its public classification
and diagnostic presentation. Usage guidance accompanies usage errors; operational
failures receive diagnostics appropriate to the failed operation.
Warning severity alone permits a successful operation. A diagnostic writer
failure is an operational failure.
A writer failure can leave a partial report and returns operational failure. The
outcome identifies incomplete delivery. Custom handler output remains
application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report
preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful
comparison, including differences and coverage gaps.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| A single failure classification | Simplifies outcome handling. Callers derive syntax, operational, and domain distinctions through additional interpretation. |
| Backend errors and output as the public outcome model | Reuses framework machinery directly. Backend conventions become dependencies for applications and subprocess consumers. |
| One common mapping for application domain failures | Produces uniform numeric statuses. Applications adapt existing artifact and execution outcomes to shared categories. |
| Handler-owned diagnostics for every failure | Gives each handler full presentation control. Shared diagnostic consistency requires repeated ownership and formatting coordination. |
## Consequences
The outcome model supports both Go callers and executable entry points. Explicit
classification keeps domain identity intact through adapter and facade conversion.
Single diagnostic ownership supports predictable presentation.
The public result/error design must preserve cause and delivery failures across
handlers, writers, and the backend adapter. Diagnostic delivery bookkeeping
remains API design work. Acceptance establishes the compatibility meaning of the
status values and delivery rules across standard commands.

View File

@@ -1,77 +0,0 @@
# ADR 0006: Apply common command grammar and recommend shared vocabulary
## Status
Accepted.
## Date
2026-10-06
## Context
CommandKit owns routing, parsing, and help for standard and registered application
commands. A concrete grammar makes option placement, operands, repetition, and
help predictable across the application suite. Shared vocabulary also reduces
the need to remember different verbs for equivalent operations.
## Decision
CommandKit's standard grammar and vocabulary are part of the contract for
CommandKit-owned commands. Its shared dispatcher and binding APIs implement that
grammar, so application-owned commands registered through those APIs receive the
same parsing mechanics. Applications retain ownership of their command names,
operand meanings, and domain behavior.
CommandKit's documentation recommends the same grammar and vocabulary for
application-owned commands, including commands implemented outside its dispatcher.
Applications are encouraged to use standard verbs such as `help`, `list`, and
`show` where their operations have the corresponding purposes described in
[ADR 0004](0004-standard-inspection-surface.md).
CommandKit parses options after the complete registered command path. Within the
selected command, options and declared operands may be interspersed. Operand
counts and types come from registration.
Standard options use lowercase names with hyphen-separated words and the `--`
prefix. Value options accept `--name value` and `--name=value`. Boolean options
accept their documented presence form and explicit values such as `--name=false`.
The `--` token ends option parsing; subsequent tokens are operands.
Registered metadata identifies each flag as singleton or repeatable. For a
singleton, the last occurrence supplies its value, including when the invocation
mixes supported spellings. A repeatable flag accumulates values in invocation order.
Presence follows the typed-input contract in
[ADR 0001](0001-command-dispatch-and-inputs.md).
CommandKit validates paths, flag names, conversions, operand counts, and declared
input combinations through the shared parser. Those failures use the usage
classification in [ADR 0005](0005-invocation-outcomes.md).
Root help is available through `app`, `app help`, `app --help`, and `app -h`.
Invoking a command group with no registered execution handler displays group help.
A runnable command follows its registered handler and operand requirements,
including when it also has subcommands. CommandKit-owned groups such as `config`
display help on bare invocation. `app help COMMAND ...` and a resolved command's
`--help` display the same registered help. Required execution operands are checked
for execution requests; help uses registered metadata. Invalid paths and flag
names receive usage diagnostics.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Flags before all operands | Gives argument order a simple positional rule. Operators organize values around that rule when assembling commands. |
| Common flags before or between command-path components | Supports familiar global-option styles. Routing and help need rules for options across several command levels. |
| Reject repeated singleton flags | Detects repeated assignments, but prevents callers from assembling defaults followed by explicit overrides. |
| Application-selected parser conventions | Supports existing CLI implementations. Operators and shared help need per-application syntax knowledge. |
## Consequences
Operators learn one option placement and repetition policy for adopting
commands using CommandKit. Shared vocabulary guidance encourages consistent
names for equivalent application-owned operations.
The internal adapter must preserve the selected grammar across backend changes.
The [CLI reference](../cli.md) owns operation-specific command signatures and
examples for implemented commands.

View File

@@ -0,0 +1,78 @@
# ADR 0006: Share invocation reporting while preserving application outcomes
## Status
Accepted.
The structured-report formatting extension is accepted but not yet implemented;
the [adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
tracks that work.
## Date
2026-11-07; consolidates decisions made on 2026-10-06 and 2026-10-25
and the accepted shared-format ownership extension.
## Context
An invocation crosses routing, parsing, acquisition, command execution, and output
delivery. Applications need consistent presentation and useful returned outcomes
without giving a CLI library control of domain recovery or process termination.
Separate reporting facilities would repeat visibility and failure-handling rules.
## Decision
Core provides one reporting facility per invocation for command results,
immediate messages, deferred messages, and final presentation. Callers choose
result and diagnostic destinations. Applications and integrations choose content,
messages, and domain outcomes; core coordinates visibility, delivery, and its
own parsing and presentation failures.
Applications decide whether a domain error warrants recovery, a warning, or a
failed invocation. They supply explicit outcomes and explanations, optionally
with an error cause. A cause alone does not select an outcome or create an
operator-facing message. Preserve supplied causes and their supported public
identity. Integrations adapt upstream evidence without taking ownership of its
domain meaning or disclosure rules.
Return explicit outcome classifications that distinguish shared usage and
operational failures from application-defined domain outcomes. Preserve domain
classification and keep presentation failures separately observable from the
operation's explanation and cause. Delivery failure must be visible without
silently discarding the application's result. The application entry point owns
final process status mapping and termination.
Present terminal failure explanations after ordinary reporting finishes. Core
coordinates its own diagnostic paths to avoid duplicate presentation. Reporting
state serves delivery and the returned outcome; logging and message history
remain application responsibilities.
The accepted structured-report extension centralizes format selection,
serialization, and rendering in core. Applications and integrations hand off
selected content, including their chosen detail level and optional text template;
core determines how to output it. New core formats should work through that
shared handoff without each command maintaining encoders. This extends the same
invocation lifecycle, with a writer API sharing rendering outside invocations.
Implementation status and initial format choices remain in the roadmap above.
## Alternatives considered
- Handler-owned presentation would maximize local control but repeat output,
visibility, and delivery-error infrastructure.
- Letting core interpret domain errors would give it recovery decisions that
depend on application behavior.
- One failure category or numeric status alone would lose meaningful distinctions
between usage, operational, and domain results.
- Separate facilities for results and messages, or encoders in each integration,
would spread shared behavior across independent implementations.
## Consequences
Commands share presentation while retaining ownership of their content and
outcomes. Core must keep operation and presentation evidence distinguishable,
including when output is partial. Caller writers retain their own lifecycle.
GoDoc and the [CLI reference](../cli.md#reporting) own implemented APIs, numeric
statuses, visibility controls, and exact delivery behavior. Report layouts,
format defaults, YAML framing, and scope ordering are presentation contracts,
not separate architectural decisions. The shared-format extension remains
planned until its implementation and canonical contracts land.

View File

@@ -1,73 +0,0 @@
# ADR 0007: Use native YAML and ordered documents for inspection reports
## Status
Accepted.
## Date
2026-10-06
## Context
ConfigKit and PromptKit provide native YAML configuration inspection. Reusing
their writers preserves captured value semantics, ordering, and redaction. YAML
also supports automated consumption of structured reports.
## Decision
The initial standard uses text for concise operator reports and YAML for
structured consumption.
A single-scope `config show` writes the owning library's complete native redacted
YAML. Its output consists of the inspection document itself. The native writer
preserves effective presence, null and container distinctions, stable mapping
order, and sequence order according to its public contract.
`config show --scope all` emits one native document per registered scope in the
order defined by [ADR 0009](0009-configuration-scopes.md). CommandKit supplies
document separators and scope-label comments around native writer output. Each
scope retains its own value namespace and native rendering; the documents can
be decoded individually.
For `config show`, CommandKit checks operation availability for selected scopes
before acquisition. It acquires all selected views and completes validation
required by those provider contracts before report output begins. A failure
returns its diagnostic outcome before report writing. Other inspection operations
define their own output timing. After writing begins, a writer failure may leave
partial bytes and returns operational failure under
[ADR 0005](0005-invocation-outcomes.md).
Report confidentiality follows the owning libraries' sensitivity classifications.
Their safe writers and records supply redacted content. CommandKit preserves
those confidentiality semantics; arbitrary caller content retains the caller's
disclosure policy.
Additional families select formats appropriate to their resources. Custom handler
formats remain application-owned. The [CLI reference](../cli.md) owns exact
per-operation formats, framing syntax, and report shapes for implemented commands.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| A scope-keyed YAML wrapper | Carries scope identity as parsed mapping keys. Rendering needs an additional wrapper representation and indentation of native documents. |
| JSON alongside YAML initially | Serves JSON-specific tools. The library maintains another serializer and representation contract. |
| YAML as every report's default | Provides uniform structured output. Interactive help, tables, and explanation workflows use structured documents for routine discovery. |
| Write `config show` documents as each scope is acquired | Starts reporting earlier. A later acquisition failure can leave a successful prefix representing only part of the selection. |
| Marshal application runtime structs | Reuses application data types. Presence, captured resolution, ordering, and safe historical inspection need a separate rendering lifecycle. |
## Consequences
Native display preserves owning-library behavior and keeps rendering focused.
Combined inspection requires multi-document YAML consumption and an ordered scope
contract.
Loading all selected scopes before `config show` output adds preparation time and
allows a failure in one scope to prevent combined display. Explicit single-scope
display remains available for a narrower view.
Additional structured schemas are compatibility contracts owned by CommandKit.
Native concatenation can use existing writers; semantic comparison separately
requires the safe structured data described in
[ADR 0008](0008-semantic-configuration-diff.md).

View File

@@ -1,77 +0,0 @@
# ADR 0008: Compare semantic snapshots with explicit sensitivity gaps
## Status
Accepted.
## Date
2026-10-06
## Context
Configuration comparison helps operators understand changes in final effective
values across roots and overlays. Formatting, authored file layout, and provenance
identifiers are different properties from effective-value equality. Sensitivity
classification also limits the values available for inspection.
Meaningful comparison requires structured effective snapshots with per-path
sensitivity data from the owning libraries. The comparison policy must preserve
value semantics while making limited coverage visible to operators.
## Decision
For a scope declaring safe comparison support, `config diff` compares two
independently resolved effective configurations. CommandKit checks that capability
before acquisition. Providers supply captured results through the owning
libraries' supported public inspection APIs.
Compare values using the owning library's safe structured inspection semantics,
including defaults and recorded domain resolution. Preserve the distinctions
among absent values, null, empty mappings, and empty sequences. Mappings are
compared recursively; ordered sequences are compared as whole values.
For each path, use the union of the two sides' sensitivity classifications to
protect both before and after presentation. A value classified on either side
receives the corresponding safe treatment in both snapshots. Hidden values
produce explicit comparison gaps with their paths and supported reasons.
Equality conclusions cover the comparable data. A comparison with gaps reports
its limited coverage alongside observed differences. A complete comparison with
an empty difference set supports an equality conclusion for the inspected
selection. Redaction masks are presentation data; classification metadata
determines comparison coverage.
Text and YAML reports include differences and coverage gaps. The exact report
schema belongs in the [CLI reference](../cli.md) when implemented. Selection
syntax, inheritance rules, and detailed change records are outside the scope
of this decision.
A completed comparison returns success whether it finds differences or gaps;
load, comparison, and writer failures use the operational failure classification in
[ADR 0005](0005-invocation-outcomes.md).
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Rendered YAML line comparison | Reuses native display output. Formatting and masking become part of change interpretation. |
| Compare masks as literal strings | Uses visible snapshot bytes directly. Equal masks express equal presentation while hidden values remain uncertain. |
| Use one side's sensitivity policy | Simplifies classification selection. A value's disclosure treatment depends on which snapshot supplies that policy. |
| Fetch secret values for comparison | Gives access to additional equality evidence. Comparison acquires sensitive material and additional confidentiality responsibilities. |
| Element-by-element sequence diff | Gives detailed collection changes. Stable element identity and alignment need another comparison contract. |
| A distinct status for observed differences | Supports assertion-oriented scripts. Inspection completion and change detection share an outcome signal. |
## Consequences
Operators receive meaningful effective-value changes with explicit uncertainty
where sensitivity limits comparison. Whole-sequence comparison keeps
implementation scope focused at the cost of less detailed collection changes.
Diff support depends on safe snapshots preserving value semantics and sensitivity.
CommandKit applies this comparison policy to scopes declaring that capability.
Other operations and families can be integrated independently under
[ADR 0004](0004-standard-inspection-surface.md).
Applications can use the successful report as input to domain decisions or future
assertion workflows.

View File

@@ -1,80 +0,0 @@
# ADR 0009: Select named configuration scopes with declared inspection capabilities
## Status
Accepted.
## Date
2026-10-06
## Context
An application can expose its own configuration, PromptKit engine configuration,
and other independently meaningful configurations through the `config` family.
A scope identifies the configuration targeted by an operation. Different
providers may support different inspection operations.
Configuration scopes and command families are distinct. A scope targets
configuration; a family defines operations on its own resources. A library
integration can expose either or both.
## Decision
Use stable semantic scope identifiers. The `app` scope describes application
configuration; an optional `promptkit` scope describes captured engine
configuration. Registration also accepts additional independently meaningful
configuration scopes with unique identifiers.
Each scope declares its available inspection operations and their providers.
Capabilities can include validation, native effective YAML, source inventory,
path explanation, and safe semantic comparison data. CommandKit validates declared
wiring and registers operations supplied by at least one scope under
[ADR 0004](0004-standard-inspection-surface.md). Help and scope discovery report
actual capabilities from metadata, independently of file loading.
Define narrow integration contracts, reusing upstream public types where useful
and introducing CommandKit-owned abstractions where they simplify integration.
Preserve supported upstream values, provenance, and confidentiality. Select
provider representations against concrete integration needs; exact Go declarations
remain API design work under
[ADR 0003](0003-public-facade.md).
`--scope NAME` selects one configuration. `all` denotes all registered scopes for
`config validate` and `config show`; `sources`, `explain`, and `diff` select one.
An aggregate selection retains its all-scope meaning. CommandKit checks operation
availability across the requested selection before acquiring views. An unavailable
operation yields a usage diagnostic identifying the scope and available choices,
so an aggregate result accurately describes the requested coverage.
`show`, `sources`, `explain`, `diff`, and `validate` default to `app`. Explicit
`--scope all` requests aggregate validation or display.
Scope order is `app` first when registered, `promptkit` next when registered,
then additional scopes sorted by identifier. `config scopes` and combined YAML
show use that order. [ADR 0007](0007-native-yaml-reports.md) defines combined
presentation. The [CLI reference](../cli.md) owns implemented selector syntax;
the [consumer guide](../consumers/guide.md) explains acquisition integration.
## Alternatives considered
| Alternative | Analysis |
| --- | --- |
| Require every configuration inspection operation per scope | Gives one capability set. Missing comparison or provenance blocks other useful inspection operations. |
| A separate command tree for each configuration | Makes the target part of the path. Repeats common operations and help. |
| One verb set for every library family | Gives a uniform inventory. Resource domains acquire operations with little useful meaning. |
| Executable names as scope identifiers | Relates scopes to application branding. Shared scripts need application-specific identifiers. |
| Validation defaults to `all` | Checks every scope implicitly, but adding a scope without validation support can make bare validation unavailable. |
| Skip unsupported scopes during aggregate inspection | Produces available reports. The meaning and coverage of `all` become less apparent. |
| Require CommandKit-owned provider types throughout | Gives a uniform abstraction boundary, but can duplicate upstream APIs and require unnecessary adapters. |
## Consequences
A shared configuration family serves multiple configurations while help and
inventory make operation availability explicit. Individual capabilities can be
integrated before other upstream inspection APIs exist. Default and ordering
rules remain observable CommandKit behavior.
An `all` request can report unsupported coverage; default application inspection
remains independent of other scopes' capabilities. Provider design must balance
simple wiring against dependency coupling as concrete integrations are developed.

14
docs/adr/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Architectural decision records
These records explain major structural and ownership decisions. The
[architecture policy](../policy/architecture.md) owns normative boundaries;
GoDoc and the [CLI reference](../cli.md) own implemented contracts.
| ADR | Decision |
| --- | --- |
| [0001](0001-command-dispatch-and-inputs.md) | Dispatch, captured declarations, and typed inputs |
| [0002](0002-public-facade-and-cli-backend.md) | Public facade and internal CLI backend |
| [0003](0003-configuration-scopes.md) | Configuration scopes and declared capabilities |
| [0004](0004-optional-integration-modules.md) | Optional integration modules and upstream ownership |
| [0005](0005-application-alias-routing.md) | Alias routing and canonical command ownership |
| [0006](0006-invocation-reporting.md) | Invocation reporting, outcomes, and presentation ownership |

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +1,415 @@
# Consumer guide # Consumer guide
CommandKit's supported Go API is the root `commandkit` package. Start with an CommandKit is distributed as three Go modules. Start with the core and add an
`App`, register CommandKit's standard inspection families, register application integration module only when the application uses that library. Core and
commands, and call `App.Execute` with the caller's context and streams. The compatible integration command factories return canonical `commandkit.Command` values.
caller retains process startup, signal handling, and exit mapping. Applications may install those alongside application-owned `RoutingRule`
declarations through `commandkit.New` or `App.Register`; routing rules are
installed at the application root, not as group children.
## Module selection
| Import | Use when |
| --- | --- |
| `go.maximumdirect.net/commandkit` | The application needs typed command declarations and common CLI behavior. |
| `go.maximumdirect.net/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
| `go.maximumdirect.net/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
Core v0.4.0 is published. The previously published matched releases are core
v0.3.0, ckconfig v0.2.0, and ckprompt v0.2.0.
The published ckconfig v0.3.0 uses ConfigKit v1.1.0 and does not compose
with ckprompt v0.2.0, which retains ckconfig v0.2.0 and ConfigKit v0.8.0.
Install the current core with `go get go.maximumdirect.net/commandkit@v0.4.0`.
For the older matched integration set, select core v0.3.0 and the corresponding
ckconfig or ckprompt v0.2.0 release together.
The ckconfig v0.4.0 candidate uses published core v0.4.0 and ConfigKit v1.1.0.
The current ckprompt source targets core v0.4.0, ckconfig v0.3.0, ConfigKit
v1.1.0, and PromptKit v0.20.0. Its APIs require the upcoming ckconfig release;
a Go 1.27.0 workspace composes the local modules until that dependency is aligned.
The two integrations are independent optional imports. `ckprompt` uses the
public scope and error contracts from `ckconfig`; importing it does not add the
`config` family. An application that wants both families passes both returned
declarations to core. The [maintained examples](../../README.md#examples) show
core-only, ConfigKit-only, and composed applications.
## Go compatibility ## Go compatibility
The consumer minimum is declared in [go.mod](../../go.mod): Go 1.26.0. Core declares a consumer minimum of Go 1.26.0; both current integration
ReleaseKit v0.3.0 requires that version and is selected as a tool dependency in modules require Go 1.27.0. The root module also
the same module graph. The minimum therefore applies to library consumers even selects ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) as a Go tool, which
though production packages do not import ReleaseKit. Consumers using Go 1.25 makes that tool's module graph part of the root consumer module requirements
need a Go 1.26 or newer toolchain to adopt this revision. even though production packages do not import ReleaseKit. The
[release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
defines the support assumption. Maintainers use Go 1.27.0 for validation and
publication; see the [release procedure](../release.md#release-toolchain).
The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain) ## Core commands
defines support assumptions and versioning for changes to that minimum.
Maintainers select their validation toolchain separately in the
[release procedure](../release.md#release-toolchain). Updating that toolchain's
patch version alone leaves the consumer minimum unchanged.
## A configuration scope Declare typed flags and operands with `CommandSpec[I]`, then capture them with
`NewCommand`. Bindings deliver `Supplied[T]`, which distinguishes an omitted
Use [`NewConfigKitScope`](../../config.go) to adapt one ConfigKit loader. The default from an explicitly supplied empty, false, or zero value.
loader receives the invocation's explicit `Selection`; the application decides
how those supplied config and overlay values map to its inputs.
```go ```go
scope, err := commandkit.NewConfigKitScope[appContext, appConfig]( package example
"app",
"Application settings.", import (
func(ctx context.Context, selection commandkit.Selection, app appContext) (*configkit.Result[appConfig], error) { "fmt"
return loadAppConfig(ctx, selection, app) "strconv"
},
commandkit "go.maximumdirect.net/commandkit"
) )
if err != nil {
return err type GreetInput struct {
Count commandkit.Supplied[int]
}
func Greeting() commandkit.Command {
countDefault := 1
return commandkit.NewCommand(commandkit.CommandSpec[GreetInput]{
Name: "greet", Summary: "Write a greeting.",
Flags: []commandkit.Flag[GreetInput]{
commandkit.ValueFlag(
commandkit.FlagMeta{Name: "count", Usage: "Number of greetings."},
&countDefault, strconv.Atoi,
func(input *GreetInput, value commandkit.Supplied[int]) {
input.Count = value
},
),
},
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
return commandkit.Failure("Could not write the greeting.").WithCause(err)
}
return commandkit.Success()
},
})
} }
err = commandkit.RegisterConfig(app, commandkit.ConfigSuite[appContext]{
Scopes: []commandkit.ConfigScope[appContext]{scope},
})
``` ```
`config show`, `config sources`, and `config explain` use the configuration The application supplies `context.Context`, argv without the executable name,
captured by that load. A scope can support only the operations it provides. and output streams to `App.Execute`; it decides how the returned status maps to
When several scopes share invocation-local application state, create it in process exit. `New` installs generated help/version and caller declarations as
`ConfigSuite.NewContext`; use typed `Selector` bindings to copy only the values one batch. `Register` validates each call as a batch before changing that App.
that apply to each scope. See the [composed example](../../examples/composed/README.md) Invalid declaration errors retain their wrapped identity for `errors.Is` and
for a cache shared by the `app` and `promptkit` scopes. `errors.As`.
## PromptKit configuration and definitions Declarations copy their metadata and slice inputs when captured, retain callback
references, and can be reused across applications. Copies of a typed binding
preserve its identity; a separately constructed lookalike does not. Supplied
values convert before input construction. A command's `NewInput` creates input
only when execution reaches it; help does not construct caller input or run
provider callbacks.
Register `NewPromptKitScope` with the config suite when you want the effective ## Shell completion
PromptKit configuration and its ConfigKit provenance available under `config`.
Register `NewPromptKitSuite` and `RegisterPromptKit` separately to enable
`promptkit show prompt` and/or `promptkit show profile`. Each adapter calls the
owning public inspection method and preserves acquisition diagnostics with a
failed named lookup. Definition inspection does not invoke execution APIs or
resolve provider credentials.
The [composed example](../../examples/composed/README.md) shares its ordinary app and Install `commandkit.Completion("app")` alongside the application's other
engine-loading functions across the configuration and definition suites. It declarations, using the basename operators actually type. The basename accepts
also keeps `session restore` as an application-owned typed command, including letters, digits, underscores, dots, hyphens, and colons, except for a leading
its session data and domain outcome mapping. hyphen. Add a hint to a flag or operand's metadata when a value has useful
static choices or is a filesystem path:
## Typed application commands ```go
commandkit.FlagMeta{
Name: "view",
Completion: commandkit.CompleteChoices(
commandkit.CompletionChoice{Value: "text", Description: "Readable text"},
commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"},
),
}
commandkit.FlagMeta{Name: "config", Completion: commandkit.CompleteFiles()}
commandkit.ArgumentMeta{Name: "directory", Completion: commandkit.CompleteDirectories()}
```
Declare flags, operands, and a typed input struct with `NewCommand`. Bindings Hints are suggestions: converters and input validation still decide which
preserve whether a value was explicitly supplied, so an explicit zero can differ values are accepted. An omitted hint or `NoCompletion()` gives no value
from an omitted default. `RepeatFlag` retains occurrence order. The suggestions. `CompleteChoices()` with no choices also gives no suggestions.
[simple example](../../examples/simple/README.md) combines both features with a typed Declarations retain copies of supplied choices. The [CLI reference](../cli.md#shell-completion)
ConfigKit scope. describes supported shells and loading scripts; the [core example](../../examples/core)
and [composed example](../../integrations/ckprompt/examples/composed) show
installation alongside aliases and integration commands.
## Outcomes and process exit ## Invocation reporting
Handlers return `Success`, `Failure`, or an application-owned `Outcome`. The `Invocation.Reporter()` supplies the reporter for the current handler. Use
caller chooses how a nonzero status maps to process exit; `Execute` never exits `Report()` for formatted substantive values and optional text templates;
the process. It writes CommandKit diagnostics to the supplied error stream and use `Output()` or `RenderOutput()` for deliberate raw output. Use `Warn`, `Info`,
returns the resulting classification and delivery information. Applications `Progress`, and `Debug` for categorized messages. Queue a summary with
keep their domain meanings and output in their own handlers. `AtEnd().Info` when it belongs after the handler returns. The
[CLI reference](../cli.md#reporting) owns flag grammar, visibility, prefixes,
and status behavior.
Exact CommandKit contracts are documented on the exported declarations and Reports use JSON serialization to establish the common value representation
their GoDoc comments. ConfigKit owns configuration decoding and provenance; for YAML and JSON. A text template receives the original value; without one,
consult its [inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/inspection.go). text uses readable scalar or string-list output and YAML for structured values.
PromptKit owns prompt formats and profile resolution; consult its [public The CLI's shared format selection and the writer-level `RenderReport` use the
engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go), same rules. Use `DecodeYAMLReport` only for safe upstream inspection YAML that
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/configuration_inspection.go), must become a report value; ordinary strings are not parsed as YAML.
and [inspection types](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/types.go).
Return terminal text with `Failure(message, details...)`, `Usage(message,
details...)`, or `WithExplanation` on a domain outcome. Attach an optional Go
error separately with `WithCause`. A cause is preserved for inspection, but it
does not choose the outcome or become user-facing text. Rendering callbacks
write to their supplied writer only while the callback runs; they must not
submit another message or start another rendering operation through the same
reporter.
```go
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
if _, err := fmt.Fprintln(invocation.Reporter().Output(), input.Count.Value); err != nil {
return commandkit.Failure("Could not write the greeting.").WithCause(err)
}
return commandkit.Success()
}
```
After `App.Execute`, inspect the returned `Outcome`. Its `Cause` retains the
operation error; `Presentation.Output` and `Presentation.Diagnostics` report
delivery state and the first rendering or write errors for their destinations.
CommandKit does not retain a message transcript. Reporter operations for one
invocation are safe concurrently, but concurrent submission order is not
specified. The caller owns the writers passed to `Execute`; CommandKit does not
close them.
## Application-owned aliases
Pass commands and aliases together as declarations. Integration factories can
be called directly in the installation list, and command-scoped flag aliases
name the canonical command path:
```go
app, err := commandkit.New(
identity,
ckprompt.Command(suite),
commandkit.Alias("pk", "promptkit"),
commandkit.FlagAliasFor("promptkit show prompt", "--fmt", "--format"),
)
```
For a reusable collection, use `[]commandkit.Declaration`. Go does not expand a
`[]commandkit.Command` into `...commandkit.Declaration`, so convert command-only
slices when composing them with routing rules:
```go
commands := []commandkit.Command{ckprompt.Command(suite)}
declarations := make([]commandkit.Declaration, 0, len(commands)+1)
for _, command := range commands {
declarations = append(declarations, command)
}
declarations = append(declarations, commandkit.Alias("pk", "promptkit"))
app, err := commandkit.New(identity, declarations...)
```
New and Register validate each supplied batch before publishing its commands
and routing rules. Factories capture their inputs eagerly; malformed or
conflicting rules are reported at installation. Identical rules are safe to
contribute from reusable helpers. The [core example](../../examples/core) and
[composed consumer](../../integrations/ckprompt/examples/composed) show complete
maintained wiring with ordinary command behavior alongside aliases. The
[Go declarations and GoDoc](../../command.go) own the public API contracts;
the [CLI reference](../cli.md#application-owned-aliases) owns routing and
operator-visible behavior.
## ConfigKit scopes
For an application with one ordinary app configuration loader,
`ckconfig.AppCommand` supplies the state-free convenience declaration:
```go
app, err := commandkit.New(
commandkit.Identity{Name: "Example", Version: "dev"},
ckconfig.AppCommand(loadAppConfig),
)
```
`loadAppConfig` has the signature
`func(context.Context, ckconfig.Selection) (*configkit.Result[T], error)`.
Construction, installation, and help do not call it. `Selection` preserves
whether config and overlay paths were explicitly supplied, including empty
values.
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
multiple named scopes, selector flags, scope-aware input checks, or shared
acquisition state or a custom comparison provider. A declared `Diff` capability
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
ConfigKit-backed implementation. `Selector.AgainstName` opts a selector into an
independent right-side flag. Each comparison side gets its own initialized and
checked input, with only its supplied selectors applied; state is shared by the
invocation. Factory results
remain caller-owned, so separate initialization calls do not guarantee distinct
objects. See
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
timing, omitted-factory zero values (including nil pointers), and ownership.
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
adapts a typed ConfigKit loader without calling it during construction.
The `config` family provides scope discovery, validation, safe effective configuration display,
source inventory, path explanation, and ConfigKit-backed comparison for the
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
owns operator syntax and report details. `Scope.Features` declares optional modes; acquired
`View.Metadata` and `View.Findings` carry native path metadata and diagnostic
records when available. `Findings.Messages` contains presentations associated
with those records, while `Acquired.Diagnostics` carries independent messages.
Set `Scope.Features.Overrides` only when the loader passes
`Selection.Overrides` to ConfigKit as part of its normal load. The integration
parses CLI records but cannot add them to an already-loaded result. The
[CLI reference](../cli.md#configuration-scopes) describes the operator syntax.
## PromptKit configuration, definitions, and requirements
`ckprompt.ConfigScope(loadEngine)` returns a `ckconfig.Scope` for the PromptKit
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
it under `config` with validate/show/sources/explain and native diff operations;
it does not install PromptKit catalog, definition, or requirements commands.
Its override feature is disabled because the released PromptKit configuration
loader does not accept ConfigKit override records.
`ckprompt.FromEngine(loadEngine)` supplies nine callbacks: prompt, profile,
backend, namespace, and collection catalogs; named prompt, profile, and backend
inspection; and native requirements discovery. Concrete request validation,
prepared-request preview, and smoke testing are separate capabilities, added
with `ckprompt.WithRequestValidation`, `ckprompt.WithRequestPreparation`, and
`ckprompt.WithSmokeTesting`. The suite has twelve independently optional
callbacks in total. `ckprompt.Command(suite)` creates only operations whose
callbacks are present, with canonical flags. `ckprompt.Declarations(suite)`
returns the command and its scoped convenience aliases for installation with
other declarations. These capabilities can be installed independently. A
composed suite can enable the additional operations:
```go
suite := ckprompt.FromEngine(loadEngine)
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
suite = ckprompt.WithRequestPreparation(suite, loadEngine, nil)
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
suite.NewInput = newInput
suite.NewState = newState
suite.Selectors = selectors
definitionCommand := ckprompt.Command(suite)
```
A validation-only suite can start empty. With application types named `Input`
and `State`, a nil builder forwards the request fields populated by standard
flags directly to PromptKit:
```go
suite := ckprompt.WithRequestValidation(
ckprompt.Suite[Input, State]{}, loadEngine, nil,
)
validationCommand := ckprompt.Command(suite)
```
If the loader is nil, the returned suite retains a wiring issue for installation
to report, even if capabilities are narrowed later. A builder runs before the
engine loader and receives the seed request, selection, typed input, shared
state, and invocation context. It returns the
concrete request; the adapter does not merge seed fields back afterward. The
loader receives the original selection. Builders should start from the seed,
preserve operator values and bindings, and apply defaults only when a map key is
absent, so explicitly supplied empty values stay meaningful. Applications that
validate and then execute a request are encouraged to share their request
construction logic between those paths.
The catalog and inspection callbacks and the explicit additions are
independently optional. For catalogs without named or requirements inspection,
narrow the suite before command capture:
```go
definitions := ckprompt.FromEngine(loadEngine)
definitions.InspectPrompt = nil
definitions.InspectProfile = nil
definitions.InspectBackend = nil
definitions.InspectRequirements = nil
definitionCommand := ckprompt.Command(definitions)
```
Each selected inspection, requirements, validation, preparation, smoke, or catalog operation
prepares one input, applies its captured selectors, checks that input, and
initializes one state value when those steps are reached. The bundled validation
adapter delegates concrete request preparation to PromptKit and carries its
native errors plus engine-loading diagnostics through the acquisition. It does
not call the model or retain a prepared execution snapshot. See
[ckprompt.Suite's GoDoc](../../integrations/ckprompt/adapters.go) for factory
timing, omitted-factory zero values, and caller-owned objects and effects.
The loader receives invocation context, explicitly supplied selection, input,
and state. `FromEngine` uses PromptKit's public listing and inspection methods,
including `Engine.InspectRequirements`; validation must be opted into separately
and uses `Engine.ValidateRequest`.
`ckprompt.WithRequestPreparation` adds a separate callback backed by
`Engine.Prepare`. Its optional builder receives the seeded request and returns
the complete request to prepare. The resulting native report can contain
rendered application content. See the [CLI reference](../cli.md#promptkit-prepared-request-preview)
for operator behavior.
Application-owned commands can use `ckprompt.NewExecutionFlags`,
`ckprompt.NewResourceFlags`, and `ckprompt.NewPromptInputFlags` to bind the
same canonical request flags into their own input types. Each collection exposes
individual handles and an `All` method, so a command can choose its applicable
subset. The application decides how to apply the resulting values to its request.
`ckprompt.WithNamespaces` enables standard resource namespace selection from
application-supplied defaults. Loaders used with this option should leave view
selection to the adapter. Without it, a loader can supply its own engine view.
Direct callbacks receive the composed selection in their request. The
[CLI reference](../cli.md#resource-namespace-selection) lists applicable flags.
Catalog, named definition inspection, and requirements discovery do not render
messages or load request artifacts. Concrete request validation follows native
PromptKit preparation, which can resolve prompt/profile resources, load
artifacts and schemas, render messages, and perform applicable configuration
and credential checks. It does not make a model call or certify that a later
execution will succeed. Application loaders and direct callbacks retain their
own effects. Smoke testing explicitly invokes one native PromptKit diagnostic
or all four in order. Native inference checks use the configured public
`promptkit.LLMClient`; applications retain ownership of client and probe
configuration. A builder may customize the seeded native request, and its
complete return value is authoritative. The [CLI reference](../cli.md#promptkit-smoke-testing),
[definition inspection](../cli.md#promptkit-definition-inspection), and
[requirements contract](../cli.md#promptkit-requirements) own operator syntax
and reports.
The [composed example](../../integrations/ckprompt/examples/composed/README.md)
combines app and PromptKit config scopes, comparison and catalogs, named and
requirements inspection, request validation, smoke testing, and an
application-owned `session restore` command.
### Namespace views
An application can return a scoped PromptKit view from its own engine loader:
```go
func loadScopedEngine(ctx context.Context, selection ckconfig.Selection, input Input, state State) (*promptkit.Engine, error) {
engine, err := loadEngine(ctx, selection, input, state)
if err != nil {
return nil, err
}
return engine.WithNamespaces(promptkit.Namespaces{
Prompt: "editorial", Profile: "team", Backend: "providers",
})
}
```
These are independent defaults for unqualified caller-supplied prompt, profile,
and backend names; qualified IDs pass through. Stored references and configuration
inspection are unchanged, while namespace and collection inventories remain global.
Catalog `--namespace` filters are separate from these defaults. See the released
[namespace API](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/namespaces.go).
## Outcomes and ownership
Core classifies usage and operational failures while preserving application
domain outcomes and supported error identity. Applications choose domain
classification and status. A terminal explanation is presented separately from
the optional operation cause, while report and diagnostic delivery observations
are available in `Outcome.Presentation`. The caller owns process startup,
signal handling, and exit mapping; `Execute` never exits the process.
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
configuration decoding, validation, and provenance. PromptKit owns prompt
formats, profile resolution, and inspection details. See the
[ConfigKit v1.1.0 source records](https://forge.maximumdirect.net/base-layer/configkit/src/commit/191015e289d7f2b91abb9fdd9c4f2f5cf75b4de9/records.go),
[PromptKit engine methods](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/engine.go),
[configuration inspection](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/configuration_inspection.go),
[smoke diagnostics](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/smoke_diagnostics.go),
and [PromptKit v0.20.0 consumer guidance](https://forge.maximumdirect.net/base-layer/promptkit/src/commit/8ff9aa3efdc9f16b9d2729e0a29ea083eb3c3eaf/docs/consumers/pkg-promptkit.md).

View File

@@ -1,24 +1,30 @@
# Developing CommandKit # Developing CommandKit
CommandKit currently contains adopted repository policies, accepted architectural CommandKit is distributed as a core Go module and two optional integration
decisions, identity and outcome primitives, typed command modules. Core owns captured declarations, typed input preparation, installation,
declarations, an instance-owned registry, a Cobra-backed routing and parsing dispatch, invocation reporting and outcomes, help, and version.
adapter, public `App.Execute` coordination, and generated help/version commands. `integrations/ckconfig` owns ConfigKit scope adaptation and reports;
Configuration suite registration, scope discovery, ConfigKit validation and `integrations/ckprompt` owns PromptKit engine,
reports, PromptKit engine configuration inspection, and optional PromptKit definition inspection, requirements discovery, and concrete request validation.
prompt/profile inspection commands are implemented. The maintained The maintained [consumer guide](consumers/guide.md) links to the three maintained
[consumer guide](consumers/guide.md) links to maintained examples. The consumers.
[README](../README.md) provides project orientation; the The [README](../README.md) provides project orientation; the
[documentation policy](policy/documentation.md#canonical-ownership) assigns [documentation policy](policy/documentation.md#canonical-ownership) assigns
canonical documentation owners. canonical documentation owners.
For application-owned aliases, use the
[CLI reference](cli.md#application-owned-aliases),
[Go declarations and GoDoc](../command.go), and
[ADR 0005](adr/0005-application-alias-routing.md). Keep unfinished work and
capability proposals in [future work](roadmap/future.md).
## Initial orientation ## Initial orientation
1. Inspect the worktree and preserve unrelated staged and unstaged changes. 1. Inspect the worktree and preserve unrelated staged and unstaged changes.
2. Read the [agent policy](policy/agents.md) and select other applicable policies 2. Read the [agent policy](policy/agents.md) and select other applicable policies
using the guide below. using the guide below.
3. Read the owning roadmap for proposals and implementation status, and the 3. Read the owning roadmap for proposals and implementation status, and the
applicable ADR for accepted decisions and rationale. applicable [ADR](adr/README.md) for accepted decisions and rationale.
4. Inspect implementation and relevant consumer contracts when they exist. 4. Inspect implementation and relevant consumer contracts when they exist.
## Policy Reading Guide ## Policy Reading Guide
@@ -48,24 +54,31 @@ canonical documentation owners.
| Task | Read | | Task | Read |
| --- | --- | | --- | --- |
| Implemented CLI behavior | [CLI reference](cli.md) | | Implemented CLI behavior | [CLI reference](cli.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [configuration declarations](../config.go), [PromptKit declarations](../promptkit.go), and applicable ADRs | | Invocation reporting | [CLI reporting contract](cli.md#reporting), [consumer guide](consumers/guide.md#invocation-reporting), core GoDoc, and [ADR 0006](adr/0006-invocation-reporting.md) |
| Application-owned aliases | [CLI reference](cli.md#application-owned-aliases), [Go declarations and GoDoc](../command.go), [consumer guide](consumers/guide.md#application-owned-aliases), and [ADR 0005](adr/0005-application-alias-routing.md) |
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) | | Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) |
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) | | Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-public-facade-and-cli-backend.md) |
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md), and the architecture policy | | Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0002](adr/0002-public-facade-and-cli-backend.md), [ADR 0004](adr/0004-optional-integration-modules.md), and the architecture policy |
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) | | Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
| Completed planning and implementation history | [Retired records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap) | | Unfinished work and capability proposals | [Future work](roadmap/future.md) |
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
| Release tooling | [Maintainer release procedure](release.md) | | Release tooling | [Maintainer release procedure](release.md) |
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) | | Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [possible further efficiency work](roadmap/future.md#possible-further-efficiency-work) |
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) | | Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
| Policy provenance | [Policy provenance](policy/meta.md) | | Policy provenance | [Policy provenance](policy/meta.md) |
Read all applicable rows for cross-cutting work. Go declarations and GoDoc own Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
current exported contracts; the CLI reference owns operator behavior. The current exported contracts; the CLI reference owns operator behavior. The
[maintainer release procedure](release.md) owns current release operations. [maintainer release procedure](release.md) owns current release operations.
Completed roadmaps and implementation evidence remain available in Git history; Completed roadmaps and implementation evidence remain available in Git history,
they are not assignments to restart completed work. The including [PromptKit v0.18.0 adoption and implementation](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap),
[deferred-work record](roadmap/deferred.md) preserves future capability targets [ConfigKit adoption, reporting, release-tooling, and remediation records](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap),
and unselected proposals separately from current contracts. [application-owned alias planning, implementation, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/f42aefe02d84e154855ac922173e6f367180929f/docs/roadmap)
and [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
These records are not assignments to restart completed work. The
[future work record](roadmap/future.md) preserves unfinished work and unselected
proposals separately from current contracts.
## Documentation validation ## Documentation validation
@@ -88,21 +101,31 @@ Sibling-checkout references in retired planning records are historical review
evidence, not build dependencies. Implemented contracts belong in CommandKit evidence, not build dependencies. Implemented contracts belong in CommandKit
under their canonical owners. under their canonical owners.
Run `go test ./...` from the repository root to compile the module and run its The root module does not traverse nested modules. Preserve the ignored `go.work`
deterministic tests. Format changed Go files with `gofmt`. The and `go.work.sum`; use `GOWORK=off GOTOOLCHAIN=local` for dependency preparation
and independent module suites once released requirements are aligned. ckconfig
can be validated independently against released core v0.4.0; ckprompt still
requires the aligned workspace until its ckconfig requirement is updated.
Run `GOWORK=off GOTOOLCHAIN=local go test ./...` separately from each module
directory. From the repository root, a workspace containing all three modules can
also run:
```sh
GOTOOLCHAIN=local go test ./...
GOTOOLCHAIN=local go test ./integrations/ckconfig/...
GOTOOLCHAIN=local go test ./integrations/ckprompt/...
```
Format changed Go files with `gofmt`. The
[maintainer release procedure](release.md) owns candidate preparation and [maintainer release procedure](release.md) owns candidate preparation and
publication commands. publication commands.
## Release tooling development ## Release tooling development
The local publication exercise is opt-in and uses disposable Git repositories. Run the focused launcher wiring tests with `go test ./scripts`. The release
Prepare the module cache before running it; ordinary tests do not bootstrap or profiles select upstream checks of committed inputs independently for each
publish a release: module; these checks do not replace domain review. See the
[release procedure](release.md) for publication, delivery, and recovery.
```sh
GOWORK=off GOTOOLCHAIN=local go mod download
COMMANDKIT_RELEASE_INTEGRATION=1 GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -count=1 ./scripts -run '^TestReleaseIntegration$'
```
Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed Follow the [local commit policy](policy/agents.md#git-commit-policy). Completed
units receive local commits by default, subject to explicit user instructions. units receive local commits by default, subject to explicit user instructions.
@@ -111,16 +134,46 @@ or publishing.
## Current public examples ## Current public examples
The maintained consumers under `examples/` compile with the module suite and The maintained consumers run without external services using the aligned local
run without external services: workspace. The PromptKit smoke examples use local fixtures and the example's
configured client:
```sh ```sh
go run ./examples/simple config show GOTOOLCHAIN=local go run ./examples/core workspace greet --count=0 --tag=first --tag second
go run ./examples/simple greet --count=0 --tag=first --tag second (cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config show)
go run ./examples/composed config validate --scope all --campaign spring (cd integrations/ckconfig/examples/simple && GOTOOLCHAIN=local go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
go run ./examples/composed promptkit show prompt briefing --campaign spring --format yaml (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config validate --scope all --campaign spring --limit=0)
go run ./examples/composed session restore session-42 --campaign spring (cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list backends --namespace team)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list namespaces --kind prompt --kind profile)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit list collections)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prompt briefing --campaign spring --format yaml)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit show prepared briefing --campaign spring --input topic=sprint --profile spring --extra-params '{"campaign":"preview"}')
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit requirements validate briefing --campaign spring --input-file topic=fixtures/topic.txt --var mode=full --input-file notes=fixtures/notes.txt --format yaml)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . promptkit test all --campaign spring --prompt briefing --profile spring)
(cd integrations/ckprompt/examples/composed && GOTOOLCHAIN=local go run . session restore session-42 --campaign spring)
``` ```
The [consumer guide](consumers/guide.md) explains how their public API usage The [consumer guide](consumers/guide.md) explains how their public API usage
fits together. fits together.
## Shell completion validation
The opt-in interactive completion checks require Bash, Zsh, Fish, Python with
`pexpect`, and the Bash completion package. The harness starts a clean Bash
and sources `/usr/share/bash-completion/bash_completion` by default. Set
`COMPLETION_BASH_INIT` to your installation's initialization file if it is
elsewhere. See the [operator setup](cli.md#shell-completion) for ordinary use.
```sh
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionShells$' ./internal/cobra
COMMANDKIT_COMPLETION_SHELLS=1 go test -count=1 -run '^TestCompletionConsumerInShells$' .
```

View File

@@ -1,48 +1,123 @@
# Internal package overview # Internal package overview
CommandKit ships one public package, `commandkit`, and one internal package, CommandKit's core module contains the public `commandkit` facade, the private
`internal/cobra`. This inventory describes the current implementation; the neutral metadata package, and the internal Cobra adapter. Two nested Go modules
[architecture policy](../policy/architecture.md) owns normative boundaries and own the optional ConfigKit and PromptKit integrations. The
[ADR 0003](../adr/0003-public-facade.md) records the public package boundary. [architecture policy](../policy/architecture.md) owns normative boundaries;
this page inventories the implemented owners.
## `commandkit` The core, ckconfig, and ckprompt modules declare
`go.maximumdirect.net/commandkit`, `go.maximumdirect.net/ckconfig`, and
`go.maximumdirect.net/ckprompt`, respectively.
The root package declares the public API and owns the instance registry, typed ## Root `commandkit`
command input preparation, invocation coordination, standard handlers, and
provider adapters. Configuration reports adapt ConfigKit's public records and
writers. PromptKit scope and definition handlers use PromptKit's public
inspection methods and map their results to CommandKit-owned reports.
The root lazily caches one immutable neutral bridge tree per `App`, invalidating The root facade owns captured canonical command declarations, application-owned
it after successful nonempty registration. For each `Execute`, it prepares one routing rules, binding conversion, invocation coordination, the per-App
adapter session and reuses that session for parsing, help, and usage. If a registries, standard help/version commands, shared report rendering, invocation reporting, and outcome
callback registers commands during execution, metadata-dependent presentation finalization.
refreshes without repeating input callbacks. The root still prepares typed Its dependency graph has no ConfigKit or PromptKit integration dependency.
input, invokes application/standard handlers, and coordinates outcomes and Applications provide typed callbacks and install routing rules alongside the
diagnostic delivery. Application callbacks and their supported error identity complete canonical command tree through the same batch API.
are handled in the root through its typed contracts.
Configuration registration owns and normalizes its declarations, then prepares Canonical commands own handlers and parsing/help metadata; routing rules resolve
ordered scopes, name lookup, installed operations, and a discovery DTO. Command entered command paths and flag spellings to those canonical records without
handlers reuse that private metadata; each invocation still creates its context adding command nodes or handlers. The shared command compiler prepares supplied
and invokes acquisition only for selected scopes. values before constructing input. The batch installer validates retained
issues, placement, and collisions across both registries before publishing
either. Each invocation uses one routing snapshot and backend session; a
successful registration invalidates the snapshot for a later invocation. The
root package owns app input and callback execution, not provider configuration
semantics.
Each execution creates one reporter backed by the caller's result and diagnostic
writers. The Cobra adapter returns parsed values, flag spellings, and reporting
flag occurrences. Core reduces those occurrences in entered order after route
resolution, applies the final mode and progress selection, and presents any
collected parser events through that selection. The selected mode therefore
applies to command execution, help, and version; routing and parsing failures
remain visible in every mode.
The reporter owns invocation-scoped visibility, message formatting, managed
report rendering, delivery observations, and finalization. It presents queued
messages after the handler returns, then a failed outcome's explanation, and
closes its handles before `Execute` returns. It records rendering and write
errors separately for result and diagnostic destinations; the returned
`Outcome.Presentation` exposes those observations independently from the
operation cause. The reporter does not retain a transcript and does not close
or flush caller-owned writers.
Internally, one reporting-state mutex guards visibility, queued messages,
destination state, and delivery errors while serializing reporter operations
and writes. Rendering callbacks execute within that coordination boundary, so
they must not recursively submit or render through the same reporter. This is
an implementation detail supporting the callback contract; the mutex does not
coordinate writes made outside the reporter.
## `internal/alias` and `internal/commandpath`
`internal/commandpath` provides shared path parsing and token operations used by
declaration capture, routing, and backend selection. `internal/alias` compiles
application routing rules, resolves command prefixes while retaining token
provenance, and validates command-route termination. Neither package owns
handlers, application factories, or backend types.
## `internal/commandmeta`
This package owns the private neutral command, flag, argument, parsed-value, and
failure records shared between the public facade and backend adapter. It does
not import Cobra or optional integration modules. The facade and adapter
exchange these records without exporting backend-specific types.
## `internal/cobra` ## `internal/cobra`
This package owns the Cobra-specific command tree, argument/flag parsing, This package owns the Cobra command tree, argument/flag parsing, and help
and help rendering. A session owns one mutable backend tree for an invocation; rendering. A session owns the mutable backend tree for one invocation; parsing
parsing and presentation reuse it. Backend commands map directly to neutral and presentation reuse that session. The adapter returns raw values and neutral
declarations, and Cobra parent links reconstruct only the selected command path. syntax failures to core. It does not own the registry, typed input preparation,
The adapter returns raw parsed values and neutral syntax-failure data consumed integration declarations, provider acquisition, or native report rendering.
by the root package. It does not import `commandkit` and does not own registry Cobra and pflag imports remain inside this package.
policy, public command declarations, provider loading, or standard inspection
report rendering.
## External implementation boundaries ## Optional modules
ConfigKit owns typed configuration loading, source history, validation, `integrations/ckconfig` owns ConfigKit adaptation, scope capture, the standard
resolution, and path explanations. PromptKit owns captured engine `config` command family, and native validate/show/sources/explain/diff behavior.
configuration, prompt definitions, profile inheritance, and named inspections. It depends on the public core facade and ConfigKit; it does not depend on
CommandKit depends on their public module APIs and does not maintain parallel PromptKit. Each selected scope or comparison side receives an independently
schemas. Cobra and pflag are confined behind the internal adapter; YAML report initialized input, while one suite-owned state value is shared by an invocation's
encoding is performed by the root package. acquisitions or comparison callback.
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope
adaptation and native comparison, plus optional prompt/profile/backend/namespace/
collection catalogs, named inspection, native requirements discovery,
concrete request validation, prepared-request preview, and native smoke testing.
Validation, preview, and smoke testing delegate to PromptKit and are independently
opted into a suite. Smoke
operations use the application's native engine and its configured client or
probes. The integration uses public core and ckconfig contracts plus PromptKit's
public APIs. It does not add a configuration family when its declaration is
installed; callers compose `ckprompt.ConfigScope` with `ckconfig.Command`
separately when they want configuration operations alongside PromptKit
commands.
The three maintained consumers live at `examples/core`,
`integrations/ckconfig/examples/simple`, and
`integrations/ckprompt/examples/composed`. The
[consumer guide](../consumers/guide.md) links to those examples and explains the
public wiring. Run tests from each module; root `./...` does not traverse the
nested module directories. The [development guide](../development.md) gives the
workspace test commands. Workspace builds do not establish independent module
graph isolation.
## Upstream implementation boundaries
ConfigKit owns configuration decoding, validation, source history, and path
explanations. PromptKit owns its captured engine configuration, prompt
definitions, profile inheritance, named inspection, requirements discovery,
and concrete request validation, and native smoke-test semantics. CommandKit
adapts those public contracts without maintaining parallel schemas. Explicit
native smoke operations may use the configured client or probes; other
inspection, catalog, and request-validation paths do not make model calls.
Native request validation may load resources and artifacts, render messages,
and perform applicable configuration or credential checks through PromptKit.
Application loaders and domain behavior remain application owned.

View File

@@ -1,57 +1,5 @@
# Open Source Attribution Notices # Open Source Attribution Notices
## ConfigKit
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.5.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd).
## PromptKit
Selected module: `gitea.maximumdirect.net/eric/promptkit` at `v0.12.0`.
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3).
## PromptKit OpenRouter Catalog
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3` at `v3.20260930.0` (transitive dependency).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0).
## PromptKit Rakestrawhome Catalog
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3` at `v3.20260930.0` (transitive dependency).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d).
## Cobra ## Cobra
Selected module: `github.com/spf13/cobra` at `v1.10.1`. Selected module: `github.com/spf13/cobra` at `v1.10.1`.
@@ -149,31 +97,6 @@ License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049). [Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
## JSON Schema
Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2` (transitive dependency).
SPDX-License-Identifier: Apache-2.0
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
[LICENSE](https://github.com/santhosh-tekuri/jsonschema/blob/29cbed948d24a04700eb94436416b18a07953b71/LICENSE).
[Source](https://github.com/santhosh-tekuri/jsonschema/tree/29cbed948d24a04700eb94436416b18a07953b71).
## Go text
Selected module: `golang.org/x/text` at `v0.14.0` (transitive dependency).
Copyright (c) 2009 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/LICENSE) |
[PATENTS](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/PATENTS).
[Source](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353).
## gocheck ## gocheck
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency). Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
@@ -196,8 +119,9 @@ Material: `docs/policy/meta.md`, `docs/policy/agents.md`,
`docs/policy/documentation.md`, `docs/policy/release.md`, and `docs/policy/documentation.md`, `docs/policy/release.md`, and
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph `docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
this repository. The release policy uses baseline this repository. The documentation policy uses baseline
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline `beb5f191470a9daef2383981a6bfd02232490ab4`; the release policy uses
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses
`d84a38ebab26f048cafbe59a2246700c35d29843`. `d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -208,8 +132,64 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) | [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348). [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4).
## ReleaseKit
Selected module: `go.maximumdirect.net/releasekit` at `v0.9.0` (selected Go tool; not a runtime import).
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
License and notices: [LGPL v3.0](../../LICENSE) |
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/LICENSE).
[Source](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4).
## Goldmark
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
Copyright (c) 2019 Yusuke Inuzuka
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
## Go modules
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
## Go tools
Selected module: `golang.org/x/tools` at `v0.49.0` (ReleaseKit tool graph dependency).
Copyright (c) 2009 The Go Authors. All rights reserved.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
[LICENSE](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58/LICENSE).
[Source](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58).

View File

@@ -56,7 +56,7 @@ invariant, not from the convenience of the selected implementation. Explain the
specific failure it prevents and why a less restrictive implementation cannot specific failure it prevents and why a less restrictive implementation cannot
satisfy the contract. satisfy the contract.
The [deferred-work record](../roadmap/deferred.md) distinguishes future proposals The [future work record](../roadmap/future.md) distinguishes future proposals
from accepted contracts. Do not approve proposed commands, grammar, statuses, or from accepted contracts. Do not approve proposed commands, grammar, statuses, or
illustrative API declarations illustrative API declarations
merely by repeating them in implementation or tests. Cobra/pflag defaults do not merely by repeating them in implementation or tests. Cobra/pflag defaults do not

View File

@@ -2,16 +2,18 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-08 **Last Updated**: 2026-11-06
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md) **Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
## Purpose And Ownership ## Purpose And Ownership
CommandKit is an opinionated Go library for standard inspection commands, CommandKit is an opinionated Go library for standard inspection commands,
consistent CLI syntax, and reduced application command infrastructure. This consistent CLI syntax, and reduced application command infrastructure. It provides
policy owns normative responsibility boundaries, dependency direction, and useful operator access to capabilities owned by its integrated libraries through
architectural constraints. ADRs own decision rationale; the shared command vocabulary, registration, selection, coordination, presentation,
and invocation outcomes. This policy owns normative responsibility boundaries,
dependency direction, and architectural constraints. ADRs own decision rationale; the
[implementation roadmaps](../development.md#task-specific-reading-guide) own [implementation roadmaps](../development.md#task-specific-reading-guide) own
proposals, sequencing, and implementation status. proposals, sequencing, and implementation status.
@@ -21,6 +23,22 @@ Exact exported contracts belong in Go declarations and GoDoc; implemented
operator behavior belongs in the CLI reference. Do not describe planned packages operator behavior belongs in the CLI reference. Do not describe planned packages
or APIs as existing implementation. or APIs as existing implementation.
## Ownership Boundaries
Applications and integration handlers choose the operation and report content;
CommandKit core supplies the shared CLI mechanics. Integrated libraries retain
ownership of their domain behavior. These boundaries govern current and planned
capabilities; they do not imply that a planned API is implemented.
| Area | CommandKit Owns | CommandKit Does Not Own |
| --- | --- | --- |
| **CLI output — core** | Format selection, serialization, template execution, and output delivery | Choosing report content or detail level; authoring application-specific text templates |
| **PromptKit/ConfigKit integrations** | Standard commands, flags, and report content that expose useful operator tasks through upstream public APIs | Helpers or convenience interfaces for application-internal use of upstream APIs |
| **CLI inputs** | CLI syntax, parsing, typed bindings, presence tracking, and usage diagnostics | Application business rules or upstream domain-validation semantics |
| **Upstream behavior** | Selecting and invoking supported capabilities, adapting their results, and preserving their meaning | Reimplementing upstream configuration resolution, prompt preparation, validation, or other domain algorithms |
| **Outcomes and errors — core** | Carrying and presenting selected outcomes; handling CommandKit's own parsing, rendering, and delivery failures | Deciding application recovery policy or terminating the process |
| **Application lifecycle** | Command dispatch and invocation reporting lifecycle | Application startup, engine construction, credential provisioning, signal handling, or long-lived application state |
## Success Criteria ## Success Criteria
Evaluate designs and adoption against both criteria: Evaluate designs and adoption against both criteria:
@@ -40,40 +58,81 @@ Replacing parsing and dispatch plumbing with equally repetitive registration or
callback bookkeeping does not satisfy infrastructure reduction. Keep enough callback bookkeeping does not satisfy infrastructure reduction. Keep enough
flexibility to express genuine application requirements within one command model. flexibility to express genuine application requirements within one command model.
## Go Types And Facilities
CommandKit is a Go library for Go applications. Its APIs and implementation use
ordinary Go types, language features, and standard-library facilities. Their
documented semantics and representational limits are accepted design constraints.
CommandKit does not introduce substitute type systems or compatibility machinery
merely to circumvent those limits.
Ordinary parsing, application-defined domain types, and necessary serialization
adapters remain appropriate. Preserve values that the selected Go types can
represent; accepting their limits does not justify accidental rounding or
truncation during conversion. Keep CLI boundary conversions small and focused,
without turning them into general-purpose type-conversion frameworks.
Specific numeric parsing and report serialization contracts remain separate.
A CLI parser's selected numeric types do not establish a blanket restriction on
application-supplied values or silently redefine their serialization contracts.
GoDoc and CLI documentation own implemented behavior; roadmaps own pending work.
## Command And Application Ownership ## Command And Application Ownership
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected [ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
CommandKit-owned dispatcher, per-instance registry, and typed-input model. dispatcher, captured declarations, installation boundary, and typed-input model.
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
diagnostics, and the handlers and observable contracts of its standard families. diagnostics, and the handlers and CLI contracts of its standard families.
The integrated libraries own the domain semantics exposed through those handlers.
Applications register domain commands outside those families and own their Applications register domain commands outside those families and own their
meaning, effects, domain validation, artifacts, and operational outcomes. meaning, effects, domain validation, artifacts, and operational outcomes.
Namespaces provide the ownership boundary: standard `config` and an installed Namespaces provide the ownership boundary. Core construction reserves help and
`promptkit` family remain CommandKit-owned. CommandKit reports registration errors version. Optional standard families, including config and promptkit, own their
for attempts to replace their handlers or add private operations beneath them. root namespace when installed; importing an integration or declaring an absent
family claims no namespace. An application may occupy an uninstalled family name.
Installing the standard family then reports a collision, as does application
registration after the standard family is installed. Neither order merges groups
or replaces handlers. Installed standard families cannot acquire private
application operations beneath them. Standard-family declarations are root-only;
nesting a real family beneath another group reports an assembly error.
Applications own their executable entry points, context creation, signals, Applications own their executable entry points, context creation, signals,
stream selection, runtime construction, and final process exit. CommandKit stream selection, runtime construction, and final process exit. CommandKit
returns invocation outcomes and must not terminate the process. Its common returns invocation outcomes and must not terminate the process. Its common
mechanics apply to custom commands without taking ownership of their domain mechanics apply to custom commands without taking ownership of their domain
semantics or success-report formats. semantics or report content.
CommandKit's grammar applies to commands using its dispatcher and binding APIs. CommandKit's grammar applies to commands using its dispatcher and binding APIs.
Its documentation recommends the same grammar and vocabulary for application-owned Its documentation recommends the same grammar and vocabulary for application-owned
commands, including those outside its dispatcher, under commands, including those outside its dispatcher. The
[ADR 0006](../adr/0006-command-grammar.md). [CLI reference](../cli.md#registered-command-syntax) owns implemented grammar.
CommandKit preserves application-supplied outcome classifications and supported
error identity. It assigns responsibility for its diagnostics, avoids duplicate
presentation through its own paths, and reports write failures. Caller callbacks
retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.md)
describes the outcome and delivery model.
Use explicit instance construction and composable command registration, not Use explicit instance construction and composable command registration, not
package-global or import-time registries. One definition supplies routing, package-global or import-time registries. Applications and integrations supply
parsing, and help metadata. Duplicate paths and attempts to replace reserved captured canonical command declarations through the core installation API.
commands report errors rather than letting registration order select a winner. Declaration factories capture metadata, bindings, and callback references eagerly;
construction errors remain attached for installation to report. Capture the
metadata collections needed to keep later edits to a source declaration from
changing a returned command. Application callback objects retain their ownership.
Canonical command declarations own handlers, parsing metadata, and help
metadata. Applications may provide separate routing rules in the same
installation batch; these rules resolve invocations to canonical commands and
do not add command nodes, handlers, or flags.
Validate each installation batch before mutating the instance registry. A failed
construction yields no usable application; a failed later registration leaves the
existing registry intact. Separate successful registration calls are not one
transaction. Duplicate canonical paths and attempts to replace reserved commands
report errors rather than letting registration order select a winner. Identical
routing rules are idempotent; competing targets for the same source and scope
report an error. Command-scoped flag routing refers to an exact canonical
command path.
Explicit absent-command declarations contribute no commands, help entries, or
namespace claims; attached errors are reported before skipping absence. An invalid
zero declaration is not absence. Integrations define their own empty-suite
semantics and retain invalid wiring errors even when capabilities are narrowed.
Standard families are assembled from declared provider capabilities. Each family Standard families are assembled from declared provider capabilities. Each family
specifies its own resources and supported operations; shared verbs retain specifies its own resources and supported operations; shared verbs retain
@@ -95,17 +154,94 @@ inspection/execution loading and focused acquisition callbacks are recommended
integration practices. Independent consumer use of other CLI frameworks remains integration practices. Independent consumer use of other CLI frameworks remains
application-owned. application-owned.
## Invocation Reporting And Outcomes
Use one reporting facility per invocation for command results, immediate messages,
and deferred messages. Core owns shared visibility, formatting, delivery, and
finalization. Applications and integrations choose their report content and
messages; callers choose the destinations.
[ADR 0006](../adr/0006-invocation-reporting.md) records this reporting and outcome
ownership model. Its accepted structured-report extension remains planned in the
[adoption roadmap](../roadmap/promptkit-v0.20.0.md#shared-core-report-output-prerequisite).
For that shared report handoff, core owns format selection, serialization,
template execution, and delivery. Applications and integrations select logical
report content and detail level, and author any report-specific text templates.
Changing format must not implicitly change that content or detail level.
Representations and encoders are core implementation choices; commands should
not maintain parallel rendering pipelines or a separate report tree per format.
Use ordinary Go report values and thin projections where upstream data needs
adaptation. Prefer standard serialization and template facilities to a new public
document model, tag convention, or template language. Shared rendering serves
both invocation reporting and direct writer use, while invocation delivery keeps
the existing visibility, outcome, and presentation-failure lifecycle. Exact
serialization conventions, format options, and API signatures belong in the
roadmap until implemented, then in their API and CLI contracts.
Applications decide how to respond to domain-specific errors. CommandKit may
provide shared helpers for carrying, inspecting, and presenting errors while
leaving those decisions with the application. Supplying an error cause alone
does not select an outcome or generate an operator-facing message. CommandKit
handles its own errors, including routing, parsing, and presentation failures.
Preserve application-defined domain classifications and supplied error identity.
Report rendering and delivery failures separately from the command's explanation
and cause, applying the [CLI reporting contract](../cli.md#presentation-failures).
Intentionally suppressed output is not a delivery failure. Present terminal
failure explanations after normal reporting finishes, avoid duplicate presentation
through core's own paths, and retain the explanation and any supplied cause in
the returned outcome.
Keep reporting state scoped to the invocation's presentation and returned outcome.
Logging and message history remain application concerns.
## Handler Inputs And Invocation State ## Handler Inputs And Invocation State
Handlers receive typed input structs with explicit flag and operand bindings. Handlers receive typed input structs with explicit flag and operand bindings.
Use presence-aware fields where omission matters; preserve explicitly supplied Use presence-aware fields where omission matters; preserve explicitly supplied
empty, false, and zero values independently of parser defaults. Context, streams, empty, false, and zero values independently of parser defaults. Context, streams,
and execution dependencies are separate from CLI inputs. and execution dependencies are separate from CLI inputs. Integration suites keep
their typed CLI input separate from shared acquisition state. For a configuration
operation, initialize and populate each selected scope's input independently,
applying only its applicable selector bindings; initialize shared state once for
the selected inspection invocation. Configuration input validation receives the
selected scope identity together with its applicable typed input. Named definition
inspection uses one input and one shared state. Complete applicable preflight
and input checks before shared state initialization and provider acquisition.
Factories need not run after a failure and remain uninvoked during construction,
installation, help, version, and scope discovery.
For two-selection configuration comparison, prepare and check each side's input
separately with its scope identity and selection, applying only the selectors
supplied for that side. Finish both checks before initializing shared
state once and invoking the integration-owned pair provider. The
[consumer guide](../consumers/guide.md#configkit-scopes) and linked GoDoc own the
provider lifecycle; the [CLI reference](../cli.md#compare-configuration-selections)
owns current selector behavior. Required provider wiring follows declared
capabilities; a comparison-only scope does not require an unrelated ordinary
acquisition callback.
Omitted input and state factories supply the respective type's zero value,
including nil for pointers. Caller factories and callbacks retain responsibility
for objects they return or reference; do not promise arbitrary deep copies,
repair caller-created aliases, or silently allocate arbitrary pointer types.
Core-owned parsed collections stay independent where values are applied to
separate inputs.
Provide typed binding composition through supported public core contracts when
integrations need to apply captured converted values to selected inputs. Preserve
binding identity and presence without exposing private setters or backend state.
Required-value checks apply to the selected binding subset. Missing required
operator input is a usage failure; invalid binding selection or destination is a
wiring failure with operational classification when detected during invocation.
Metadata defects detectable during assembly remain installation errors. Input
composition does not create a second parser or a raw-argument handler model.
CommandKit keeps parsed values, collections, and presence from one invocation out CommandKit keeps parsed values, collections, and presence from one invocation out
of subsequent runs. Storage allocation and safe reuse are implementation choices; of subsequent runs. Storage allocation and safe reuse are implementation choices;
rebuilding backend state is the initial strategy under the [internal overview](../internal/overview.md#internalcobra) describes the
[ADR 0002](../adr/0002-cobra-backend.md). Caller-owned closures and runtime state current backend lifecycle. Caller-owned closures and runtime state
retain their own lifecycle. Concurrent dispatch is a separate contract. retain their own lifecycle. Concurrent dispatch is a separate contract.
Applications may interpret parsed domain values and provide domain value parsers Applications may interpret parsed domain values and provide domain value parsers
@@ -114,10 +250,22 @@ Keep input declarations available without application initialization.
## Public Facade And Dependency Direction ## Public Facade And Dependency Direction
[ADR 0003](../adr/0003-public-facade.md) records the root `commandkit` facade and [ADR 0004](../adr/0004-optional-integration-modules.md) selects separate optional
internal adapter boundary. The root is the sole initial supported public package integration modules with their own public package contracts. The facade and
and defines CommandKit's public named types. Additional public packages require a backend isolation principles in
deliberate decision based on a durable consumer responsibility. [ADR 0002](../adr/0002-public-facade-and-cli-backend.md) apply within those module
boundaries.
The core owns command declarations, registration, bindings, dispatch, help/version,
shared syntax, reporting mechanics, diagnostics, and outcomes. Each integration
owns its standard handlers, provider contracts, report content, adapters, and
integration-specific public types. Integrations expose operator-facing CLI tasks;
they do not provide convenience interfaces for application-internal upstream APIs.
Dependencies point from integrations toward the core; an integration may
depend on another integration when its responsibility requires that relationship.
Document those dependencies. Core public signatures, implementation imports, and
module requirements remain independent of optional integration libraries,
including dependencies introduced through tests, examples, or tools.
Public handles retain private implementation state and expose explicitly selected Public handles retain private implementation state and expose explicitly selected
methods. Any forwarding and conversions must preserve type identity, value methods. Any forwarding and conversions must preserve type identity, value
@@ -125,9 +273,9 @@ ownership, callback lifetime, presence, and supported public error semantics. Do
not alias public types to internal or backend types, anonymously embed those types not alias public types to internal or backend types, anonymously embed those types
to promote methods, or expose implementation bridge methods. to promote methods, or expose implementation bridge methods.
Straightforward implementation may live in the root package without expanding Straightforward implementation may live in an owning public package without
its exports. Introduce internal packages, interfaces, and conversions where they expanding its exports. Introduce internal packages, interfaces, and conversions
simplify implementation or protect the backend boundary. Package organization and where they simplify implementation or protect the backend boundary. Package organization and
the amount of internal layering follow implementation needs. the amount of internal layering follow implementation needs.
Public signatures, fields, callbacks, constraints, and method sets must not expose Public signatures, fields, callbacks, constraints, and method sets must not expose
@@ -138,17 +286,21 @@ retain their documented handling; this boundary does not require inspecting or
transforming arbitrary application objects. transforming arbitrary application objects.
Standard contracts such as `context.Context` and `io.Writer` remain appropriate. Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
Public types from integration libraries, such as ConfigKit and PromptKit, may be Upstream public types may be selected as deliberate dependencies in the
selected as deliberate dependencies. Application-owned input types remain integrations that own them; that permission does not justify optional library
application-owned. Do not expand exports merely to simplify internal assembly. dependencies in the core. Core owns shared presentation categories,
and integrations adapt upstream diagnostics while preserving selected outcome
and error semantics. Application-owned input types remain application-owned.
Expose narrow core composition contracts for demonstrated integration needs,
without exporting private assembly or backend structures.
## Backend And Library Integration ## Backend And Library Integration
[ADR 0002](../adr/0002-cobra-backend.md) selects Cobra with pflag as the sole [ADR 0002](../adr/0002-public-facade-and-cli-backend.md) selects Cobra with pflag
initial backend. Their types and operations stay inside an internal adapter that as the sole initial backend. Their types and operations stay inside an internal
translates CommandKit-owned definitions into backend machinery. Registry logic, adapter that translates CommandKit-owned definitions into backend machinery.
standard handlers, and shared internal representations remain independent of Registry logic, standard handlers, and shared internal representations remain
backend-specific structures. independent of backend-specific structures.
Reuse backend routing and parsing mechanisms, adapting them to selected Reuse backend routing and parsing mechanisms, adapting them to selected
CommandKit semantics. Do not replicate Cobra's entire API through renamed CommandKit semantics. Do not replicate Cobra's entire API through renamed
@@ -156,22 +308,48 @@ wrappers. Introduce narrow internal interfaces where actual interactions justify
them; a public backend selection or plugin framework is not part of the design. them; a public backend selection or plugin framework is not part of the design.
ConfigKit and PromptKit own configuration composition, provenance, inspection ConfigKit and PromptKit own configuration composition, provenance, inspection
semantics, secret classification, and native rendering. Applications provide semantics, secret classification, and their native inspection representations.
schemas, acquisition, domain resolution and validation, and construction of Applications provide schemas, acquisition, domain resolution and validation,
inspection providers. CommandKit exposes those capabilities without implementing and construction of inspection providers. CommandKit exposes those capabilities through supported
a parallel configuration or prompt engine. Use independently released public upstream APIs. The public contract of the adopted library version governs their
contracts; do not import sibling internal packages or depend on adjacent checkouts. meaning and disclosure rules. When CommandKit adopts an upstream revision,
changes to those semantics flow through its integration. Adaptation and
documentation follow the adopted contract; descriptions of upstream behavior
do not establish a separate, fixed CommandKit specification of that behavior.
CommandKit owns presentation and may summarize or transform returned safe data
for different operator needs. Reports can evolve in format, organization, and
detail while respecting upstream disclosure rules and the evidence supplied by
the capability. Exact report contracts belong in the CLI reference when
implemented; changes to CommandKit's published contracts follow the release
policy.
Use independently released public contracts; do not import sibling internal
packages or depend on adjacent checkouts.
Use narrow integration contracts, reusing upstream public types where useful and Use narrow integration contracts, reusing upstream public types where useful and
introducing CommandKit-owned abstractions where they simplify integration. introducing CommandKit-owned abstractions where they simplify integration.
Concrete integration needs guide provider representations under Concrete integration needs guide provider representations under
[ADR 0009](../adr/0009-configuration-scopes.md). [ADR 0003](../adr/0003-configuration-scopes.md).
Native report writers and safe inspection data preserve owning-library semantics. Use upstream safe inspection data to preserve owning-library meaning and
Loading all selected views before report output is a `config show` decision under disclosure rules under [ADR 0004](../adr/0004-optional-integration-modules.md).
[ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define Do not reconstruct inspection reports from mutable runtime configuration or source
their own output timing. Semantic comparison uses supported safe snapshots and files when the upstream inspection API supplies captured, safe evidence. Explicit
reports sensitivity gaps under [ADR 0008](../adr/0008-semantic-configuration-diff.md). adaptation into ordinary report values must preserve the selected evidence and
report conversion failures rather than silently changing its meaning.
CommandKit parses and forwards supplied CLI selections and overrides through the
declared acquisition boundary. Upstream libraries retain path validity,
resolution, precedence, conflict handling, and domain validation. Preserve their
errors instead of inventing a parallel override policy or retrying with altered
values. Application loading and application-owned overrides retain their own
responsibilities.
The [CLI reference](../cli.md#show-effective-configuration) owns current report
formats and operation-specific preparation and delivery timing. Semantic
comparison exposes the adopted upstream comparison API and carries its evidence
through the provider boundary; it does not introduce a CommandKit equality engine.
## Scope And Non-goals ## Scope And Non-goals
@@ -182,7 +360,7 @@ recommended for application-owned commands; their names and domain behavior
remain application choices. remain application choices.
Accepted ADRs and current CLI/API owners define selected contracts; the Accepted ADRs and current CLI/API owners define selected contracts; the
[deferred-work record](../roadmap/deferred.md) preserves remaining proposals. [future work record](../roadmap/future.md) preserves remaining proposals.
Backend defaults and incidental consumer behavior do not establish new contracts. Backend defaults and incidental consumer behavior do not establish new contracts.
A replacement backend must preserve published CLI behavior or follow deliberate compatibility A replacement backend must preserve published CLI behavior or follow deliberate compatibility
changes under the release policy. changes under the release policy.

View File

@@ -2,9 +2,9 @@
**Base Policy Version:** 1.11 **Base Policy Version:** 1.11
**Last Updated**: 2026-10-06 **Last Updated**: 2026-11-07
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md) **Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/beb5f191470a9daef2383981a6bfd02232490ab4/docs/policy/documentation.md)
## Purpose ## Purpose
@@ -86,7 +86,7 @@ secret values.
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. | | Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. | | Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. | | Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. | | Future work and implementation status | `docs/roadmap/` | Proposed, accepted, or deferred work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. | | Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. | | Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
@@ -147,6 +147,17 @@ mechanisms only.
## Architecture Decision Records ## Architecture Decision Records
ADRs record major architectural decisions, such as responsibility and ownership
boundaries, large-scale architectural features, or significant architectural
invariants, and the reasons for choosing them. Routine choices, implementation
or presentation decisions, and rejected suggestions do not warrant an ADR
unless they otherwise represent a significant architectural decision. Ask
whether changing the detail would alter the architecture or represent reasonable
evolution within it. Details that can evolve within the chosen architecture
belong in API or CLI contracts, implementation documentation, or roadmaps.
Describe upstream behavior as context without turning it into an independent
project guarantee.
Use sequentially numbered ADR filenames such as Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format: `0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
@@ -160,8 +171,8 @@ Use sequentially numbered ADR filenames such as
Treat the decision content of an accepted ADR as immutable. When a decision Treat the decision content of an accepted ADR as immutable. When a decision
changes, create a new ADR and update the earlier ADR's status to superseded. changes, create a new ADR and update the earlier ADR's status to superseded.
Rejected architectural alternatives belong in the ADR; rejected product ideas
belong in the roadmap. Roadmaps should preserve plausible future work, not rejected product ideas.
## Maintenance ## Maintenance

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.6 **Base Policy Version:** 1.6
**Last Updated**: 2026-10-08 **Last Updated**: 2026-10-11
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md) **Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
@@ -16,17 +16,22 @@ Adopt stronger verification or reproducibility requirements through an explicit
project-specific decision justified by the intended audience and support needs. project-specific decision justified by the intended audience and support needs.
The repository-specific commands belong in `docs/release.md`. Historical release The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted summaries belong in each released module's `docs/releases/` directory. The
policy and link to it rather than introduce additional release requirements. procedure must agree with the adopted policy and link to it rather than introduce
additional release requirements.
## CommandKit Release Scope ## CommandKit Release Scope
CommandKit releases source-only Go modules. Standard CLI behavior is exposed by CommandKit independently versions three source-only Go modules: the root core,
consuming applications; CommandKit does not declare binary deliverables or a `integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
cross-platform build matrix. ReleaseKit v0.3.0 is selected as the maintainer exposed by consuming applications; CommandKit does not declare binary
publication tool. The [release procedure](../release.md) owns its source-only deliverables or a cross-platform build matrix. ReleaseKit v0.9.0 is selected as
workflow and repository-specific commands; the integration adds no application the maintainer publication tool. Maintainers may separately deliver page-only
command family or runtime ReleaseKit import. Gitea releases from tagged source; delivery has its own outcome and URL. The
[release procedure](../release.md) owns the selected-module workflow, release
order, and repository-specific commands;
the integration adds no application command family or runtime ReleaseKit
import.
## Release Model And Completion ## Release Model And Completion
@@ -123,9 +128,18 @@ rules above.
## Release Documentation And Preparation ## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged Every new module release must include `docs/releases/<version>.md` relative to
source tree. Use the complete version tag as the filename and begin with a the selected module directory in the tagged source tree. Here `<version>` is
title containing the product name and that exact version. that module's stable semantic version, `vMAJOR.MINOR.PATCH`; use it as the
filename and begin with a title containing the selected release profile's
product name and that exact semantic version.
The semantic version and full Git tag are distinct. The root module's tag is
its semantic version; an integration's full tag includes its module prefix,
as recorded in the [release procedure](../release.md#modules-versions-notes-and-shared-files).
Use the full tag for Git refs and immutable source URLs, with repository-relative
paths identifying the selected module's note, grant, license, and attribution.
The module prefix is not part of the note filename or title's version.
Include a short summary and any relevant compatibility impact or upgrade Include a short summary and any relevant compatibility impact or upgrade
instructions. Choose sections suited to the release and keep the note instructions. Choose sections suited to the release and keep the note

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **Base Policy Version:** 1.3
**Last Updated**: 2026-10-07 **Last Updated**: 2026-11-07
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md) **Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
@@ -63,23 +63,26 @@ implemented:
- Help and command discovery without provider loading or runtime construction. - Help and command discovery without provider loading or runtime construction.
- Provider failures, cancellation, output errors, and preservation of application - Provider failures, cancellation, output errors, and preservation of application
outcomes and public error identity. outcomes and public error identity.
- Selected inspection/report contracts, including native YAML and document order - Selected inspection/report contracts, including safe scalar preservation and
for `config show --scope all`. scope-keyed aggregation for `config show --scope all`.
- Public type ownership and the internal Cobra/pflag import boundary selected by - Public type ownership and the internal Cobra/pflag import boundary selected by
[ADR 0003](../adr/0003-public-facade.md). [ADR 0002](../adr/0002-public-facade-and-cli-backend.md).
Use external-package consumer tests for facade semantics and real backend Use external-package consumer tests for facade semantics and real backend
integration where correctness depends on that interaction. Internal package integration where correctness depends on that interaction. Internal package
tests own focused mechanisms. Do not retest the dependencies' complete suites. tests own focused mechanisms.
Boundary checks protect selected architectural constraints; behavioral evidence Boundary checks protect selected architectural constraints; behavioral evidence
protects invocation semantics and ownership through conversions. protects invocation semantics and ownership through conversions.
For shared reports, compare logical values and selected evidence rather than
YAML formatting or mapping order. Preserve sequence ordering only where the
relevant contract gives it meaning.
Do not infer concurrent-dispatch support from fresh invocation state. Test Do not infer concurrent-dispatch support from fresh invocation state. Test
concurrency guarantees only if deliberately selected. Coverage targets, race concurrency guarantees only if deliberately selected. Coverage targets, race
testing, static analysis, example gates, and platform matrices require their own testing, static analysis, example gates, and platform matrices require their own
risk justification; another library's workflow does not establish them here. risk justification; another library's workflow does not establish them here.
To the extent the following features are present in the application, prioritize tests for: For behavior owned by CommandKit, prioritize tests for:
1. Public and package-level contracts. 1. Public and package-level contracts.
2. Domain rules and important invariants. 2. Domain rules and important invariants.
@@ -106,6 +109,41 @@ application's guarantees. Account for protection already provided at other layer
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere. Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
## Upstream integration testing ownership
CommandKit tests the interfaces and adapters through which it exposes upstream
APIs. ConfigKit, PromptKit, and other integrated libraries own tests establishing
the correctness of their domain functionality. Trust their public contracts;
CommandKit tests must not independently establish whether those libraries
implement their claimed domain functionality. This applies to partial feature
checks as well as complete suites.
Integration assertions should establish CommandKit-owned behavior:
- CLI choices, typed inputs, configuration selection, and context reach the
appropriate upstream API with the intended values.
- CommandKit performs its selected coordination and preserves returned data,
diagnostics, and supported error identity across adaptation.
- CommandKit's reports and invocation outcomes correctly represent the returned
evidence, including presence, null, empty, and redacted values where applicable.
- Command availability, input/state lifetimes, cancellation handling, and output
delivery follow CommandKit's contracts.
For example, test that a namespace choice becomes the intended native listing
filter, rather than testing which resources PromptKit considers members of a
namespace. Test faithful presentation of ConfigKit's returned source evidence,
rather than its destination resolution, version checking, or sensitivity
classification. Test request forwarding and returned-error handling, rather than
whether PromptKit correctly detects missing artifacts or invalid templates.
A real upstream engine or result can be a useful fixture for a focused
integration test. Its use does not change assertion ownership: the test should
detect incorrect CommandKit adaptation, not independently establish upstream
domain correctness. Use the simplest suitable fixture or test double; this
boundary does not require new mocking interfaces or prohibit real dependencies
in tests. CommandKit's own parsing, registration, coordination, presentation,
and outcome behavior remain within its testing scope.
## Choose the right test boundary ## Choose the right test boundary
Choose the narrowest stable boundary that expresses the behavior clearly and Choose the narrowest stable boundary that expresses the behavior clearly and

View File

@@ -1,165 +1,198 @@
# CommandKit release procedure # CommandKit release procedure
## Scope and prerequisites ## Scope and release order
CommandKit publishes source-only Go module releases: an immutable annotated CommandKit publishes source-only Go module releases. Each module has an
version tag for one committed candidate and its matching checked-in release independent version and immutable annotated tag. A successful source-tag push
note. This procedure does not publish a hosted release page, binaries, completes the source release. A separate local delivery command can publish a
checksums, or other delivery assets, and it requires no delivery credentials. Gitea release page; there are no binaries,
checksums, or other delivery assets. CI, module proxy availability, and consumer
installation are not completion gates.
Use branch `main`, remote `origin`, and the expected effective push endpoint Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The profile enforces that integration modules require the preceding CommandKit releases, so prepare and
endpoint during publication. The maintainer needs the selected publish each module only after its CommandKit dependencies are available. A root
[release toolchain](#release-toolchain), Git with push access to `origin`, and a release runs only the root module's default suite; it need not run suites for
POSIX shell. independently released integrations.
This procedure follows the [release policy](policy/release.md); the release-note Use branch `main`, remote `origin`, and expected effective push endpoint
footer follows the [OSS discoverability policy](policy/oss.md#discoverability). `ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git`. The selected
module profile enforces the endpoint. The maintainer needs the selected
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
This procedure follows the [release policy](policy/release.md); release-note
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
ReleaseKit v0.3.0 owns candidate validation, publication guards, annotated tag ReleaseKit v0.9.0 (`go.maximumdirect.net/releasekit`) owns candidate validation,
creation, tag publication, and outcome reporting. The repository profile selects publication guards, tag creation, tag publication, and outcome reporting.
only its default candidate suite. The launcher bootstraps the module-selected Its pinned public contracts are the
native executable into ignored `.releasekit/` storage and invokes its `publish` [CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md),
command. The upstream [CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md), [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md),
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/config.md), the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md),
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/consumers/publish.md) and the [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md).
own detailed command, profile, and status contracts. The repository selects one default module suite per invocation. The launcher
uses the root module’s registered tool with native host settings and read-only
module metadata.
## Release toolchain ## Release toolchain
The selected release toolchain is **Go 1.26.8**. The consumer minimum is The selected release toolchain is **Go 1.27.0**. Core declares a
**Go 1.26.0**; validation uses a later patch within that supported Go series. consumer minimum of **Go 1.26.0**; both integrations require **Go 1.27.0**. Maintainers
The [consumer guide](consumers/guide.md#go-compatibility) explains the minimum and use Go 1.27.0 for dependency preparation, candidate validation, and ReleaseKit
its shared tool-module requirement; the execution. The
[release policy](policy/release.md#consumer-minimum-and-release-toolchain) governs [consumer guide](consumers/guide.md#go-compatibility) and
changes to either selection. [release policy](policy/release.md#consumer-minimum-and-release-toolchain)
describe these selections.
Use this toolchain for candidate validation and the native ReleaseKit bootstrap, Select Go 1.27.0 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
including CI jobs that perform those tasks. The launcher and ReleaseKit invoke install it with the standard Go tools:
`go` from `PATH` with `GOTOOLCHAIN=local`, so select that installation in the
shell used for preparation and publication.
If it is not installed, use the [standard Go installation method](https://go.dev/doc/manage-install).
Ensure Go's executable installation directory (`GOBIN`, or `GOPATH/bin` by
default) is on `PATH` so the downloaded-version wrapper can be invoked:
```sh ```sh
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.27.0@latest
go1.26.8 download go1.27.0 download
export PATH="$(go1.26.8 env GOROOT)/bin:$PATH" export PATH="$(go1.27.0 env GOROOT)/bin:$PATH"
hash -r hash -r
GOWORK=off GOTOOLCHAIN=local go version GOWORK=off GOTOOLCHAIN=local go version
``` ```
The final command should identify Go 1.26.8. An existing Go 1.26.8 installation ## Modules, versions, notes, and shared files
may be selected directly on `PATH` instead. Toolchain installation and dependency
preparation precede the offline publication checks; they add no validation pass.
## Prepare and publish a candidate Select exactly one module with the launcher:
Choose a fresh, unused stable Semantic Version tag in the form | Selector | Physical module directory | Release note | Full tag |
`vMAJOR.MINOR.PATCH`, with no leading zeroes. Before `v1.0.0`, a minor release | --- | --- | --- | --- |
may change a documented contract if its note explains the impact and consumer | `core` | repository root | `docs/releases/<version>.md` | `<version>` |
action; a patch release must not intentionally break contracts within its | `ckconfig` | `integrations/ckconfig` | `integrations/ckconfig/docs/releases/<version>.md` | `integrations/ckconfig/<version>` |
minor line. Never reuse a version previously published through any release | `ckprompt` | `integrations/ckprompt` | `integrations/ckprompt/docs/releases/<version>.md` | `integrations/ckprompt/<version>` |
channel. The first version is selected during release preparation, not embedded
in the tool or profile.
Start from `main` and prepare a note at `docs/releases/<version>.md`. Begin with Use a fresh stable semantic version (`vMAJOR.MINOR.PATCH`) for the selected
a heading containing `CommandKit` and the exact version. Include a useful module. Before `v1.0.0`, a minor release may change a documented contract when
summary and any compatibility impact or upgrade instructions. End it with the its note explains the impact and consumer action. Never reuse a published
license and attribution links at that version's immutable source tag, replacing version. Each release note starts with a product title and exact version, gives
`VERSION` with the selected tag: a proportionate summary and compatibility guidance, and ends with immutable
links to that module's local grant, license, and attribution paths. The full tag
in each URL includes the integration directory where applicable. For future
notes, use `https://forge.maximumdirect.net/base-layer/commandkit/src/tag/FULL_TAG/`
followed by the repository-relative path: `COPYRIGHT`, `LICENSE`, and
`docs/oss/attribution.md` for core, or those paths prefixed with the selected
integration directory. Future tag URLs need not resolve before publication.
```markdown All three release profiles explicitly select the repository-root `COPYRIGHT`,
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/COPYRIGHT) | `LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/LICENSE) | ReleaseKit requires these committed files to exist in the candidate. The list
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/docs/oss/attribution.md) does not expand Go validation scope; see the pinned [profile reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/config.md).
```
Review these paths in the candidate; the future tag URLs do not need to resolve Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
before publication. `docs/oss/attribution.md`. These module-local materials are separate from the
explicit root shared-file list and remain subject to the [OSS policy](policy/oss.md).
Automatic module file inputs include applicable module-local files and stop at
independent nested-module boundaries. Their inclusion does not itself enable
license or content validation.
Prepare dependencies separately from the offline candidate suite: Each profile selects Go formatting, Markdown links, workspace-file and local
replacement checks, and forbids the committed `.releasekit/gitea-token` path.
These upstream checks inspect committed inputs; module file selection stops at
independent module boundaries, while the forbidden path covers the repository.
They do not replace domain review. Publication also runs the selected module’s
standard offline suite and ReleaseKit’s candidate/note and publication guards.
Run these commands in the same shell session so the version variable remains ## Prepare and publish one candidate
set:
Prepare dependencies in the selected module before the candidate is finalized.
```sh Do this after the preceding CommandKit module has been published and before the
commandkit_version=vX.Y.Z selected candidate suite is run. For `core`, run the command at the repository
GOWORK=off GOTOOLCHAIN=local go mod download root; for integrations, run it from that module directory. Commit any resulting
``` `go.mod` or `go.sum` changes with the release candidate. The sum files must
reflect dependencies actually downloaded for that module; do not fabricate
If dependency preparation changes `go.mod` or `go.sum`, review and commit those future sums.
changes with the candidate. Commit the note and all candidate changes on `main`,
then publish that commit to `origin`: Then commit the selected module's release note and all intended candidate
changes on `main`, and publish the candidate commit to `origin`. Set one
module directory and version from the table above, then run the corresponding
publication command:
```sh ```sh
module_dir=.
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
# Commit reviewed dependency changes and the selected module's note on main.
git push origin HEAD:refs/heads/main git push origin HEAD:refs/heads/main
./scripts/release "$commandkit_version" ./scripts/release core vX.Y.Z
# For integrations use: ./scripts/release ckconfig vX.Y.Z
# or: ./scripts/release ckprompt vX.Y.Z
``` ```
The launcher builds the ReleaseKit version selected by the module in readonly Run only the command for the selected module. Its profile runs the complete
mode. After preflight, `publish` runs the offline default Go suite once, checks offline deterministic default suite once for that module, checks candidate and
the candidate and remote guards, creates an annotated tag from the committed remote guards, creates the annotated tag from the matching note, and pushes
note, and pushes only that tag object. The launcher does not push `main`; push only that full tag. `./scripts/releasekit core check vX.Y.Z` is an optional
the candidate first. `check` is available for optional diagnosis and is not a diagnostic, not a required pre-publication step: publication repeats validation.
required step before `publish`. The automated publication workflow runs the Use the relevant integration selector and its version when diagnosing that
final-candidate suite once; configured additional checks should avoid duplicating module. Inspect the registered tool with `./scripts/releasekit version`. The
that suite. Maintainers may rerun diagnostics when useful. Successful source launcher does not push `main`; publish the
publication does not wait for CI, module availability, or consumer installation. candidate commit first. Successful source publication does not wait for CI,
proxy availability, or downstream installation.
## Completion and statuses ## Completion and ambiguous publication
Publication is complete when the intended annotated tag is successfully pushed A successful push to the expected endpoint completes the source release.
to the expected endpoint, or when an exact tag-object query confirms that push ReleaseKit also attempts to save private publication evidence under the
after an error. Status 0 reports confirmed publication. Status 1 reports a repository's Git common directory, shared by linked worktrees. It reports the
known failure or conflict; status 2 reports invalid input or profile; status 3 saved record path or a recording warning separately from publication status;
means the push outcome remains unresolved. See the upstream [CLI status a storage failure does not change publication status. Records can contain
contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md). credential-bearing endpoints. Retain them privately while recovery is needed
and remove them when their recovery value expires; retention is maintainer-owned.
For a failed or ambiguous push, use the exact endpoint, version, and annotated On an ambiguous result, use the recovery command printed for that invocation's
tag object reported by the invocation. Confirm that Git still resolves the saved record. To discover records and explicitly select the matching attempt,
captured endpoint to itself before querying only that tag ref: the shared helper can also be used:
```sh ```sh
releasekit_endpoint='COPY THE CAPTURED ENDPOINT EXACTLY' ./scripts/releasekit publication-attempts
releasekit_version='vX.Y.Z' ./scripts/releasekit reconcile --record /absolute/path/to/attempt.json
releasekit_tag_object='COPY THE REPORTED TAG OBJECT ID'
releasekit_ref="refs/tags/$releasekit_version"
releasekit_resolved_endpoint=$(git ls-remote --get-url -- "$releasekit_endpoint") || {
printf '%s\n' 'publication state is unresolved: endpoint resolution failed' >&2
exit 1
}
if [ "$releasekit_resolved_endpoint" != "$releasekit_endpoint" ]; then
printf '%s\n' 'publication state is unresolved: endpoint resolution changed' >&2
exit 1
fi
releasekit_remote_tag=$(git ls-remote --refs -- "$releasekit_endpoint" "$releasekit_ref") || {
printf '%s\n' 'publication state is unresolved: remote query failed' >&2
exit 1
}
printf '%s\n' "$releasekit_remote_tag"
``` ```
`--refs` returns the annotated tag object's ID, not its peeled commit. Compare Replace the example record path with the saved path for the intended attempt.
that ID with `releasekit_tag_object`: an exact match confirms publication; a Relative record paths resolve from the repository root.
different object is a conflict; successful output with no matching ref proves Discovery lists local evidence without querying remotes or selecting an attempt.
absence at the captured endpoint. A failed query or changed URL resolution is Reconciliation observes the recorded endpoint, full tag, and tag object without
unresolved, never evidence of absence. Do not query a different fetch URL or rerunning candidate validation or changing tags. It distinguishes confirmed
follow a rewritten endpoint to infer the captured destination's state. publication, current absence, conflict, and an unresolved observation. Current
absence does not prove that a version was never published. The pinned
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
owns recovery statuses and structured output; the
[publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/publish.md#retain-and-reconcile-publication-evidence)
describes record storage and recovery entry points.
ReleaseKit retains a local tag after a failed push, so the tag blocks an ordinary If no usable record was saved, use the exact endpoint, full tag, and annotated
retry. After proving the tag absent at the captured endpoint, tag object reported by that invocation. Query only that exact tag ref at the
confirm that it was never published or used by another release channel before captured endpoint. An exact tag-object match confirms publication, a different
deliberately removing the unpublished local tag: object is a conflict, successful output without the ref shows current absence,
and a failed query leaves the result unresolved. Do not infer the captured
endpoint's state from another remote or a rewritten URL. Resolve unknown
publication state before retrying.
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
local tag only after proving absence at the captured endpoint and confirming
that the tag was never published or used by another release channel. Never
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
fresh version; corrections to published source require a new version.
## Hosted delivery
After source publication, check out the original tagged source with a clean
worktree and index, then deliver the selected module's page:
```sh ```sh
git tag -d "$releasekit_version" ./scripts/releasekit core deliver vX.Y.Z
# Or use ckconfig or ckprompt with that module's semantic version.
``` ```
Do not move, overwrite, recreate, or delete a published tag. If the exact tag The profile derives the full module-specific tag from the version. Delivery
object is present remotely, keep it. Resolve conflicts with a fresh version; uses the checked-in release note as a page-only body and reports its own outcome
corrections to published source require a new version. A transport failure and URL separately from source publication. Retry later from the same original
alone does not require a new version. tagged source. The pinned [delivery guide](https://forge.maximumdirect.net/base-layer/releasekit/src/commit/2180aa868ec2caa95fc8279fd59d5f0ebac41bd4/docs/consumers/delivery.md)
owns delivery and retry behavior.
A nonblank `RELEASEKIT_GITEA_TOKEN` takes precedence over the repository-relative
`.releasekit/gitea-token` file. Keep the credential directory ignored and retain
its local contents. Credentials and Gitea availability do not gate source
publication.

52
docs/releases/v0.2.0.md Normal file
View File

@@ -0,0 +1,52 @@
# CommandKit v0.2.0
## Summary
CommandKit v0.2.0 replaces the core registration API with reusable captured
command declarations and moves ConfigKit and PromptKit support into independently
versioned optional modules. This is a source-only release with incompatible API
changes from v0.1.0.
## Changes
- Capture typed command specifications with `NewCommand`, then install the
resulting `Command` declarations through `commandkit.New` or `App.Register`.
Installation validates a batch before changing the application's registry.
- Reuse captured declarations across applications. Typed bindings preserve
supplied values separately from defaults, and input construction occurs only
when execution reaches the command.
- Keep ConfigKit and PromptKit dependencies outside the core module. Applications
explicitly import integrations and install their returned declarations.
- Adopt ReleaseKit v0.4.2 for independently versioned module publication. It
remains a maintainer tool dependency; production packages do not import it.
## Compatibility and upgrade
The consumer minimum remains **Go 1.26.0**. The selected maintainer release
toolchain remains Go 1.26.8.
Consumers upgrading from v0.1.0 must rewrite command declarations, typed
bindings, and installation wiring for the captured declaration API. There is no
compatibility layer for the earlier registration API. Move configuration
integration code to
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`, and PromptKit
integration code to
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`.
The integrations' initial releases are each v0.1.0; their version numbers are
independent of core and upstream dependency versions. They add native
configuration comparison, richer source evidence, PromptKit requirements
discovery and validation, five catalogs, and opt-in smoke testing. Their own
release notes describe the features and operator changes:
- [ckconfig v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/docs/releases/v0.1.0.md)
- [ckprompt v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/docs/releases/v0.1.0.md)
See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/consumers/guide.md)
for migration examples and the
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/cli.md)
for standard command and report contracts.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/oss/attribution.md)

88
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,88 @@
# CommandKit v0.3.0
## Summary
CommandKit v0.3.0 moves core to `go.maximumdirect.net/commandkit`, adds
application-owned command and flag aliases, shared invocation reporting, and
shell completion. This pre-1.0 minor release also updates the installation
declaration type and Invocation struct shape, replaces the error-first outcome
and diagnostic-delivery APIs, and reserves reporting flag names; review the
migration guidance before upgrading.
## Changes
- Declare command shortcuts and global or canonical-command-scoped flag
spellings with `Alias`, `FlagAlias`, and `FlagAliasFor`. Install routing rules
beside canonical commands through `New` or `App.Register`. Aliases route to
existing handlers and preserve the target command's operand and binding
behavior.
- Read the entered and canonical command paths from `Invocation` with
`EnteredPath` and `CanonicalPath`.
- Interpret a canonical short value flag such as `-c=` as an explicitly
supplied empty value, consistent with `--color=` and `-c ""`.
- Obtain an invocation-scoped `Reporter` from each handler's `Invocation` for
substantive output, Warning/Info/Progress/Debug messages, deferred messages,
and managed report rendering. The shared `--quiet`, `--verbose`, `--debug`,
and `--progress` flags apply across core and integration commands.
- Return terminal explanation text with `Failure`, `Usage`, or
`Outcome.WithExplanation`; attach an optional operation error separately with
`Outcome.WithCause`. Returned `Outcome.Presentation` records output and
diagnostic delivery observations without changing the operation cause.
- Install the optional `Completion` command and declare static choices or
path hints for interactive shell completion. Bash, Zsh, and Fish scripts
use the application's captured commands and routing metadata.
- Maintainer release tooling now uses the root-selected ReleaseKit v0.9.0 tool,
with separate source publication and optional page-only hosted delivery.
## Compatibility and migration
Change core imports from `gitea.maximumdirect.net/eric/CommandKit` to
`go.maximumdirect.net/commandkit` and select v0.3.0. These paths identify
different Go packages, so their types cannot be mixed. Applications using the
integrations should migrate together to the forthcoming matched
`go.maximumdirect.net/ckconfig v0.2.0` and
`go.maximumdirect.net/ckprompt v0.2.0` releases, which depend on the new core;
the existing integration releases retain their old identities.
`New` and `App.Register` now accept `...Declaration` instead of `...Command`.
Command, group, and integration command factories still return `Command`, so
individual factory calls can be passed directly. A `[]Command` cannot be
expanded into `...Declaration`; use a `[]Declaration` for mixed reusable
collections or convert each command element before expanding it. Function
values whose parameter type is `...Command`, including wrappers assigned from
`New` or `Register`, must update their signature to `...Declaration`.
Adding private route fields to `Invocation` means positional struct literals no
longer compile. `Invocation` no longer contains `Streams`; `App.Execute` supplies
the reporter and route paths to handlers. `Streams` remains the argument to
`App.Execute`, where the caller owns the result and diagnostic writers.
Handlers that used `invocation.Streams.Out` or `.Err` should use
`invocation.Reporter().Output()` or the categorized reporting methods. Replace
`Failure(err)` and `Usage(err)` with an operator-facing explanation and an
optional `.WithCause(err)`; causes alone are not rendered. Replace
`Outcome.Diagnostics` submissions and terminal `SeverityError` records with
reporter messages and an `Outcome` explanation. Provider-returned
`Acquired.Diagnostics` remain passive data for the owning integration to
submit. Replace `ReportIncomplete`, `DiagnosticDelivery`, and `DeliveryError`
with `Outcome.Presentation.Output` and `.Diagnostics`, whose `DeliveryResult`
contains a state plus first render and write errors.
The long flags `--quiet`, `--verbose`, `--debug`, and `--progress` are reserved
for CommandKit reporting and cannot be declared by applications. Application
aliases may target them. See the current [CLI reporting contract](../cli.md#reporting)
and [consumer migration guide](../consumers/guide.md#invocation-reporting) for
their behavior and API examples.
The consumer minimum remains Go 1.26.0 and the maintainer release toolchain
remains Go 1.26.8. Core v0.3.0 must be published before preparing dependent
integration releases that adopt it and record downloaded dependency sums; this
release does not change their v0.1.0 release records.
See the [consumer guide](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/consumers/guide.md),
[CLI reference](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/cli.md),
and [maintainer release procedure](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/release.md).
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.3.0/docs/oss/attribution.md)

32
docs/releases/v0.4.0.md Normal file
View File

@@ -0,0 +1,32 @@
# CommandKit v0.4.0
CommandKit v0.4.0 adds shared structured reporting and reusable CLI input
facilities. Applications and integrations can submit one report value for
automatic or explicit text, YAML, and JSON output. Core selects the format,
renders optional standard text templates, and records presentation failures
through the existing invocation lifecycle. The writer API exposes the same
rendering behavior outside an invocation.
Report values use JSON serialization conventions for structured output. The
safe YAML conversion helper accepts upstream inspection documents without
reconstructing configuration or treating ordinary strings as YAML. Public CLI
JSON parsing and ordered flag occurrences support structured inputs and
interleaved bindings. Applications can reuse and rename flag bindings while
preserving their typed parsing and presence behavior.
This pre-1.0 minor release reserves the long flag `--format` for core and
changes standard version output to use shared reporting. Applications that
declare a local `--format` must remove or rename it; an application alias may
target the core flag. Integration report framing and format availability have
changed with the shared API; select compatible integration releases as they
become available. See the [CLI reference](../cli.md#reporting) and
[consumer guide](../consumers/guide.md#invocation-reporting) for current
contracts and examples.
Core continues to declare a Go 1.26.0 consumer minimum. The maintainer release
toolchain is Go 1.27.0, and core has no ConfigKit or PromptKit integration
dependency.
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/COPYRIGHT) |
[LICENSE](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/LICENSE) |
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/commandkit/src/tag/v0.4.0/docs/oss/attribution.md)

View File

@@ -1,89 +0,0 @@
# Deferred work
Status: retained future work and unselected proposals; no implementation schedule.
## Purpose and authority
Completed initial delivery, performance, ReleaseKit upgrade, and remediation
records have been retired. This document preserves their unfinished capability
targets and deferred ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation).
The original records remain available in
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
an idea here does not select its syntax, API, priority, or delivery date.
## Deferred capability targets
### DIFF-01: semantic configuration comparison
Expose comparison of two independently resolved selections of one scope using
owning-library safe snapshots. [ADR 0008](../adr/0008-semantic-configuration-diff.md)
owns the accepted comparison semantics, sensitivity union, and coverage-gap
handling. The target includes text/YAML reports of differences and gaps;
completed comparisons can succeed even when they find either.
The initial review of ConfigKit v0.5.0 and PromptKit v0.12.0 did not find the
complete public snapshot/sensitivity contract needed for that target. Reassess
released upstream APIs when planning it; do not treat that historical review as
a claim about newer versions. Hidden-path metadata and supported gap reasons
must be available without fetching secrets. Rendered YAML and sibling internals
are not substitutes for an agreed public comparison representation.
Comparison selection syntax, context inheritance, and report layout remain
future design work. Earlier possibilities included right-side root/overlay
selection, shared versus independent domain context, and an optional assertion
mode. Those choices are unselected. Add public types and handlers when the
capability is supported, rather than exposing placeholders.
### CAT-01: PromptKit catalogs
List captured prompt/version entries and named profiles through owning public
catalog APIs, with deterministic reports of loaded definitions. Resource names,
enumeration details, and report shapes remain future design work.
The initial PromptKit v0.12.0 review did not find public enumeration APIs.
Reassess current released capabilities before planning delivery. Do not infer
catalogs by reparsing configuration YAML or inspecting sibling internals.
## Other deferred proposals
These capabilities remain outside the delivered scope and require a concrete
operator or consumer need and an appropriate ownership/behavior decision:
- JSON reports, shell completion, and output-file management or reloadable export.
- Configuration mutation, generic value assignment, and exhaustive field-origin
inventories.
- Credential diagnostics and live provider probes.
- Additional component identity registration and domain artifact catalogs.
- Additional library families, including possible FeedKit integration; families
can define suitable verbs without requiring comparison or configuration scopes.
- Backend selection or replacement, a plugin framework, general dependency
injection, and concurrent-dispatch guarantees.
Execution, rendering, serving, and session operations remain application-owned.
Shared vocabulary is guidance under [ADR 0006](../adr/0006-command-grammar.md).
Backend replacement remains possible behind the accepted facade, subject to the
published API/CLI contracts and deliberate compatibility decisions.
## Possible further efficiency work
Selected-scope memoization and selector-membership indexes were deferred pending
measurements that justify their bookkeeping. Mutable backend reuse across
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
redesign, and a replacement backend were outside the completed performance
target. Reconsider them only with concrete motivation and review of ownership,
invocation state, native reports, and the affected contracts. Retained measurement
records are historical evidence, not performance guarantees or release gates.
## Consumer adoption
Consumer migrations remain separate from library delivery. Applications can
replace duplicated standard handlers and routing, then update their CLI docs
and subprocess consumers for intentional changes while retaining domain commands.
No migration order or claim about individual applications' adoption is recorded
here; the [consumer guide](../consumers/guide.md) and maintained examples provide
integration guidance.

221
docs/roadmap/future.md Normal file
View File

@@ -0,0 +1,221 @@
# Future work
Status: pending release and integration work, plus future capability proposals.
No delivery dates are selected here.
## Purpose and authority
Completed roadmaps, implementation plans, and review findings have been retired.
This document preserves unfinished work and unselected ideas under the
[release policy](../policy/release.md#release-documentation-and-preparation).
The completed PromptKit v0.18.0 adoption roadmap and implementation plan remain
available in the
[PromptKit retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/540e2359d66d604a563df0be1c9fc961588f5b9c/docs/roadmap).
The completed ConfigKit adoption, reporting, dependency alignment, release-tooling,
and remediation records remain available at the
[retirement snapshot](https://forge.maximumdirect.net/base-layer/commandkit/src/commit/260256d722f52bc947103d078cb67a129f4cc9f1/docs/roadmap).
[Earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
also remain in Git history. Completed records are not assignments to restart work.
Accepted [ADRs](../adr/) and the [architecture policy](../policy/architecture.md)
govern any future implementation. The [CLI reference](../cli.md), public GoDoc,
and [consumer guide](../consumers/guide.md) own implemented behavior. Retaining
an idea here does not select its syntax, API, priority, or delivery date.
## Pending release and integration work
### ckprompt release
The PromptKit v0.18.0 adoption is complete. The
[ckprompt v0.3.0 candidate](../../integrations/ckprompt/docs/releases/v0.3.0.md)
remains unpublished. Publication and hosted delivery remain separate operations
under the [release procedure](../release.md).
The published ckprompt v0.2.0 retains its earlier dependencies; see the
[consumer guide](../consumers/guide.md#module-selection) for module selection.
### PromptKit v0.20.0 adoption
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md) owns the agreed adoption,
the shared core reporting/input prerequisites, operator CLI additions for
PromptKit and ConfigKit, recorded decisions, and completion checklist. It targets
a clean alpha adoption without backward-compatibility provisions or consumer
migration guidance. Implementation has not started. Use released upstream
contracts only; do not inspect or monitor unreleased work in anticipation of
adoption.
## Future capability targets
### Comparison extensions
The [working roadmap](promptkit-v0.20.0.md#additional-configkit-operator-workflows)
owns independent comparison selections (OD-9), assertion outcomes (OD-10), and
optional detailed reports (OD-5). These accepted plans do not change currently
implemented behavior.
General collection-clearing support is deferred pending a concrete consumer need.
Revisit standardized syntax and binding support for explicitly replacing a
nonempty application-default selector collection with an empty collection.
This is separate from clearing configuration lists through OD-11's transient
overrides and does not reinstate comparison inheritance. No clearing grammar
or public helper API is selected.
### Configuration inspection extensions
The [working roadmap](promptkit-v0.20.0.md#shared-configuration-capabilities)
owns metadata/path discovery (OD-4), detailed comparison (OD-5), failed-load
evidence and aggregate validation (OD-6), focused inspection/filters (OD-8), and
transient overrides (OD-11). The six-command suite, selected output behavior,
and pass-through override policy are defined there. Implementation belongs in
`ckconfig`, with PromptKit exposing native evidence through its configuration
scope. Use the shared core reporting prerequisite and thin projections;
presentation refinements should follow implementation and beta feedback.
All numbered decisions are settled; exact APIs and wiring remain implementation
work. Persistent configuration editing remains deferred.
Discovery, import defaults, collection identities, and field-level resolution
recording remain available through application loaders. Discovery tracing,
resource-context inspection, and secrets-directory bootstrap commands remain
deferred. Acquired source records do not provide a complete trace of rejected
discovery candidates; bootstrap and resource contexts primarily serve
application loading and startup.
### PromptKit inspection and selection extensions
The [PromptKit v0.20.0 roadmap](promptkit-v0.20.0.md#targeted-end-state)
owns native prompt/profile/backend resource inspection, concrete request preview,
applicable request flags, namespace selection, and the shared configuration
inspection extensions. The [resource inspection plan](promptkit-v0.20.0.md#resource-inspection)
includes backend inspection through the released upstream API. Execution flags
will be public reusable `ckprompt` collections for standard and application-owned
CLI commands, composed through existing core bindings.
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
records the agreed canonical naming, scoped convenience aliases, integration
installation collection, selected text/file input forms, and CLI JSON number
policy. Numeric conversion uses ordinary Go types and a small internal helper
with policy comments, behind a narrow public core CLI JSON parser. Collections
and aliases use existing declarations; cross-flag text/file precedence uses the
roadmap's ordered typed input prerequisite.
Output-contract overrides and appended-message flags are deferred below.
These are CLI features; general-purpose helpers for
application-internal PromptKit use are outside CommandKit's ownership boundary.
[Roadmap OD-3](promptkit-v0.20.0.md#od-3-namespace-flags-and-loader-defaults)
records the agreed namespace-selector applicability, including the limited role
of profile selection in requirements inspection. That selection follows the
released upstream identity requirement; it does not change prompt dependencies.
- **Output-contract override flags.** Deferred from the current adoption. A
possible future operator task is validating or previewing one prompt with a
replacement output contract without editing its definition. PromptKit uses
whole-contract replacement; a complete-contract file option was discussed,
but no flag spelling or file format is selected. Existing configuration and
application builders remain available. Revisit when the standard `promptkit`
commands have a demonstrated need for this workflow.
- **Appended-message flags.** Deferred from the current adoption. The potential
operator task is reproducing a single request with application-added messages
through `promptkit show prepared` or `promptkit requirements validate`, to
inspect roles, ordering, and content or check preparation. These commands do
not test model responses or application workflows. Appended messages follow
definition messages in supplied order; they are already rendered and do not
satisfy missing named prompt inputs. Revisit when request reproduction is a
demonstrated operator requirement. An ordered message-list file is a candidate,
but no flag syntax, file format, or supported-role selection is decided.
Deferring flags does not remove native appended messages from preview reports
when an application builder supplies them.
[Roadmap OD-2](promptkit-v0.20.0.md#od-2-request-controls-and-provider-parameter-grammar)
selects inline text and explicit file input for single-prompt validation and
preview. Native API availability alone does not select further CLI syntax or
require a convenience API for application execution.
### Alias completion and discovery
The delivered [shell completion feature](../cli.md#shell-completion) provides
canonical suggestions and alias-aware interpretation. Potential future work
includes suggesting command and flag alias spellings and valid continuations of
alias-only prefixes. For example, completion could suggest `inspect` after
`app legacy ` when `legacy inspect` is an installed alias.
Alias discovery is outside the initial scope. This deferral does not establish a
permanent canonical-only completion contract or prohibit future alias discovery.
A later design should determine how alias suggestions are presented alongside
canonical suggestions while ensuring inserted continuations form valid next
steps. No delivery date or final API is selected here.
### Reporting extensions
The [shared core report output prerequisite](promptkit-v0.20.0.md#shared-core-report-output-prerequisite)
owns application-selected content, core rendering, universal/automatic format
selection, JSON representation conventions, text templates, and safe YAML input
conversion. Text, YAML, and JSON land together; new core formats work through the
same report API. The [output inventory](promptkit-v0.20.0.md#output-implementation-inventory)
identifies report paths and preserves explicit raw/help/diagnostic boundaries.
OD-6 defines one aggregate validation report and its changed delivery timing.
XML is outside current consideration; Markdown and other renderers are not
selected for this adoption.
Error-conversion helpers such as `WarnError` remain deferred pending demonstrated
value; applications can already use `report.Warn(err.Error())`. Terminal redraw
and progress-bar presentation were outside the completed reporting scope and
remain unselected. Whole-run buffering to make failed runs show only errors
was also outside that scope. Any later extension needs a deliberate API and
presentation decision rather than assuming those behaviors are already provided.
### Release workflow extensions
Historical hosted-page backfilling and automated CI delivery remain separate
future choices. The current local page-only delivery workflow is implemented;
these extensions are not prerequisites for using it. The release procedure owns
delivery, immutable content, and recovery behavior.
Additional validation options, including race testing, full vet, compilation
matrices, size limits, and more forbidden-file paths, can be considered for
demonstrated needs. None is selected by retaining it here, and the existing
candidate checks do not become general prohibitions on development workspaces.
## Other future proposals
These capabilities remain outside the delivered scope and require a concrete
operator or consumer need and an appropriate ownership/behavior decision:
- Additional report renderers as described under [reporting extensions](#reporting-extensions),
and output-file management or reloadable export.
- Shell completion extensions, including alias discovery described above and
dynamic discovery of configured or remote values.
- Persistent configuration mutation. Transient CLI overrides are selected under
roadmap OD-11; field metadata and provenance inspection are scoped under
[configuration inspection extensions](#configuration-inspection-extensions).
- Credential diagnostics beyond PromptKit's native smoke checks and additional
live provider probes.
- Additional component identity registration and domain artifact catalogs.
- Additional library families, including possible FeedKit integration; families
can define suitable verbs without requiring comparison or configuration scopes.
- Backend selection or replacement, a plugin framework, general dependency
injection, and concurrent-dispatch guarantees.
Execution, serving, and session operations remain application-owned. The
[request preview proposal](promptkit-v0.20.0.md#preview-and-request-flags)
exposes native preparation and rendering as an agreed operator inspection target;
implementation has not started.
The [architecture policy](../policy/architecture.md#command-and-application-ownership)
recommends shared vocabulary; the [CLI reference](../cli.md#registered-command-syntax)
owns implemented grammar.
Backend replacement remains possible behind the accepted facade, subject to the
published API/CLI contracts and deliberate compatibility decisions.
## Possible further efficiency work
Selected-scope memoization and selector-membership indexes were deferred pending
measurements that justify their bookkeeping. Mutable backend reuse across
invocations, segmented aggregate YAML delivery, provider caching, consumer-loader
redesign, and a replacement backend were outside the completed performance
target. Reconsider them only with concrete motivation and review of ownership,
invocation state, native reports, and the affected contracts. Retained measurement
records are historical evidence, not performance guarantees or release gates.
## Consumer adoption
CommandKit is in alpha with no consumers. No application adoption work or rollout
order is selected. The [consumer guide](../consumers/guide.md) and maintained
examples provide integration guidance for future applications.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +0,0 @@
# Composed consumer
This example composes application configuration, PromptKit configuration,
definition inspection, and one application-owned command:
```sh
go run . config scopes
go run . config validate --scope all --campaign spring
go run . promptkit show prompt briefing --campaign spring --format yaml
go run . promptkit show profile spring --campaign spring
go run . session restore session-42 --campaign spring
```
The sources and session store are small in-memory fixtures. `--campaign` is a
typed selector shared by the app and PromptKit configuration scopes. Their
scope contexts share a per-invocation cache created by `ConfigSuite.NewContext`.
Prompt/profile inspection has its own invocation context and composes the same
application and engine loader functions.

View File

@@ -1,223 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
type campaign struct {
PromptID string `yaml:"prompt_id"`
PromptVersion string `yaml:"prompt_version"`
ProfileID string `yaml:"profile_id"`
Model string `yaml:"model"`
}
type appConfiguration struct {
DefaultCampaign string `yaml:"default_campaign"`
Campaigns map[string]campaign `yaml:"campaigns"`
}
type invocationCache struct {
application *configkit.Result[appConfiguration]
}
type configContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type definitionContext struct {
Cache *invocationCache
Campaign commandkit.Supplied[string]
}
type restoreInput struct {
Campaign commandkit.Supplied[string]
Session string
}
type session struct {
ID string
Campaign string
Summary string
}
type sessionStore struct {
active string
records map[string]session
}
func loadApplicationConfig(_ context.Context, _ commandkit.Selection, cache *invocationCache) (*configkit.Result[appConfiguration], error) {
if cache != nil && cache.application != nil {
return cache.application, nil
}
result, err := configkit.Load[appConfiguration](configkit.Mapping("composed app config", map[string]any{
"default_campaign": "spring",
"campaigns": map[string]any{
"spring": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "spring", "model": "spring-model"},
"autumn": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "autumn", "model": "autumn-model"},
},
}))
if err == nil && cache != nil {
cache.application = result
}
return result, err
}
func selectedCampaign(configuration appConfiguration, supplied commandkit.Supplied[string]) (string, campaign, error) {
name := configuration.DefaultCampaign
if supplied.Set {
name = supplied.Value
}
value, ok := configuration.Campaigns[name]
if !ok {
return "", campaign{}, fmt.Errorf("unknown campaign %q", name)
}
return name, value, nil
}
func loadPromptEngine(ctx context.Context, selection commandkit.Selection, cache *invocationCache, selected commandkit.Supplied[string]) (*promptkit.Engine, error) {
application, err := loadApplicationConfig(ctx, selection, cache)
if err != nil {
return nil, err
}
name, chosen, err := selectedCampaign(application.Config, selected)
if err != nil {
return nil, err
}
engineConfig := configkit.Mapping("campaign "+name, map[string]any{
"profiles": map[string]any{chosen.ProfileID: map[string]any{
"endpoint": "https://example.invalid/v1",
"model": chosen.Model,
"extra_params": map[string]any{
"campaign": name,
},
}},
"prompts": map[string]any{chosen.PromptID: map[string]any{
chosen.PromptVersion: map[string]any{
"default_profile": chosen.ProfileID,
"inputs": []any{map[string]any{"name": "topic", "required": true, "content_type": "text/plain", "description": "Topic to brief"}},
"messages": []any{map[string]any{"role": "user", "content": "Write a concise briefing about {{.topic}}."}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
},
}},
})
return promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: engineConfig}}})
}
func campaignSelector[C any](set func(*C, commandkit.Supplied[string])) commandkit.Selector[C] {
return commandkit.Selector[C]{
Binding: commandkit.ValueFlag[C](commandkit.FlagMeta{Name: "campaign", Usage: "Select a configured campaign."}, nil, func(value string) (string, error) { return value, nil }, set),
Scopes: []string{"app", "promptkit"},
}
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"})
if err != nil {
return nil, err
}
appScope, err := commandkit.NewConfigKitScope[configContext, appConfiguration]("app", "Application campaigns.", func(ctx context.Context, selection commandkit.Selection, input configContext) (*configkit.Result[appConfiguration], error) {
return loadApplicationConfig(ctx, selection, input.Cache)
})
if err != nil {
return nil, err
}
promptScope, err := commandkit.NewPromptKitScope(func(ctx context.Context, selection commandkit.Selection, input configContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
configSuite := commandkit.ConfigSuite[configContext]{
NewContext: func() configContext { return configContext{Cache: &invocationCache{}} },
Selectors: []commandkit.Selector[configContext]{campaignSelector(func(input *configContext, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Scopes: []commandkit.ConfigScope[configContext]{appScope, promptScope},
}
if err := commandkit.RegisterConfig(app, configSuite); err != nil {
return nil, err
}
definitionSuite, err := commandkit.NewPromptKitSuite(func(ctx context.Context, selection commandkit.Selection, input definitionContext) (*promptkit.Engine, error) {
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
})
if err != nil {
return nil, err
}
definitionSuite.NewContext = func() definitionContext { return definitionContext{Cache: &invocationCache{}} }
definitionSuite.Selectors = []commandkit.Selector[definitionContext]{campaignSelector(func(input *definitionContext, value commandkit.Supplied[string]) {
input.Campaign = value
})}
if err := commandkit.RegisterPromptKit(app, definitionSuite); err != nil {
return nil, err
}
store := &sessionStore{records: map[string]session{"session-42": {ID: "session-42", Campaign: "spring", Summary: "A synthetic planning session."}}}
restore, err := commandkit.NewCommand(commandkit.CommandSpec[restoreInput]{
Name: "restore",
Summary: "Restore one saved session.",
Flags: []commandkit.Flag[restoreInput]{commandkit.ValueFlag[restoreInput](commandkit.FlagMeta{Name: "campaign", Usage: "Campaign owning the session."}, nil, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Campaign = value
})},
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument[restoreInput](commandkit.ArgumentMeta{Name: "ID"}, true, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
input.Session = value.Value
})},
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
return restoreSession(invocation, store, input)
},
})
if err != nil {
return nil, err
}
sessionGroup, err := commandkit.Group("session", "Manage saved sessions.", restore)
if err != nil {
return nil, err
}
if err := app.Register(sessionGroup); err != nil {
return nil, err
}
return app, nil
}
func restoreSession(invocation commandkit.Invocation, store *sessionStore, input restoreInput) commandkit.Outcome {
application, err := loadApplicationConfig(invocation.Context, commandkit.Selection{}, nil)
if err != nil {
return commandkit.Failure(err)
}
name, _, err := selectedCampaign(application.Config, input.Campaign)
if err != nil {
return commandkit.Failure(err)
}
record, ok := store.records[input.Session]
if !ok {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
}
if record.Campaign != name {
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
}
store.active = record.ID
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

38
examples/core/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Core example
Run the example from the repository root:
```sh
go run ./examples/core hello --times=0 --tag=first
go run ./examples/core ws greet --count=2
go run ./examples/core ws --help
go run ./examples/core hello --hlep
go build -o core-example ./examples/core
./core-example completion bash
```
The example installs a canonical `workspace greet` command together with a
command shortcut, a group shortcut, a scoped flag alias, and a global help
alias. Its declaration slice mixes the canonical group and routing rules as
`[]commandkit.Declaration`. The handler prints `Invocation.EnteredPath()` and
`Invocation.CanonicalPath()` so the alternate route and its canonical target
are visible in the output.
The example also installs `Completion("core-example")` and gives the `report`
scenario argument static choices. Initialize `bash-completion` first if using
Bash. Build it with the basename shown above, load
the script for your shell using the [CLI reference](../../docs/cli.md#shell-completion),
and try completing `core-example report d` or `core-example ws gr`.
Compatible integration factories also return `commandkit.Command` and can be
passed directly to `New` beside `commandkit.Alias` or flag-alias declarations.
The matched ckconfig and ckprompt releases use the new core identity. For a
command-only `[]commandkit.Command`, convert its elements into a
`[]commandkit.Declaration` before appending routing rules; Go cannot expand the
command slice as `...commandkit.Declaration`. The
[consumer guide](../../docs/consumers/guide.md#application-owned-aliases)
shows both forms, and the [composed consumer](../../integrations/ckprompt/examples/composed)
is the maintained integration example.
These aliases require core v0.3.0. Repository contributors can run its test with
`go test ./examples/core` from the root.

162
examples/core/main.go Normal file
View File

@@ -0,0 +1,162 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"text/template"
commandkit "go.maximumdirect.net/commandkit"
)
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
type reportInput struct {
Scenario string
}
type greetingReport struct {
Count string `json:"count"`
Tags string `json:"tags"`
Entered string `json:"entered"`
Canonical string `json:"canonical"`
}
var greetingTemplate = template.Must(commandkit.ParseTextTemplate("greeting", "Hello; count={{.Count}}; tags={{.Tags}}; entered={{printf \"%q\" .Entered}}; canonical={{printf \"%q\" .Canonical}}\n", nil))
var (
errRecoveredFixture = errors.New("a local read failed and was recovered")
errWarningFixture = errors.New("a local warning has a cause")
errPartialFixture = errors.New("the local report could not be completed")
)
func newApp() (*commandkit.App, error) {
countDefault := 1
tagDefaults := []string{"demo"}
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show presence-aware values and repeated labels.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
reporter := invocation.Reporter()
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
_ = reporter.Report(commandkit.Report{Value: greetingReport{Count: count, Tags: tags, Entered: invocation.EnteredPath(), Canonical: invocation.CanonicalPath()}, TextTemplate: greetingTemplate})
return commandkit.Success()
},
})
report := commandkit.NewCommand(commandkit.CommandSpec[reportInput]{
Name: "report",
Summary: "Demonstrate invocation reporting with local scenarios.",
NewInput: func() reportInput {
return reportInput{Scenario: "demo"}
},
Arguments: []commandkit.Argument[reportInput]{
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "scenario", Usage: "demo, recover, warn, warn-error, fail, or partial.", Completion: commandkit.CompleteChoices(
commandkit.CompletionChoice{Value: "demo"}, commandkit.CompletionChoice{Value: "recover"}, commandkit.CompletionChoice{Value: "warn"},
commandkit.CompletionChoice{Value: "warn-error"}, commandkit.CompletionChoice{Value: "fail"}, commandkit.CompletionChoice{Value: "partial"})}, false, func(value string) (string, error) { return value, nil },
func(input *reportInput, value commandkit.Supplied[string]) { input.Scenario = value.Value }),
},
CheckInput: func(input reportInput) error {
switch input.Scenario {
case "demo", "recover", "warn", "warn-error", "fail", "partial":
return nil
default:
return fmt.Errorf("unknown reporting scenario %q", input.Scenario)
}
},
Run: func(invocation commandkit.Invocation, input reportInput) commandkit.Outcome {
return runReportingExample(invocation, input.Scenario)
},
})
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
declarations := []commandkit.Declaration{
workspace,
report,
commandkit.Completion("core-example"),
commandkit.Alias("hello", "workspace greet"),
commandkit.Alias("ws", "workspace"),
commandkit.FlagAliasFor("workspace greet", "--times", "--count"),
commandkit.FlagAlias("--hlep", "--help"),
}
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, declarations...)
}
func runReportingExample(invocation commandkit.Invocation, scenario string) commandkit.Outcome {
reporter := invocation.Reporter()
deferred := reporter.AtEnd()
switch scenario {
case "demo":
_ = reporter.Progress("Loaded one local fixture.")
_ = deferred.Warn("Optional fixture warning", func(writer io.Writer) error {
_, err := io.WriteString(writer, "The sample contains an optional note.\n")
return err
})
_ = deferred.Info("Using deterministic local data.")
_ = deferred.Debug("Selected the built-in reporting example.")
_ = deferred.Info("Summary", writeExampleSummary)
_ = reporter.RenderOutput(writeExampleReport)
return commandkit.Success()
case "recover":
return commandkit.Success().WithCause(errRecoveredFixture)
case "warn":
_ = deferred.Warn("The local operation completed with a warning.")
return commandkit.Success()
case "warn-error":
_ = deferred.Warn("The local operation completed with a warning.")
return commandkit.Success().WithCause(errWarningFixture)
case "fail":
return commandkit.Failure("The selected local operation could not be completed.")
case "partial":
_ = reporter.RenderOutput(writePartialExampleReport)
return commandkit.Failure("The local report stopped after partial output.").WithCause(errPartialFixture)
default:
return commandkit.Usage(fmt.Sprintf("unknown reporting scenario %q", scenario))
}
}
func writeExampleReport(writer io.Writer) error {
_, err := fmt.Fprintln(writer, "Prepared a local report from one fixture.")
return err
}
func writePartialExampleReport(writer io.Writer) error {
_, err := io.WriteString(writer, "Partial local report: first section complete.\n")
return err
}
func writeExampleSummary(writer io.Writer) error {
_, err := io.WriteString(writer, "Processed one local fixture.\n")
return err
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

199
examples/core/main_test.go Normal file
View File

@@ -0,0 +1,199 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
)
func TestAliasesReachCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
args []string
entered string
canonical string
greeting string
}{
{name: "command shortcut and scoped flag", args: []string{"hello", "--times=0", "--tag", "one"}, entered: "hello", canonical: "workspace greet", greeting: "count=explicit 0; tags=explicit [one]"},
{name: "group shortcut", args: []string{"ws", "greet", "--count=2"}, entered: "ws greet", canonical: "workspace greet", greeting: "count=explicit 2"},
{name: "canonical command", args: []string{"workspace", "greet"}, entered: "workspace greet", canonical: "workspace greet", greeting: "count=default 1 (not supplied)"},
} {
t.Run(test.name, func(t *testing.T) {
var output bytes.Buffer
outcome := app.Execute(context.Background(), test.args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v; output %q", test.args, outcome, output.String())
}
for _, fragment := range []string{
test.greeting,
"entered=\"" + test.entered + "\"",
"canonical=\"" + test.canonical + "\"",
} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("output %q does not contain %q", output.String(), fragment)
}
}
})
}
}
func TestGlobalHelpAliasDoesNotRunCoreExampleCommand(t *testing.T) {
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"hello", "--hlep"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "--hlep → --help") || strings.Contains(output.String(), "Hello;") {
t.Fatalf("help alias outcome/output = (%#v, %q)", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"ws", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "greet") || strings.Contains(output.String(), "Hello;") {
t.Fatalf("group shortcut help outcome/output = (%#v, %q)", outcome, output.String())
}
}
func TestReportingExampleModes(t *testing.T) {
for _, test := range []struct {
name string
args []string
wantOutput []string
wantDiag []string
omitDiag []string
}{
{
name: "ordinary result",
args: []string{"report", "demo"},
wantOutput: []string{"Prepared a local report from one fixture."},
omitDiag: []string{"Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG:"},
},
{
name: "progress selected",
args: []string{"report", "demo", "--progress"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"Loaded one local fixture."},
omitDiag: []string{"WARN:", "Using deterministic local data.", "DEBUG:"},
},
{
name: "verbose messages",
args: []string{"report", "demo", "--verbose"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"WARN: Optional fixture warning", "The sample contains an optional note.", "Using deterministic local data.", "Summary", "Processed one local fixture."},
omitDiag: []string{"Loaded one local fixture.", "DEBUG:"},
},
{
name: "debug messages",
args: []string{"report", "demo", "--debug"},
wantOutput: []string{"Prepared a local report from one fixture."},
wantDiag: []string{"WARN: Optional fixture warning", "Using deterministic local data.", "Loaded one local fixture.", "DEBUG: Selected the built-in reporting example.", "Processed one local fixture."},
},
{
name: "quiet suppresses result and messages",
args: []string{"report", "demo", "--quiet"},
},
} {
t.Run(test.name, func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, test.args...)
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("Execute(%q) = %#v", test.args, outcome)
}
for _, fragment := range test.wantOutput {
if !strings.Contains(output, fragment) {
t.Errorf("output %q does not contain %q", output, fragment)
}
}
for _, fragment := range test.wantDiag {
if !strings.Contains(diagnostics, fragment) {
t.Errorf("diagnostics %q does not contain %q", diagnostics, fragment)
}
}
for _, fragment := range test.omitDiag {
if strings.Contains(diagnostics, fragment) {
t.Errorf("diagnostics %q unexpectedly contain %q", diagnostics, fragment)
}
}
if test.name == "quiet suppresses result and messages" && (output != "" || diagnostics != "") {
t.Errorf("quiet output/diagnostics = (%q, %q), want both empty", output, diagnostics)
}
})
}
}
func TestReportingExampleScenarios(t *testing.T) {
t.Run("silent recovery retains cause", func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, "report", "recover")
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errRecoveredFixture) {
t.Fatalf("outcome = %#v, want successful recovery with original cause", outcome)
}
if output != "" || diagnostics != "" {
t.Fatalf("recovery output/diagnostics = (%q, %q), want silent recovery", output, diagnostics)
}
})
t.Run("warning without cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "warn", "--verbose")
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Cause != nil {
t.Fatalf("outcome = %#v, want success without cause", outcome)
}
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") {
t.Fatalf("diagnostics = %q, want explicit warning", diagnostics)
}
})
t.Run("warning with independent cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "warn-error", "--verbose")
if outcome.Kind != commandkit.OutcomeSuccess || !errors.Is(outcome.Cause, errWarningFixture) {
t.Fatalf("outcome = %#v, want successful warning with original cause", outcome)
}
if !strings.Contains(diagnostics, "WARN: The local operation completed with a warning.") || strings.Contains(diagnostics, errWarningFixture.Error()) {
t.Fatalf("diagnostics = %q, want warning text without cause text", diagnostics)
}
})
t.Run("terminal explanation without cause", func(t *testing.T) {
outcome, _, diagnostics := executeExample(t, "report", "fail", "--quiet")
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status == 0 || outcome.Cause != nil {
t.Fatalf("outcome = %#v, want terminal failure without cause", outcome)
}
if outcome.Explanation.Message != "The selected local operation could not be completed." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
}
})
t.Run("partial output keeps cause independent", func(t *testing.T) {
outcome, output, diagnostics := executeExample(t, "report", "partial")
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, errPartialFixture) {
t.Fatalf("outcome = %#v, want terminal failure with original cause", outcome)
}
if !strings.Contains(output, "Partial local report: first section complete.") {
t.Fatalf("output = %q, want completed first section", output)
}
if outcome.Explanation.Message != "The local report stopped after partial output." || !strings.Contains(diagnostics, outcome.Explanation.Message) {
t.Fatalf("explanation/diagnostics = (%q, %q)", outcome.Explanation.Message, diagnostics)
}
if strings.Contains(diagnostics, errPartialFixture.Error()) {
t.Errorf("diagnostics %q unexpectedly contain cause text", diagnostics)
}
})
}
func executeExample(t *testing.T, args ...string) (commandkit.Outcome, string, string) {
t.Helper()
app, err := newApp()
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: &diagnostics})
return outcome, output.String(), diagnostics.String()
}

View File

@@ -1,14 +0,0 @@
# Simple consumer
Run the maintained example with:
```sh
go run . config show
go run . greet
go run . greet --count=0 --tag=first --tag second
```
The `config` family comes from one typed ConfigKit scope. The custom `greet`
command uses CommandKit's typed bindings and shows the difference between an
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
The configuration is synthetic and stays in memory.

View File

@@ -1,90 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type appConfiguration struct {
Greeting string `yaml:"greeting"`
}
type greetInput struct {
Count commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
func loadAppConfiguration(context.Context, commandkit.Selection, struct{}) (*configkit.Result[appConfiguration], error) {
return configkit.Load[appConfiguration](configkit.Bytes("simple example", []byte("greeting: hello\n")))
}
func newApp() (*commandkit.App, error) {
app, err := commandkit.New(commandkit.Identity{Name: "simple-example", Version: "1.0"})
if err != nil {
return nil, err
}
scope, err := commandkit.NewConfigKitScope[struct{}, appConfiguration]("app", "Application settings.", loadAppConfiguration)
if err != nil {
return nil, err
}
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
return nil, err
}
countDefault := 1
tagDefaults := []string{"example"}
greet, err := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
Name: "greet",
Summary: "Show flag presence and repeated values.",
Flags: []commandkit.Flag[greetInput]{
commandkit.ValueFlag[greetInput](commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
input.Count = value
}),
commandkit.RepeatFlag[greetInput](commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
input.Tags = value
}),
},
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
configuration, err := loadAppConfiguration(invocation.Context, commandkit.Selection{}, struct{}{})
if err != nil {
return commandkit.Failure(err)
}
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
if input.Count.Set {
count = fmt.Sprintf("explicit %d", input.Count.Value)
}
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
if input.Tags.Set {
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
}
if _, err := fmt.Fprintf(invocation.Streams.Out, "%s; count=%s; tags=%s\n", configuration.Config.Greeting, count, tags); err != nil {
return commandkit.Failure(err)
}
return commandkit.Success()
},
})
if err != nil {
return nil, err
}
if err := app.Register(greet); err != nil {
return nil, err
}
return app, nil
}
func main() {
app, err := newApp()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}

View File

@@ -5,108 +5,216 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strconv"
"strings"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra" internalcobra "go.maximumdirect.net/commandkit/internal/cobra"
configkit "gitea.maximumdirect.net/eric/ConfigKit" commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
) )
// Execute parses and dispatches one invocation. args excludes the executable // Execute parses and dispatches one invocation. args excludes the executable
// name. CommandKit writes diagnostics to streams.Err and leaves process exit to // name. Supplied values are converted before command input construction; help
// the caller. A nil context returns an operational failure, and nil streams are // does not construct caller inputs or run acquisition callbacks. It applies the
// replaced with io.Discard. // parsed reporting selection, finalizes invocation presentation, and returns
// the resulting Outcome without exiting the process. Supplied streams remain
// caller-owned; nil destinations are replaced with io.Discard. A nil context
// returns an operational failure.
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome { func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
if ctx == nil {
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
}
streams = normalizeStreams(streams)
current := newExecution(a, ctx, streams) current := newExecution(a, ctx, streams)
parsed := current.parse(args) if a == nil {
err := errors.New("cannot execute commands on a nil app")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
if ctx == nil {
err := errors.New("invocation context must not be nil")
return current.finalize(Failure(err.Error()).WithCause(err), nil, false)
}
if current.snapshot != nil && current.snapshot.completion && len(args) > 0 && args[0] == internalcobra.CompletionQueryToken {
return current.complete(args[1:])
}
routedSnapshot := current.snapshot
current.route = resolveEnteredRoute(args, routedSnapshot)
current.recordRouteEvents()
parsed := current.parse(current.route.args)
current.reporter.applySelection(parsed.Reporting)
current.recordSelectionEvents(parsed)
current.submitDebugEvents()
var outcome Outcome
path := parsed.Path
handlerRan := false
if parsed.Failure != nil { if parsed.Failure != nil {
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path) outcome = Usage(parsed.Failure.Message)
} else if current.route.kind == routeDiscovery {
if parsed.HelpRequested {
outcome = current.renderHelp(parsed.Path)
path = clonePath(current.route.enteredPath)
} else {
entered := current.route.enteredPath.String()
outcome = Usage(fmt.Sprintf("incomplete command path %q; use %q --help", entered, entered))
path = clonePath(current.route.enteredPath)
} }
command, ok := a.commandAt(parsed.Path) } else {
if !ok || command.payload == nil { payload, ok := routedSnapshot.runners[parsed.Key]
return current.present(current.renderHelp(parsed.Path), parsed.Path) if !ok || payload == nil {
outcome = current.renderHelp(parsed.Path)
} else if parsed.HelpRequested || !payload.hasRun() {
if _, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, false); err != nil {
outcome = classifyInputError(err)
} else {
outcome = current.renderHelp(parsed.Path)
} }
if parsed.HelpRequested || !command.payload.hasRun() { } else {
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil { run, err := payload.prepare(parsed.FlagValues, parsed.FlagSpellings, parsed.FlagOccurrences, parsed.Operands, true)
return current.present(Usage(err), parsed.Path)
}
return current.present(current.renderHelp(parsed.Path), parsed.Path)
}
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
if err != nil { if err != nil {
return current.present(Usage(err), parsed.Path) outcome = classifyInputError(err)
} else {
handlerRan = true
outcome = run(current)
} }
input := buildInput()
if err := command.payload.checkInput(input); err != nil {
return current.present(Usage(err), parsed.Path)
} }
if len(parsed.Path) == 1 && parsed.Path[0] == "help" {
help, ok := input.(*helpInput)
if !ok {
return current.present(Failure(errors.New("built-in help input has an unexpected type")), parsed.Path)
} }
return current.present(current.renderHelpTargets(help.Targets.Value), parsed.Path) return current.finalize(outcome, path, handlerRan)
}
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input)
return current.present(outcome, parsed.Path)
} }
type execution struct { type execution struct {
app *App app *App
ctx context.Context ctx context.Context
streams Streams streams Streams
options internalcobra.ParseOptions reporter *Reporter
metadata *internalcobra.Command debugEvents []string
options commandmeta.ParseOptions
snapshot *routingSnapshot
session *internalcobra.Session session *internalcobra.Session
route routeResult
}
type commandExecution struct {
invocation Invocation
renderHelpTargetsFunc func([]string) Outcome
}
func (current *execution) commandContext() commandExecution {
return commandExecution{
invocation: current.invocation(),
renderHelpTargetsFunc: current.renderHelpTargets,
}
}
func (current *execution) invocation() Invocation {
return Invocation{
Context: current.ctx,
reporter: current.reporter,
enteredPath: current.route.enteredPath.String(),
canonicalPath: current.route.canonicalPath.String(),
renderHelp: func() Outcome { return current.renderHelp(nil) },
}
}
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
return current.renderHelpTargetsFunc(targets)
} }
func newExecution(app *App, ctx context.Context, streams Streams) *execution { func newExecution(app *App, ctx context.Context, streams Streams) *execution {
streams = normalizeStreams(streams)
current := &execution{ current := &execution{
app: app, app: app,
ctx: ctx, ctx: ctx,
streams: streams, streams: streams,
options: internalcobra.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err}, reporter: newReporter(streams),
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
} }
if app != nil { if app != nil {
current.metadata = app.bridgeTree() current.snapshot = app.routeSnapshot()
current.session = internalcobra.Prepare(current.metadata, current.options)
} }
return current return current
} }
func (current *execution) parse(args []string) internalcobra.Result { func (current *execution) parse(args []string) commandmeta.Result {
if current.app == nil { if current.app == nil {
return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}} return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
}
if current.session == nil {
current.prepareSession()
}
return reduceReportingOccurrences(current.session.Parse(args))
}
func (current *execution) prepareSession() {
if current.app == nil {
return
}
if current.snapshot == nil {
current.snapshot = current.app.routeSnapshot()
}
options := current.options
if current.snapshot != nil {
switch current.route.kind {
case routeCanonical:
options.FlagAliases = current.snapshot.flagAliases[current.route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = current.snapshot.discoveryFlags[current.route.discoveryPath.Key()].neutralRecords()
default:
snapshot := current.snapshot
options.FlagAliasesForPath = func(path []string) []commandmeta.FlagAlias {
return snapshot.flagAliases[commandpath.FromTokens(path).Key()].neutralRecords()
}
}
current.session = internalcobra.Prepare(current.snapshot.metadata, options)
} }
return current.session.Parse(args)
} }
func (current *execution) presentationSession() *internalcobra.Session { func (current *execution) presentationSession() *internalcobra.Session {
if current.app == nil { if current.app == nil {
return nil return nil
} }
metadata := current.app.bridgeTree() snapshot := current.app.routeSnapshot()
if current.session == nil || current.session.Metadata() != metadata { if current.session == nil || current.snapshot != snapshot {
current.metadata = metadata current.snapshot = snapshot
current.session = internalcobra.Prepare(metadata, current.options) current.session = nil
current.prepareSession()
} }
return current.session return current.session
} }
func (current *execution) renderHelp(path []string) Outcome { func (current *execution) renderHelp(path []string) Outcome {
if current.app == nil { if current.app == nil {
return Failure(errors.New("cannot render help on a nil app")) err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
} }
return current.app.renderHelpOutcomeWithSession(path, current.streams, current.presentationSession()) session := current.presentationSession()
view := current.app.helpView(current.route, current.snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
} }
func (current *execution) renderHelpTargets(targets []string) Outcome { func (current *execution) renderHelpTargets(targets []string) Outcome {
if current.app == nil { if current.app == nil {
return Failure(errors.New("cannot render help on a nil app")) err := errors.New("cannot render help on a nil app")
return Failure(err.Error()).WithCause(err)
} }
return current.app.renderHelpTargetsWithSession(targets, current.streams, current.presentationSession()) snapshot := current.app.routeSnapshot()
if snapshot == nil {
err := errors.New("cannot render help without command metadata")
return Failure(err.Error()).WithCause(err)
}
route := resolveEnteredRoute(targets, snapshot)
// Explicit help targets are paths, not command invocations with operands.
// Discovery routes have no canonical path and retain only trailing options.
if route.kind == routeUnknown || len(route.args) != len(route.canonicalPath) {
session := internalcobra.Prepare(snapshot.metadata, current.options)
view := commandmeta.HelpView{EnteredPath: targets, CanonicalPath: targets}
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
}
options := current.options
switch route.kind {
case routeCanonical:
options.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
case routeDiscovery:
options.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
}
session := internalcobra.Prepare(snapshot.metadata, options)
view := current.app.helpView(route, snapshot)
return current.app.renderHelpViewOutcomeWithSession(view, current.reporter, session)
} }
func (current *execution) usageLine(path []string) string { func (current *execution) usageLine(path []string) string {
@@ -122,104 +230,138 @@ func (current *execution) usageLine(path []string) string {
return current.app.usageFallback() return current.app.usageFallback()
} }
func (current *execution) present(outcome Outcome, path []string) Outcome { func (current *execution) routeUsageLine() string {
return current.app.presentWithUsageLine(outcome, path, current.streams, current.usageLine) line := current.usageLine(clonePath(current.route.canonicalPath))
canonical := strings.TrimSpace(current.app.identity.Name + " " + current.route.canonicalPath.String())
entered := strings.TrimSpace(current.app.identity.Name + " " + current.route.enteredPath.String())
line = strings.Replace(line, "Usage: "+canonical, "Usage: "+entered, 1)
return line + "\nAlias for: " + canonical
} }
func normalizeStreams(streams Streams) Streams { func (current *execution) recordRouteEvents() {
if streams.Out == nil { entered := current.route.enteredPath.String()
streams.Out = io.Discard canonical := current.route.canonicalPath.String()
switch current.route.kind {
case routeCanonical:
if entered == canonical {
if canonical == "" {
current.debugEvents = append(current.debugEvents, "selected the application root command")
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected command path %q", canonical))
}
} else {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("resolved command alias %q to %q", entered, canonical))
}
case routeDiscovery:
current.debugEvents = append(current.debugEvents, fmt.Sprintf("selected discovery route for prefix %q", entered))
default:
if entered != "" {
current.debugEvents = append(current.debugEvents, fmt.Sprintf("no canonical route matched command path %q", entered))
} }
if streams.Err == nil {
streams.Err = io.Discard
} }
return streams
} }
func (a *App) commandAt(path []string) (Command, bool) { func (current *execution) recordSelectionEvents(result commandmeta.Result) {
if a == nil { mode := "ordinary"
return Command{}, false flagName := ""
switch result.Reporting.Mode {
case commandmeta.ReportingModeQuiet:
mode, flagName = "quiet", "quiet"
case commandmeta.ReportingModeVerbose:
mode, flagName = "verbose", "verbose"
case commandmeta.ReportingModeDebug:
mode, flagName = "debug", "debug"
} }
if len(path) == 0 { event := "selected ordinary reporting mode"
return Command{}, true if flagName != "" {
event = fmt.Sprintf("selected %s reporting mode", mode)
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name != flagName {
continue
} }
command, ok := a.commands[path[0]] enabled, err := strconv.ParseBool(occurrence.RawValue)
if !ok || (command.reserved && command.command.payload == nil) { if err != nil || !enabled {
return Command{}, false continue
}
canonical := "--" + flagName
if occurrence.Spelling != canonical {
event += fmt.Sprintf(" using %s (alias for %s)", occurrence.Spelling, canonical)
} else {
event += fmt.Sprintf(" using %s", canonical)
} }
current := command.command
for _, name := range path[1:] {
found := false
for _, child := range current.children {
if child.name == name {
current = child
found = true
break break
} }
} }
if !found { current.debugEvents = append(current.debugEvents, event)
return Command{}, false if result.Reporting.ProgressSet {
state := "disabled"
if result.Reporting.Progress {
state = "enabled"
}
event := "explicitly " + state + " progress output"
for index := len(result.FlagOccurrences) - 1; index >= 0; index-- {
occurrence := result.FlagOccurrences[index]
if occurrence.Name == "progress" {
if occurrence.Spelling != "--progress" {
event += fmt.Sprintf(" using %s (alias for --progress)", occurrence.Spelling)
}
break
} }
} }
return current, true current.debugEvents = append(current.debugEvents, event)
}
} }
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome { func (current *execution) submitDebugEvents() {
return a.presentWithUsageLine(outcome, path, streams, a.usageLine) events := current.debugEvents
current.debugEvents = nil
for _, event := range events {
_ = current.reporter.Debug(event)
}
} }
func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Streams, usageLine func([]string) string) Outcome { func (current *execution) finalize(outcome Outcome, path []string, handlerRan bool) Outcome {
if outcome.Kind == "" && outcome.Status == 0 && outcome.Domain == "" &&
outcome.Cause == nil && outcome.DeliveryError == nil &&
!outcome.ReportIncomplete && len(outcome.Diagnostics) == 0 {
outcome.Kind = OutcomeSuccess
}
if outcome.DiagnosticDelivery == "" {
outcome.DiagnosticDelivery = DeliveryNone
}
streams = normalizeStreams(streams)
if outcome.DiagnosticDelivery == DeliveryComplete {
return outcome
}
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
}
if outcome.Kind == OutcomeUsage { if outcome.Kind == OutcomeUsage {
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, usageLine(path))) details := append([]Detail(nil), outcome.Explanation.Details...)
details = append(details, func(w io.Writer) error {
line := current.usageText(path)
if !strings.HasSuffix(line, "\n") {
line += "\n"
} }
outcome.Diagnostics = diagnostics _, err := io.WriteString(w, line)
if len(diagnostics) == 0 { return err
return outcome })
outcome.Explanation.Details = details
} }
delivered := false var completion func(*Reporter)
for _, diagnostic := range diagnostics { if handlerRan {
if diagnostic.Write == nil { completion = func(reporter *Reporter) {
continue kind := outcome.Kind
if kind == "" && outcome.Status == 0 && outcome.Domain == "" {
kind = OutcomeSuccess
} }
delivered = true path := current.route.canonicalPath.String()
writer := &checkedWriter{writer: streams.Err} _ = reporter.Debug(fmt.Sprintf("handler completed for %q with %s outcome and status %d", path, kind, outcome.Status))
writeErr := diagnostic.Write(writer)
if writer.failure != nil {
if writeErr == nil {
writeErr = writer.failure
} else if !errors.Is(writeErr, writer.failure) {
writeErr = errors.Join(writeErr, writer.failure)
} }
} }
if writeErr != nil { return current.reporter.finalize(outcome, completion)
return deliveryFailure(outcome, writeErr) }
func (current *execution) usageText(path []string) string {
switch {
case current.route.kind == routeDiscovery && current.app != nil:
return fmt.Sprintf("Usage: %s %s [command]", current.app.identity.Name, current.route.enteredPath.String())
case current.route.kind == routeCanonical && !current.route.enteredPath.Equal(current.route.canonicalPath):
return current.routeUsageLine()
default:
return current.usageLine(path)
} }
}
if delivered {
outcome.DiagnosticDelivery = DeliveryComplete
}
return outcome
} }
func (a *App) usageLine(path []string) string { func (a *App) usageLine(path []string) string {
if a != nil { if a != nil {
if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil { if usage, failure := internalcobra.UsageLine(a.routeSnapshot().metadata, path); failure == nil {
return "Usage: " + usage return "Usage: " + usage
} }
} }
@@ -233,42 +375,3 @@ func (a *App) usageFallback() string {
} }
return fmt.Sprintf("Usage: %s [command]", name) return fmt.Sprintf("Usage: %s [command]", name)
} }
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Write != nil && diagnostic.Severity == configkit.SeverityError {
return true
}
}
return false
}
func deliveryFailure(outcome Outcome, err error) Outcome {
if outcome.DeliveryError == nil {
outcome.DeliveryError = err
} else {
outcome.DeliveryError = errors.Join(outcome.DeliveryError, err)
}
outcome.DiagnosticDelivery = DeliveryIncomplete
if outcome.Kind != OutcomeDomain {
outcome.Kind = OutcomeOperational
outcome.Status = 1
}
return outcome
}
type checkedWriter struct {
writer io.Writer
failure error
}
func (w *checkedWriter) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
if err != nil && w.failure == nil {
w.failure = err
}
return n, err
}

View File

@@ -9,8 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
commandkit "gitea.maximumdirect.net/eric/CommandKit" commandkit "go.maximumdirect.net/commandkit"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
) )
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) { func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
@@ -20,11 +19,11 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
} }
var inputs []input var inputs []input
var contexts []context.Context var contexts []context.Context
var streams []commandkit.Streams var reporters []*commandkit.Reporter
firstContext := context.WithValue(context.Background(), struct{}{}, "first") firstContext := context.WithValue(context.Background(), struct{}{}, "first")
secondContext := context.WithValue(context.Background(), struct{}{}, "second") secondContext := context.WithValue(context.Background(), struct{}{}, "second")
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{ Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }), commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
@@ -33,13 +32,15 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome { Run: func(invocation commandkit.Invocation, value input) commandkit.Outcome {
inputs = append(inputs, value) inputs = append(inputs, value)
contexts = append(contexts, invocation.Context) contexts = append(contexts, invocation.Context)
streams = append(streams, invocation.Streams) reporter := invocation.Reporter()
reporters = append(reporters, reporter)
_ = reporter.RenderOutput(func(writer io.Writer) error {
_, err := io.WriteString(writer, value.name.Value+"\n")
return err
})
return commandkit.Success() return commandkit.Success()
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -56,11 +57,30 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set { if inputs[1].name.Set || inputs[1].name.Value != "" || inputs[1].ids.Set {
t.Fatalf("second invocation inherited state: %#v", inputs[1]) t.Fatalf("second invocation inherited state: %#v", inputs[1])
} }
if streams[2].Out != io.Discard || streams[2].Err != io.Discard { if reporters[0] == nil || reporters[1] == nil || reporters[2] == nil || reporters[0] == reporters[1] || reporters[0] == reporters[2] || reporters[1] == reporters[2] {
t.Fatalf("nil streams were not replaced with io.Discard: %#v", streams[2]) t.Fatalf("invocations did not receive fresh reporters: %#v", reporters)
} }
if contexts[0] != firstContext || contexts[1] != secondContext || streams[0].Out != &firstOut || streams[0].Err != &firstDiagnostic || streams[1].Out != &secondOut || streams[1].Err != &secondDiagnostic { if firstOut.String() != "first\n" || secondOut.String() != "\n" || contexts[0] != firstContext || contexts[1] != secondContext {
t.Fatalf("handler invocation contexts or streams were not forwarded independently") t.Fatalf("handler invocation contexts or reporter destinations were not independent: out=%q/%q contexts=%v/%v", firstOut.String(), secondOut.String(), contexts[0], contexts[1])
}
}
func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
command := commandkit.NewCommand(commandkit.CommandSpec[*int]{
Name: "run",
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
if value != nil {
return commandkit.Failure("omitted input factory allocated a pointer")
}
return commandkit.Success()
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}); outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("pointer zero value outcome = %#v", outcome)
} }
} }
@@ -70,7 +90,7 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
enabled commandkit.Supplied[bool] enabled commandkit.Supplied[bool]
} }
var received []input var received []input
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{ Flags: []commandkit.Flag[input]{
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) {
@@ -83,9 +103,6 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
return commandkit.Success() return commandkit.Success()
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -115,27 +132,21 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
usageCause := errors.New("handler requests usage") usageCause := errors.New("handler requests usage")
var app *commandkit.App var app *commandkit.App
var calls int var calls int
late, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "late", Name: "late",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
}) })
if err != nil { command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
t.Fatal(err)
}
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
calls++ calls++
if err := app.Register(late); err != nil { if err := app.Register(late); err != nil {
return commandkit.Failure(err) return commandkit.Failure(err.Error()).WithCause(err)
} }
return commandkit.Usage(usageCause) return commandkit.Usage("The handler requests usage.").WithCause(usageCause)
}, },
}) })
if err != nil { app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -156,7 +167,7 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
type input struct{ count commandkit.Supplied[int] } type input struct{ count commandkit.Supplied[int] }
parseFailure := errors.New("bad count") parseFailure := errors.New("bad count")
checkFailure := errors.New("incompatible inputs") checkFailure := errors.New("incompatible inputs")
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) { Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
if value == "bad" { if value == "bad" {
@@ -168,9 +179,6 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
CheckInput: func(input) error { return checkFailure }, CheckInput: func(input) error { return checkFailure },
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -192,120 +200,86 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) { func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
domainCause := errors.New("domain rejected the request") domainCause := errors.New("domain rejected the request")
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed") command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, domainCause) _ = invocation.Reporter().Warn("review needed")
outcome.Diagnostics = []commandkit.Diagnostic{warning} outcome := commandkit.Domain("sample", 2).WithExplanation("The request was rejected.").WithCause(domainCause)
return outcome return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostic bytes.Buffer var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic}) outcome := app.Execute(context.Background(), []string{"run", "--verbose"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) { if outcome.Kind != commandkit.OutcomeDomain || outcome.Domain != "sample" || outcome.Status != 2 || !errors.Is(outcome.Cause, domainCause) {
t.Fatalf("domain outcome changed: %#v", outcome) t.Fatalf("domain outcome changed: %#v", outcome)
} }
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete { if diagnostic.String() != "WARN: review needed\nERROR: The request was rejected.\n" || outcome.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome) t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
} }
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "warn", Name: "warn",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(invocation commandkit.Invocation, _ struct{}) commandkit.Outcome {
result := commandkit.Success() _ = invocation.Reporter().Warn("review needed")
result.Diagnostics = []commandkit.Diagnostic{warning} return commandkit.Success()
return result
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly) app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
diagnostic.Reset() diagnostic.Reset()
outcome = app.Execute(context.Background(), []string{"warn"}, commandkit.Streams{Err: &diagnostic}) outcome = app.Execute(context.Background(), []string{"warn", "--verbose"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "review needed\n" { if outcome.Kind != commandkit.OutcomeSuccess || diagnostic.String() != "WARN: review needed\n" {
t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String()) t.Fatalf("warning-only outcome = %#v, diagnostics %q", outcome, diagnostic.String())
} }
} }
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) { func TestExecuteDoesNotPresentOperationCauseWithoutExplanation(t *testing.T) {
cause := errors.New("already shown") cause := errors.New("private operation detail")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Domain("sample", 2, cause) return commandkit.Domain("sample", 2).WithCause(cause)
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "presented\n")
return err
}}}
return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostic bytes.Buffer var diagnostic bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic}) outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if outcome.Kind != commandkit.OutcomeDomain || diagnostic.String() != "presented\n" { if outcome.Kind != commandkit.OutcomeDomain || !errors.Is(outcome.Cause, cause) {
t.Fatalf("presented cause was duplicated: %#v, %q", outcome, diagnostic.String()) t.Fatalf("domain outcome changed: %#v", outcome)
} }
if strings.Contains(diagnostic.String(), cause.Error()) || !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
diagnostic.Reset() t.Fatalf("cause was presented instead of the fallback explanation: %#v, %q", outcome, diagnostic.String())
completed := commandkit.Domain("sample", 2, cause)
completed.DiagnosticDelivery = commandkit.DeliveryComplete
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
if err != nil {
t.Fatal(err)
}
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
outcome = app.Execute(context.Background(), []string{"done"}, commandkit.Streams{Err: &diagnostic})
if diagnostic.Len() != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
t.Fatalf("completed diagnostics were repeated: %#v, %q", outcome, diagnostic.String())
} }
} }
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) { func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
operationFailure := context.Canceled operationFailure := context.Canceled
writeFailure := errors.New("writer failed") writeFailure := errors.New("writer failed")
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{ command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
outcome := commandkit.Failure(operationFailure) return commandkit.Failure("The operation was canceled.").WithCause(operationFailure)
outcome.ReportIncomplete = true
return outcome
}, },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}}) outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{err: writeFailure}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.DeliveryError, writeFailure) { if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(outcome.Cause, operationFailure) || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeFailure) {
t.Fatalf("combined operation and delivery failure = %#v", outcome) t.Fatalf("combined operation and delivery failure = %#v", outcome)
} }
short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}}) short := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: shortWriter{}})
if !errors.Is(short.DeliveryError, io.ErrShortWrite) || short.DiagnosticDelivery != commandkit.DeliveryIncomplete { if !errors.Is(short.Presentation.Diagnostics.WriteError, io.ErrShortWrite) || short.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("short write was not detected: %#v", short) t.Fatalf("short write was not detected: %#v", short)
} }
} }
@@ -313,7 +287,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) { func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
type input struct{ id commandkit.Supplied[string] } type input struct{ id commandkit.Supplied[string] }
factoryCalls, checkCalls, runCalls := 0, 0, 0 factoryCalls, checkCalls, runCalls := 0, 0, 0
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ command := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "run", Name: "run",
NewInput: func() input { NewInput: func() input {
factoryCalls++ factoryCalls++
@@ -323,9 +297,6 @@ func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
CheckInput: func(input) error { checkCalls++; return nil }, CheckInput: func(input) error { checkCalls++; return nil },
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
}) })
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -344,7 +315,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
type input struct{ topic commandkit.Supplied[string] } type input struct{ topic commandkit.Supplied[string] }
var received []input var received []input
domainCause := errors.New("application help result") domainCause := errors.New("application help result")
help, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ help := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "help", Name: "help",
Description: "Explain a session topic.", Description: "Explain a session topic.",
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument( Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(
@@ -354,16 +325,10 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
)}, )},
Run: func(_ commandkit.Invocation, value input) commandkit.Outcome { Run: func(_ commandkit.Invocation, value input) commandkit.Outcome {
received = append(received, value) received = append(received, value)
return commandkit.Domain("session-help", 7, domainCause) return commandkit.Domain("session-help", 7).WithExplanation("Session help is unavailable.").WithCause(domainCause)
}, },
}) })
if err != nil { session := commandkit.Group("session", "Session commands.", help)
t.Fatal(err)
}
session, err := commandkit.Group("session", "Session commands.", help)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, session) app, err := commandkit.New(commandkit.Identity{Name: "example"}, session)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -401,7 +366,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
tags commandkit.Supplied[[]string] tags commandkit.Supplied[[]string]
} }
factoryCalls, runCalls := 0, 0 factoryCalls, runCalls := 0, 0
show, err := commandkit.NewCommand(commandkit.CommandSpec[input]{ show := commandkit.NewCommand(commandkit.CommandSpec[input]{
Name: "show", Name: "show",
Summary: "Show one item.", Summary: "Show one item.",
Description: "Display the selected item in detail.", Description: "Display the selected item in detail.",
@@ -418,25 +383,9 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})}, Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})},
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() }, Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
}) })
if err != nil { config := commandkit.Group("config", "Inspect configuration.", show)
t.Fatal(err) now := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
} deploy := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Children: []commandkit.Command{now}, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
config, err := commandkit.Group("config", "Inspect configuration.", show)
if err != nil {
t.Fatal(err)
}
now, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
if err != nil {
t.Fatal(err)
}
deployParent, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
if err != nil {
t.Fatal(err)
}
deploy, err := commandkit.WithChildren(deployParent, now)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy) app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -500,7 +449,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls) t.Fatalf("version invoked an application command: %#v calls=%d/%d", versionOutcome, factoryCalls, runCalls)
} }
failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) failed := app.Execute(context.Background(), []string{"config", "show", "--help"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed help delivery = %#v", failed) t.Fatalf("failed help delivery = %#v", failed)
} }
out.Reset() out.Reset()
@@ -526,10 +475,15 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
} }
out.Reset() out.Reset()
yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic}) yamlOutcome := app.Execute(context.Background(), []string{"version", "--format=yaml"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if yamlOutcome.Kind != commandkit.OutcomeSuccess || out.String() != "name: ExampleApp\nversion: dev\nrevision: abc123\n" { if yamlOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), "name: ExampleApp\n") || !strings.Contains(out.String(), "version: dev\n") || !strings.Contains(out.String(), "revision: abc123\n") {
t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String()) t.Fatalf("YAML version = %#v, output %q", yamlOutcome, out.String())
} }
out.Reset() out.Reset()
jsonOutcome := app.Execute(context.Background(), []string{"version", "--format=json"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if jsonOutcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"revision": "abc123"`) {
t.Fatalf("JSON version = %#v, output %q", jsonOutcome, out.String())
}
out.Reset()
component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic}) component := app.Execute(context.Background(), []string{"version", "--scope=commandkit"}, commandkit.Streams{Out: &out, Err: &diagnostic})
if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") { if component.Kind != commandkit.OutcomeSuccess || !strings.HasPrefix(out.String(), "CommandKit ") || !strings.HasSuffix(out.String(), "\n") {
t.Fatalf("CommandKit version = %#v, output %q", component, out.String()) t.Fatalf("CommandKit version = %#v, output %q", component, out.String())
@@ -540,7 +494,7 @@ func TestVersionCommandFormatsApplicationAndCommandKitIdentity(t *testing.T) {
t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String()) t.Fatalf("invalid version scope = %#v, output %q", invalid, out.String())
} }
failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic}) failed := app.Execute(context.Background(), []string{"version"}, commandkit.Streams{Out: shortWriter{}, Err: &diagnostic})
if failed.Kind != commandkit.OutcomeOperational || !failed.ReportIncomplete || !errors.Is(failed.DeliveryError, io.ErrShortWrite) { if failed.Kind != commandkit.OutcomeOperational || failed.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(failed.Presentation.Output.WriteError, io.ErrShortWrite) {
t.Fatalf("failed version delivery = %#v", failed) t.Fatalf("failed version delivery = %#v", failed)
} }
} }
@@ -559,10 +513,7 @@ func (shortWriter) Write(data []byte) (int, error) {
} }
func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) { func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
group, err := commandkit.Group("notes", "Notes metadata.") group := commandkit.Group("notes", "Notes metadata.")
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, group) app, err := commandkit.New(commandkit.Identity{Name: "example"}, group)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -572,11 +523,11 @@ func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} { for _, args := range [][]string{nil, {"notes"}, {"--help"}, {"-h"}, {"notes", "--help"}, {"help"}, {"help", "notes"}} {
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics}) result := app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: &diagnostics})
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || result.DiagnosticDelivery != commandkit.DeliveryComplete || strings.Count(diagnostics.String(), reportErr.Error()) != 1 { if result.Kind != commandkit.OutcomeOperational || result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete || (!strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output")) {
t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String()) t.Fatalf("%v: result=%#v diagnostics=%q", args, result, diagnostics.String())
} }
result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}}) result = app.Execute(context.Background(), args, commandkit.Streams{Out: failingWriter{reportErr}, Err: failingWriter{diagnosticErr}})
if result.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || !errors.Is(result.DeliveryError, diagnosticErr) { if result.Presentation.Output.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Output.WriteError, reportErr) || result.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete || !errors.Is(result.Presentation.Diagnostics.WriteError, diagnosticErr) {
t.Fatalf("%v: result=%#v", args, result) t.Fatalf("%v: result=%#v", args, result)
} }
} }
@@ -595,104 +546,39 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause}, {commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause},
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil}, {commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil},
} { } {
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }}) command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var diagnostic bytes.Buffer
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostic})
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause {
t.Fatalf("result=%#v expected=%#v", result, tc)
}
if tc.status != 0 && !strings.Contains(diagnostic.String(), "ERROR: Command failed.") {
t.Fatalf("failed outcome omitted its fallback explanation: %q", diagnostic.String())
}
if tc.cause != nil && strings.Contains(diagnostic.String(), tc.cause.Error()) {
t.Fatalf("operation cause was presented automatically: %q", diagnostic.String())
}
}
}
func TestNilDiagnosticWriterDoesNotHideOperationCause(t *testing.T) {
cause := errors.New("operation failed")
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run",
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
return commandkit.Failure("The operation failed.").WithCause(cause)
},
})
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command) app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}) result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{})
wantDelivery := commandkit.DeliveryNone if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) || result.Presentation.Diagnostics.State != commandkit.DeliveryComplete {
if tc.cause != nil { t.Fatalf("nil diagnostic destination changed the outcome: %#v", result)
wantDelivery = commandkit.DeliveryComplete
}
if result.Kind != tc.kind || result.Status != tc.status || result.Domain != tc.domain || result.Cause != tc.cause || result.DiagnosticDelivery != wantDelivery {
t.Fatalf("result=%#v expected=%#v", result, tc)
}
}
}
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
cause := errors.New("operation failed")
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: configkit.SeverityWarning}, {Severity: configkit.SeverityError}}
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
var diagnostics bytes.Buffer
result := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: &diagnostics})
want := commandkit.DeliveryNone
if base.Cause != nil {
want = commandkit.DeliveryComplete
}
if result.Kind != base.Kind || result.Status != base.Status || result.Domain != base.Domain || result.Cause != base.Cause || result.DeliveryError != nil || result.DiagnosticDelivery != want {
t.Fatalf("result=%#v base=%#v", result, base)
}
if base.Cause == nil && diagnostics.Len() != 0 {
t.Fatalf("unexpected output=%q", diagnostics.String())
}
if base.Cause != nil && strings.Count(diagnostics.String(), cause.Error()) != 1 {
t.Fatalf("cause output=%q", diagnostics.String())
}
if base.Cause != nil {
result = app.Execute(context.Background(), []string{"run"}, commandkit.Streams{Err: failingWriter{io.ErrClosedPipe}})
if !errors.Is(result.Cause, cause) || !errors.Is(result.DeliveryError, io.ErrClosedPipe) || result.DiagnosticDelivery != commandkit.DeliveryIncomplete {
t.Fatalf("failed delivery=%#v", result)
}
}
}
}
func TestParentHelpDiscoversMetadataOnlyGroups(t *testing.T) {
leaf, err := commandkit.Group("notes", "Read notes metadata.")
if err != nil {
t.Fatal(err)
}
parent, err := commandkit.Group("topics", "Browse topic metadata.", leaf)
if err != nil {
t.Fatal(err)
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, parent)
if err != nil {
t.Fatal(err)
}
loads := 0
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
loads++
return commandkit.Acquired[commandkit.ConfigView]{}, nil
}}}}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
content string
}{
{nil, "Browse topic metadata."},
{[]string{"topics"}, "Read notes metadata."},
{[]string{"topics", "--help"}, "Read notes metadata."},
{[]string{"help", "topics"}, "Read notes metadata."},
{[]string{"topics", "notes"}, "Read notes metadata."},
{[]string{"help", "topics", "notes"}, "Read notes metadata."},
} {
var out bytes.Buffer
result := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &out})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), tc.content) || loads != 0 {
t.Fatalf("%v: result=%#v output=%q loads=%d", tc.args, result, out.String(), loads)
}
if len(tc.args) == 1 && (!strings.Contains(out.String(), "Available Commands:") || !strings.Contains(out.String(), "[command]")) {
t.Fatalf("missing child inventory/usage: %q", out.String())
}
}
var diagnostic bytes.Buffer
result := app.Execute(context.Background(), []string{"topics", "unknown"}, commandkit.Streams{Err: &diagnostic})
if result.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "[command]") {
t.Fatalf("result=%#v diagnostic=%q", result, diagnostic.String())
} }
} }

189
execute_reporting_test.go Normal file
View File

@@ -0,0 +1,189 @@
package commandkit
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
)
func TestExecuteGivesEachInvocationItsReporterAndAppliesSelection(t *testing.T) {
var reporters []*Reporter
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
reporter := inv.Reporter()
reporters = append(reporters, reporter)
if _, err := fmt.Fprint(reporter.Output(), "result\n"); err != nil {
t.Errorf("write result: %v", err)
}
if err := reporter.Warn("attention"); err != nil {
t.Errorf("submit warning: %v", err)
}
return Outcome{
Kind: OutcomeDomain, Domain: "accepted", Status: 0,
Presentation: PresentationResult{
Output: DeliveryResult{State: DeliveryIncomplete, WriteError: io.ErrClosedPipe},
},
}
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
var quietOut, quietErr bytes.Buffer
quiet := app.Execute(context.Background(), []string{"run", "--quiet"}, Streams{Out: &quietOut, Err: &quietErr})
if quiet.Kind != OutcomeDomain || quiet.Status != 0 || quiet.Domain != "accepted" {
t.Fatalf("quiet outcome = %#v", quiet)
}
if quietOut.Len() != 0 || quietErr.Len() != 0 {
t.Fatalf("quiet output = %q, diagnostics = %q", quietOut.String(), quietErr.String())
}
if quiet.Presentation.Output.State != DeliverySuppressed || quiet.Presentation.Diagnostics.State != DeliverySuppressed {
t.Fatalf("quiet presentation = %#v", quiet.Presentation)
}
var verboseOut, verboseErr bytes.Buffer
verbose := app.Execute(context.Background(), []string{"run", "--verbose"}, Streams{Out: &verboseOut, Err: &verboseErr})
if verbose.Kind != OutcomeDomain || verbose.Status != 0 {
t.Fatalf("verbose outcome = %#v", verbose)
}
if verboseOut.String() != "result\n" || verboseErr.String() != "WARN: attention\n" {
t.Fatalf("verbose output = %q, diagnostics = %q", verboseOut.String(), verboseErr.String())
}
if verbose.Presentation.Output.State != DeliveryComplete || verbose.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("verbose presentation = %#v", verbose.Presentation)
}
if len(reporters) != 2 || reporters[0] == nil || reporters[1] == nil || reporters[0] == reporters[1] {
t.Fatalf("invocation reporters = %#v", reporters)
}
if _, err := reporters[0].Output().Write([]byte("late")); !errors.Is(err, ErrReporterClosed) {
t.Fatalf("write through finalized reporter error = %v", err)
}
}
func TestExecuteQuietUsageKeepsAliasGuidanceVisible(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(Invocation, struct{}) Outcome { return Success() },
})
app, err := New(Identity{Name: "Example"}, command, Alias("r", "run"))
if err != nil {
t.Fatal(err)
}
var output, diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"r", "--quiet", "--unknown"}, Streams{Out: &output, Err: &diagnostics})
if outcome.Kind != OutcomeUsage || outcome.Status != 2 {
t.Fatalf("outcome = %#v", outcome)
}
if output.Len() != 0 {
t.Fatalf("usage output = %q", output.String())
}
if got := diagnostics.String(); !strings.Contains(got, "ERROR: unknown flag") || !strings.Contains(got, "Usage: Example r") || !strings.Contains(got, "Alias for: Example run") {
t.Fatalf("usage diagnostics = %q", got)
}
if outcome.Presentation.Diagnostics.State != DeliveryComplete {
t.Fatalf("diagnostic presentation = %#v", outcome.Presentation.Diagnostics)
}
}
func TestUnresolvedRoutesUseReportingAliasesForTheirParseContext(t *testing.T) {
calls := 0
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
NewInput: func() struct{} { calls++; return struct{}{} },
Run: func(Invocation, struct{}) Outcome { calls++; return Success() },
})
app, err := New(Identity{Name: "Example"}, Group("group", "", Group("nested", "", command)),
FlagAlias("-d", "--debug"), FlagAlias("-q", "--quiet"),
FlagAlias("-x", "--quiet"), FlagAliasFor("group nested", "-x", "--debug"))
if err != nil {
t.Fatal(err)
}
for _, path := range [][]string{{"missing"}, {"group", "missing"}, {"group", "nested", "missing"}} {
t.Run(strings.Join(path, "/"), func(t *testing.T) {
var canonicalDiagnostics, aliasDiagnostics bytes.Buffer
canonical := app.Execute(context.Background(), append(append([]string(nil), path...), "--debug"), Streams{Err: &canonicalDiagnostics})
alias := app.Execute(context.Background(), append(append([]string(nil), path...), "-d"), Streams{Err: &aliasDiagnostics})
if canonical.Kind != OutcomeUsage || alias.Kind != OutcomeUsage || canonical.Status != alias.Status || canonical.Explanation.Message != alias.Explanation.Message {
t.Fatalf("canonical=%#v alias=%#v", canonical, alias)
}
for _, text := range []string{canonicalDiagnostics.String(), aliasDiagnostics.String()} {
if !strings.Contains(text, "DEBUG:") || !strings.Contains(text, "ERROR:") || strings.Contains(text, "unknown shorthand") || strings.Contains(text, "Alias for:") {
t.Fatalf("diagnostics=%q", text)
}
}
route := resolveEnteredRoute(path, app.routeSnapshot())
if route.kind != routeUnknown || len(route.canonicalPath) != 0 {
t.Fatalf("invalid route acquired canonical identity: %#v", route)
}
var quiet bytes.Buffer
got := app.Execute(context.Background(), append(append([]string(nil), path...), "-q", "--unknown"), Streams{Err: &quiet})
if got.Kind != OutcomeUsage || !strings.Contains(quiet.String(), "ERROR: unknown flag") || strings.Contains(quiet.String(), "DEBUG:") {
t.Fatalf("quiet alias outcome=%#v diagnostics=%q", got, quiet.String())
}
})
}
var scoped bytes.Buffer
got := app.Execute(context.Background(), []string{"group", "nested", "missing", "-x"}, Streams{Err: &scoped})
if got.Kind != OutcomeUsage || !strings.Contains(scoped.String(), "DEBUG:") {
t.Fatalf("scoped precedence outcome=%#v diagnostics=%q", got, scoped.String())
}
if calls != 0 {
t.Fatalf("invalid routes ran factories or handlers %d times", calls)
}
}
type executeReportingFailureWriter struct{ err error }
func (w executeReportingFailureWriter) Write([]byte) (int, error) { return 0, w.err }
func TestExecuteFinalizesEarlyAndIgnoredWriterFailures(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
Run: func(inv Invocation, _ struct{}) Outcome {
_, _ = inv.Reporter().Output().Write([]byte("report"))
return Success()
},
})
app, err := New(Identity{Name: "Example"}, command)
if err != nil {
t.Fatal(err)
}
writeErr := errors.New("result destination failed")
var diagnostics bytes.Buffer
outcome := app.Execute(context.Background(), []string{"run"}, Streams{
Out: executeReportingFailureWriter{err: writeErr}, Err: &diagnostics,
})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || !errors.Is(outcome.Presentation.Output.WriteError, writeErr) {
t.Fatalf("ignored output failure outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "deliver") || !strings.Contains(diagnostics.String(), "output") {
t.Fatalf("fallback diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
outcome = app.Execute(nil, []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil context outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: invocation context must not be nil\n") {
t.Fatalf("nil context diagnostic = %q", diagnostics.String())
}
diagnostics.Reset()
var nilApp *App
outcome = nilApp.Execute(context.Background(), []string{"run"}, Streams{Err: &diagnostics})
if outcome.Kind != OutcomeOperational || outcome.Status != 1 || outcome.Cause == nil {
t.Fatalf("nil app outcome = %#v", outcome)
}
if !strings.Contains(diagnostics.String(), "ERROR: cannot execute commands on a nil app\n") {
t.Fatalf("nil app diagnostic = %q", diagnostics.String())
}
}

View File

@@ -1,230 +0,0 @@
package commandkit
import (
"bytes"
"errors"
"fmt"
"io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func newConfigExplainCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) {
flags := configInputFlags(prepared.suite, "text")
flags = append(flags, ValueFlag[configInput[C]](FlagMeta{
Name: "path",
Usage: "Configuration path as a JSON Pointer; an empty value selects the root.",
Required: true,
}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
input.Path = value
}))
spec := CommandSpec[configInput[C]]{
Name: string(ConfigExplain),
Summary: "Explain the effective and historical value at a configuration path.",
Flags: flags,
CheckInput: func(input configInput[C]) error {
if _, err := preflightConfig(prepared, input, ConfigExplain); err != nil {
return err
}
if !input.Path.Set {
return fmt.Errorf("--path is required")
}
if _, err := configkit.ParsePath(input.Path.Value); err != nil {
return fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err)
}
return nil
},
Run: func(invocation Invocation, input configInput[C]) Outcome {
return runConfigExplanation(invocation, prepared, input)
},
}
return NewCommand(spec)
}
type explanationReport struct {
Scope string `yaml:"scope"`
Path string `yaml:"path"`
Effective snapshotDTO `yaml:"effective"`
Origin explanationOriginDTO `yaml:"origin"`
History []explanationChangeDTO `yaml:"history"`
SecretReference *secretReferenceDTO `yaml:"secret_reference"`
}
type snapshotDTO struct {
Present bool `yaml:"present"`
Redacted bool `yaml:"redacted"`
YAML string `yaml:"yaml"`
}
type explanationOriginDTO struct {
Layer string `yaml:"layer"`
Locations []locationDTO `yaml:"locations"`
Operation string `yaml:"operation"`
Inheritance []inheritanceStepDTO `yaml:"inheritance"`
}
type inheritanceStepDTO struct {
From string `yaml:"from"`
To string `yaml:"to"`
Reference string `yaml:"reference"`
Locations []locationDTO `yaml:"locations"`
}
type explanationChangeDTO struct {
Before snapshotDTO `yaml:"before"`
After snapshotDTO `yaml:"after"`
Origin explanationOriginDTO `yaml:"origin"`
Reasons []string `yaml:"reasons"`
Dependencies []string `yaml:"dependencies"`
}
type secretReferenceDTO struct {
Name string `yaml:"name"`
}
func runConfigExplanation[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome {
if err := invocation.Context.Err(); err != nil {
return Failure(err)
}
scopeNames, err := selectConfigScopes(prepared, input, ConfigExplain)
if err != nil {
return Failure(err)
}
if !input.Path.Set {
return Failure(fmt.Errorf("--path is required"))
}
path, err := configkit.ParsePath(input.Path.Value)
if err != nil {
return Usage(fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err))
}
name := scopeNames[0]
contexts := contextsForScopes(prepared, input, scopeNames)
scope, ok := findConfigScope(prepared, name)
if !ok || scope.Acquire == nil {
return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name))
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, nil, false)
}
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name])
diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...)
if acquireErr != nil {
return explanationFailure(acquireErr, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if acquired.Value.Explanation == nil {
return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
}
explanation, found := acquired.Value.Explanation.Explain(path)
if !found {
return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if input.Format != "yaml" {
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics)
}
var report bytes.Buffer
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
return explanationFailure(err, diagnostics, false)
}
if err := invocation.Context.Err(); err != nil {
return explanationFailure(err, diagnostics, false)
}
if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil {
outcome := explanationFailure(err, diagnostics, true)
outcome.DeliveryError = err
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []Diagnostic) Outcome {
checked := &checkedWriter{writer: writer}
renderErr := reader.WriteExplanation(checked, path)
if checked.failure != nil {
if renderErr == nil {
renderErr = checked.failure
} else {
renderErr = errors.Join(renderErr, checked.failure)
}
}
if renderErr != nil {
outcome := explanationFailure(renderErr, diagnostics, true)
outcome.DeliveryError = checked.failure
return outcome
}
outcome := Success()
outcome.Diagnostics = diagnostics
return outcome
}
func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport {
report := explanationReport{
Scope: scope,
Path: explanation.Path.String(),
Effective: snapshotReport(explanation.Effective),
Origin: originReport(explanation.Origin),
History: make([]explanationChangeDTO, 0, len(explanation.History)),
}
for _, change := range explanation.History {
entry := explanationChangeDTO{
Before: snapshotReport(change.Before),
After: snapshotReport(change.After),
Origin: originReport(change.Origin),
Reasons: append([]string{}, change.Reasons...),
Dependencies: make([]string, 0, len(change.Dependencies)),
}
for _, dependency := range change.Dependencies {
entry.Dependencies = append(entry.Dependencies, dependency.String())
}
report.History = append(report.History, entry)
}
if explanation.SecretReference != nil {
report.SecretReference = &secretReferenceDTO{Name: explanation.SecretReference.Name}
}
return report
}
func snapshotReport(snapshot configkit.ValueSnapshot) snapshotDTO {
return snapshotDTO{Present: snapshot.Present, Redacted: snapshot.Redacted, YAML: snapshot.YAML}
}
func originReport(origin configkit.Origin) explanationOriginDTO {
report := explanationOriginDTO{
Layer: string(origin.Layer),
Locations: locationReports(origin.Locations),
Operation: string(origin.Operation),
Inheritance: make([]inheritanceStepDTO, 0, len(origin.Inheritance)),
}
for _, step := range origin.Inheritance {
report.Inheritance = append(report.Inheritance, inheritanceStepDTO{
From: step.From.String(),
To: step.To.String(),
Reference: step.Reference.String(),
Locations: locationReports(step.Locations),
})
}
return report
}
func locationReports(locations []configkit.Location) []locationDTO {
report := make([]locationDTO, 0, len(locations))
for _, location := range locations {
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
}
return report
}
func explanationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
outcome := Failure(cause)
outcome.Diagnostics = diagnostics
outcome.ReportIncomplete = reportStarted
return outcome
}

296
flag_aliases.go Normal file
View File

@@ -0,0 +1,296 @@
package commandkit
import (
"fmt"
"sort"
"strings"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
)
type resolvedFlagAlias struct {
source capturedFlagSpelling
target capturedFlagSpelling
}
type resolvedFlagAliases struct {
bySpelling map[string]resolvedFlagAlias
}
type flagAliasMapping struct {
target capturedFlagSpelling
scoped bool
}
type flagAliasContext struct {
path commandpath.Path
canonical map[string]capturedFlagSpelling
discovery bool
}
func (aliases resolvedFlagAliases) neutralRecords() []commandmeta.FlagAlias {
keys := make([]string, 0, len(aliases.bySpelling))
for key := range aliases.bySpelling {
keys = append(keys, key)
}
sort.Strings(keys)
records := make([]commandmeta.FlagAlias, 0, len(keys))
for _, key := range keys {
alias := aliases.bySpelling[key]
records = append(records, alias.neutralRecord())
}
return records
}
// lookupRecords skips sorting: resolved source spellings are unique and the
// parser's lookup does not depend on record order.
func (aliases resolvedFlagAliases) lookupRecords() []commandmeta.FlagAlias {
records := make([]commandmeta.FlagAlias, 0, len(aliases.bySpelling))
for _, alias := range aliases.bySpelling {
records = append(records, alias.neutralRecord())
}
return records
}
func (alias resolvedFlagAlias) neutralRecord() commandmeta.FlagAlias {
return commandmeta.FlagAlias{Source: alias.source.token(), Target: alias.target.token()}
}
func (spelling capturedFlagSpelling) token() string {
if spelling.short {
return "-" + spelling.name
}
return "--" + spelling.name
}
func compileFlagAliases(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}) (map[string]resolvedFlagAliases, map[string]resolvedFlagAliases, error) {
canonicalNodes := indexCanonicalNodes(roots)
global := make(map[string]flagAliasMapping)
scoped := make(map[string]map[string]flagAliasMapping)
for _, rule := range rules {
record := rule.record
switch record.kind {
case globalFlagAliasRule:
source := flagSpellingKey(record.sourceFlag)
global[source] = flagAliasMapping{target: record.targetFlag}
case scopedFlagAliasRule:
scope := record.scopePath.Key()
if canonicalNodes[scope] == nil {
return nil, nil, fmt.Errorf("flag alias scope %q is not a canonical command path", record.scopePath.String())
}
if scoped[scope] == nil {
scoped[scope] = make(map[string]flagAliasMapping)
}
source := flagSpellingKey(record.sourceFlag)
scoped[scope][source] = flagAliasMapping{target: record.targetFlag, scoped: true}
}
}
canonicalContexts := make(map[string]flagAliasContext, len(canonicalNodes)+1)
rootPath := commandpath.Path(nil)
canonicalContexts[rootPath.Key()] = flagAliasContext{path: rootPath, canonical: helpFlagSpellings()}
for key, node := range canonicalNodes {
canonicalContexts[key] = flagAliasContext{path: node.path.Clone(), canonical: canonicalFlagSpellings(node.command)}
}
discoveryContexts := discoverFlagContexts(rules, roots, aliases, canonicalNodes)
canonicalKeys := sortedContextKeys(canonicalContexts)
resolvedCanonical := make(map[string]resolvedFlagAliases, len(canonicalContexts))
for _, key := range canonicalKeys {
context := canonicalContexts[key]
mappings := cloneFlagMappings(global)
for source, mapping := range scoped[key] {
mappings[source] = mapping
}
resolved, err := resolveFlagAliases(context, mappings)
if err != nil {
return nil, nil, err
}
resolvedCanonical[key] = resolved
}
discoveryKeys := sortedContextKeys(discoveryContexts)
resolvedDiscovery := make(map[string]resolvedFlagAliases, len(discoveryContexts))
for _, key := range discoveryKeys {
context := discoveryContexts[key]
resolved, err := resolveFlagAliases(context, cloneFlagMappings(global))
if err != nil {
return nil, nil, err
}
resolvedDiscovery[key] = resolved
}
return resolvedCanonical, resolvedDiscovery, nil
}
func indexCanonicalNodes(roots map[string]*canonicalNode) map[string]*canonicalNode {
indexed := make(map[string]*canonicalNode)
var add func(*canonicalNode)
add = func(node *canonicalNode) {
indexed[node.path.Key()] = node
for _, child := range node.children {
add(child)
}
}
for _, root := range roots {
add(root)
}
return indexed
}
func helpFlagSpellings() map[string]capturedFlagSpelling {
flags := make(map[string]capturedFlagSpelling, 2+len(commandmeta.ReportingFlags()))
for _, spelling := range []capturedFlagSpelling{{name: "help"}, {name: "h", short: true}} {
flags[flagSpellingKey(spelling)] = spelling
}
for _, metadata := range commandmeta.ReportingFlags() {
spelling := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(spelling)] = spelling
}
return flags
}
func canonicalFlagSpellings(command Command) map[string]capturedFlagSpelling {
flags := helpFlagSpellings()
if command.record.payload == nil {
return flags
}
for _, metadata := range command.record.payload.bridgeFlags() {
long := capturedFlagSpelling{name: metadata.Name}
flags[flagSpellingKey(long)] = long
if metadata.Short != "" {
short := capturedFlagSpelling{name: metadata.Short, short: true}
flags[flagSpellingKey(short)] = short
}
}
return flags
}
func discoverFlagContexts(rules []RoutingRule, roots map[string]*canonicalNode, aliases interface {
Resolve(commandpath.Path) commandpath.Path
}, canonicalNodes map[string]*canonicalNode) map[string]flagAliasContext {
commandSources := make(map[string]struct{})
prefixes := make(map[string]commandpath.Path)
for _, rule := range rules {
if rule.record.kind == commandAliasRule {
commandSources[rule.record.sourcePath.Key()] = struct{}{}
}
}
for _, rule := range rules {
if rule.record.kind != commandAliasRule {
continue
}
for length := 1; length < len(rule.record.sourcePath); length++ {
prefix := rule.record.sourcePath[:length].Clone()
key := prefix.Key()
if canonicalNodes[key] != nil {
continue
}
if _, exactAlias := commandSources[key]; exactAlias {
continue
}
resolved := aliases.Resolve(prefix)
trace := traceCanonicalPath(roots, resolved)
if trace.exact != nil || (trace.operandOwner != nil && acceptsOperandPrefix(trace.operandOwner.command, len(resolved)-len(trace.operandOwner.path))) {
continue
}
prefixes[key] = prefix
}
}
contexts := make(map[string]flagAliasContext, len(prefixes))
for key, path := range prefixes {
contexts[key] = flagAliasContext{path: path, canonical: helpFlagSpellings(), discovery: true}
}
return contexts
}
func cloneFlagMappings(mappings map[string]flagAliasMapping) map[string]flagAliasMapping {
cloned := make(map[string]flagAliasMapping, len(mappings))
for source, mapping := range mappings {
cloned[source] = mapping
}
return cloned
}
func sortedContextKeys(contexts map[string]flagAliasContext) []string {
keys := make([]string, 0, len(contexts))
for key := range contexts {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func resolveFlagAliases(context flagAliasContext, mappings map[string]flagAliasMapping) (resolvedFlagAliases, error) {
result := resolvedFlagAliases{bySpelling: make(map[string]resolvedFlagAlias)}
sources := make([]string, 0, len(mappings))
for source := range mappings {
sources = append(sources, source)
}
sort.Strings(sources)
for _, sourceKey := range sources {
mapping := mappings[sourceKey]
source := spellingFromKey(sourceKey)
target, active, err := resolveFlagTarget(source, mapping.target, mappings, context.canonical)
if err != nil {
return resolvedFlagAliases{}, fmt.Errorf("%s: %w", flagContextDescription(context), err)
}
if !active {
if mapping.scoped {
return resolvedFlagAliases{}, fmt.Errorf("scoped flag alias %s has no canonical destination in %s", source.token(), flagContextDescription(context))
}
continue
}
if _, collision := context.canonical[sourceKey]; collision {
return resolvedFlagAliases{}, fmt.Errorf("flag alias %s conflicts with a canonical spelling in %s", source.token(), flagContextDescription(context))
}
result.bySpelling[sourceKey] = resolvedFlagAlias{source: source, target: target}
}
return result, nil
}
func resolveFlagTarget(source, target capturedFlagSpelling, mappings map[string]flagAliasMapping, canonical map[string]capturedFlagSpelling) (capturedFlagSpelling, bool, error) {
visited := map[string]int{flagSpellingKey(source): 0}
chain := []capturedFlagSpelling{source}
current := target
for {
key := flagSpellingKey(current)
if endpoint, exists := canonical[key]; exists {
return endpoint, true, nil
}
if start, exists := visited[key]; exists {
cycle := append(append([]capturedFlagSpelling(nil), chain[start:]...), current)
spellings := make([]string, 0, len(cycle))
for _, spelling := range cycle {
spellings = append(spellings, spelling.token())
}
return capturedFlagSpelling{}, false, fmt.Errorf("flag alias cycle: %s", strings.Join(spellings, " → "))
}
visited[key] = len(chain)
chain = append(chain, current)
next, exists := mappings[key]
if !exists {
return capturedFlagSpelling{}, false, nil
}
current = next.target
}
}
func spellingFromKey(key string) capturedFlagSpelling {
if strings.HasPrefix(key, "short:") {
return capturedFlagSpelling{name: strings.TrimPrefix(key, "short:"), short: true}
}
return capturedFlagSpelling{name: strings.TrimPrefix(key, "long:")}
}
func flagContextDescription(context flagAliasContext) string {
if context.discovery {
return fmt.Sprintf("alias prefix %q", context.path.String())
}
if len(context.path) == 0 {
return "application root"
}
return fmt.Sprintf("command %q", context.path.String())
}

297
flag_aliases_test.go Normal file
View File

@@ -0,0 +1,297 @@
package commandkit
import (
"strings"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
)
func flagAliasCommand(name string, flags ...Flag[struct{}]) Command {
return NewCommand(CommandSpec[struct{}]{
Name: name, Summary: name, Flags: flags,
Run: func(Invocation, struct{}) Outcome { return Success() },
})
}
func stringFlag(name, short string) Flag[struct{}] {
return ValueFlag(FlagMeta{Name: name, Short: short}, (*string)(nil),
func(value string) (string, error) { return value, nil },
func(*struct{}, Supplied[string]) {},
)
}
func TestFlagAliasChainsUseScopedOverridesAndCanonicalEndpoints(t *testing.T) {
inspect := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
show := flagAliasCommand("show", stringFlag("color", "c"), stringFlag("style", "f"))
group := Group("Tools", "tools", show)
app, err := New(Identity{Name: "Example"},
inspect,
group,
Alias("look", "inspect"),
FlagAlias("--colour", "--hue"),
FlagAlias("--hue", "--color"),
FlagAlias("-x", "--color"),
FlagAlias("--tint", "-c"),
FlagAliasFor("Tools", "--group-help", "--help"),
FlagAliasFor("inspect", "--hue", "--style"),
FlagAliasFor("inspect", "--local", "--style"),
)
if err != nil {
t.Fatal(err)
}
inspectAliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
for source, target := range map[string]string{
"--colour": "--style",
"--hue": "--style",
"-x": "--color",
"--tint": "-c",
"--local": "--style",
} {
alias, ok := inspectAliases[flagSpellingKey(capturedSpelling(source))]
if !ok || alias.target.token() != target {
t.Errorf("inspect alias %s = (%s, %v), want %s", source, alias.target.token(), ok, target)
}
}
groupAliases := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling
if _, active := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]; active {
t.Error("global alias was active on a group without its destination flag")
}
if _, inherited := groupAliases[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by its parent group")
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "local"})]; inherited {
t.Fatal("command-scoped alias was inherited by a different command")
}
groupHelp := flagSpellingKey(capturedFlagSpelling{name: "group-help"})
if got := groupAliases[groupHelp].target.token(); got != "--help" {
t.Fatalf("group-scoped help alias target = %s, want --help", got)
}
if _, inherited := app.flagAliases[commandpath.Path{"Tools", "show"}.Key()].bySpelling[groupHelp]; inherited {
t.Fatal("group-scoped alias was inherited by its child command")
}
}
func TestFlagAliasDiscoveryContextsUseGlobalReportingAliases(t *testing.T) {
show := flagAliasCommand("show")
app, err := New(Identity{Name: "Example"},
Group("Tools", "tools", show),
Alias("shortcut show", "Tools show"),
FlagAlias("--hlep", "--help"),
FlagAlias("--silent", "--quiet"),
FlagAlias("--chatty", "--verbose"),
FlagAlias("--trace", "--debug"),
FlagAlias("--show-progress", "--progress"),
FlagAliasFor("Tools show", "--local-help", "--help"),
)
if err != nil {
t.Fatal(err)
}
globalHelp := flagSpellingKey(capturedFlagSpelling{name: "hlep"})
if got := app.flagAliases[commandpath.Path(nil).Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("root --hlep target = %s, want --help", got)
}
if got := app.flagAliases[commandpath.Path{"Tools"}.Key()].bySpelling[globalHelp].target.token(); got != "--help" {
t.Fatalf("group --hlep target = %s, want --help", got)
}
discovery := app.discoveryFlags[commandpath.Path{"shortcut"}.Key()].bySpelling
if got := discovery[globalHelp].target.token(); got != "--help" {
t.Fatalf("discovery --hlep target = %s, want --help", got)
}
for label, aliases := range map[string]resolvedFlagAliases{
"root": app.flagAliases[commandpath.Path(nil).Key()],
"group": app.flagAliases[commandpath.Path{"Tools"}.Key()],
"command": app.flagAliases[commandpath.Path{"Tools", "show"}.Key()],
"discovery": app.discoveryFlags[commandpath.Path{"shortcut"}.Key()],
} {
for source, target := range map[string]string{
"silent": "quiet",
"chatty": "verbose",
"trace": "debug",
"show-progress": "progress",
} {
key := flagSpellingKey(capturedFlagSpelling{name: source})
if got := aliases.bySpelling[key].target.token(); got != "--"+target {
t.Errorf("%s --%s target = %s, want --%s", label, source, got, target)
}
}
}
if _, inherited := discovery[flagSpellingKey(capturedFlagSpelling{name: "local-help"})]; inherited {
t.Fatal("discovery context inherited a command-scoped alias")
}
}
func TestReportingFlagsAreReservedAndAcceptApplicationShortAliases(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
command := flagAliasCommand("reserved", stringFlag(name, ""))
if len(command.record.issues) == 0 {
t.Errorf("capturing --%s did not retain a declaration error", name)
}
app, err := New(Identity{Name: "Example"}, flagAliasCommand("safe"))
if err != nil {
t.Fatal(err)
}
snapshot := app.routeSnapshot()
if err := app.Register(command, Alias("reserved-shortcut", "reserved")); err == nil || !strings.Contains(err.Error(), "reserved for reporting") {
t.Errorf("registering --%s returned %v, want a retained reporting reservation error", name, err)
}
if app.routeSnapshot() != snapshot || app.commands["reserved"] != nil || app.commands["safe"] == nil || len(app.routingRules) != 0 {
t.Fatalf("failed registration of --%s changed the installed commands", name)
}
}
declarations := []Declaration{flagAliasCommand("run")}
for short, target := range map[string]string{"q": "quiet", "v": "verbose", "d": "debug"} {
declarations = append(declarations, FlagAlias("-"+short, "--"+target))
}
if _, err := New(Identity{Name: "Example"}, declarations...); err != nil {
t.Fatalf("application short aliases for reporting flags were rejected: %v", err)
}
}
func TestReportingFlagSpellingsConflictWithAliasSources(t *testing.T) {
for _, name := range []string{"quiet", "verbose", "debug", "progress", "format"} {
if _, err := New(Identity{Name: "Example"}, FlagAlias("--"+name, "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("alias source --%s returned %v, want a canonical spelling collision", name, err)
}
}
if _, err := New(Identity{Name: "Example"}, Group("tools", "", flagAliasCommand("show")), FlagAliasFor("tools", "--quiet", "--help")); err == nil || !strings.Contains(err.Error(), "conflicts with a canonical spelling") {
t.Errorf("group-scoped canonical reporting spelling returned %v, want a collision", err)
}
}
func TestRoutingSnapshotCarriesReportingMetadataOutsideConsumerBindings(t *testing.T) {
app, err := New(Identity{Name: "Example"},
flagAliasCommand("run", stringFlag("style", "f")),
Group("tools", "", flagAliasCommand("show")),
)
if err != nil {
t.Fatal(err)
}
var check func(*commandmeta.Command)
check = func(node *commandmeta.Command) {
if len(node.CoreFlags) != 5 {
t.Errorf("%s core reporting flags = %#v", node.Name, node.CoreFlags)
}
for _, flag := range node.Flags {
if commandmeta.IsReportingFlag(flag.Name) {
t.Errorf("%s reporting flag %q was added to consumer bindings", node.Name, flag.Name)
}
}
for _, child := range node.Children {
check(child)
}
}
check(app.routeSnapshot().metadata)
}
func TestFlagAliasDuplicatesAndScopesAreDeterministic(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"), stringFlag("style", "f"))
duplicate := FlagAlias("--colour", "--color")
app, err := New(Identity{Name: "Example"}, command, flagAliasCommand("plain", stringFlag("color", "c")), duplicate, FlagAlias("--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--style"))
if err != nil {
t.Fatalf("identical duplicates and scoped override were rejected: %v", err)
}
if got := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--style" {
t.Fatalf("scoped alias target = %s, want --style", got)
}
if got := app.flagAliases[commandpath.Path{"plain"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("effective global alias target = %s, want --color", got)
}
for name, rules := range map[string][]RoutingRule{
"competing global targets": {
FlagAlias("--colour", "--color"),
FlagAlias("--colour", "--style"),
},
"competing scoped targets": {
FlagAliasFor("inspect", "--colour", "--color"),
FlagAliasFor("inspect", "--colour", "--style"),
},
"unknown scope": {FlagAliasFor("missing", "--colour", "--color")},
"missing scoped destination": {FlagAliasFor("inspect", "--colour", "--missing")},
} {
t.Run(name, func(t *testing.T) {
declarations := []Declaration{command}
for _, rule := range rules {
declarations = append(declarations, rule)
}
if _, err := New(Identity{Name: "Example"}, declarations...); err == nil {
t.Fatal("New() accepted invalid flag alias rules")
}
})
}
}
func TestFlagAliasCyclesIdentifyParticipatingSpellings(t *testing.T) {
for name, declarations := range map[string][]Declaration{
"global": {
FlagAlias("--first", "--second"),
FlagAlias("--second", "--first"),
},
"scoped": {
flagAliasCommand("inspect"),
FlagAliasFor("inspect", "-a", "--second"),
FlagAliasFor("inspect", "--second", "-a"),
},
} {
t.Run(name, func(t *testing.T) {
_, err := New(Identity{Name: "Example"}, declarations...)
if err == nil {
t.Fatal("New() accepted a flag alias cycle")
}
message := err.Error()
for _, spelling := range []string{"--first", "--second"} {
if name == "scoped" {
continue
}
if !strings.Contains(message, spelling) {
t.Errorf("cycle error %q does not identify %s", message, spelling)
}
}
if name == "scoped" && (!strings.Contains(message, "-a") || !strings.Contains(message, "--second")) {
t.Errorf("scoped cycle error %q does not identify both spellings", message)
}
})
}
}
func TestInactiveGlobalAliasesActivateAtomicallyWhenDestinationAppears(t *testing.T) {
command := flagAliasCommand("inspect", stringFlag("color", "c"))
app, err := New(Identity{Name: "Example"}, command,
FlagAlias("--color", "--missing"),
FlagAlias("--colour", "--color"),
)
if err != nil {
t.Fatal(err)
}
aliases := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling
if _, active := aliases[flagSpellingKey(capturedFlagSpelling{name: "color"})]; active {
t.Fatal("global alias with an unavailable destination became active")
}
if got := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})].target.token(); got != "--color" {
t.Fatalf("--colour endpoint = %s, want canonical --color", got)
}
snapshot := app.routeSnapshot()
commands := len(app.commands)
rules := len(app.routingRules)
originalAlias := aliases[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if err := app.Register(flagAliasCommand("later", stringFlag("color", "c"), stringFlag("missing", ""))); err == nil {
t.Fatal("Register() accepted a newly active global source collision")
}
currentAlias := app.flagAliases[commandpath.Path{"inspect"}.Key()].bySpelling[flagSpellingKey(capturedFlagSpelling{name: "colour"})]
if app.routeSnapshot() != snapshot || len(app.commands) != commands || len(app.routingRules) != rules || currentAlias != originalAlias {
t.Fatal("failed registration changed the existing command or routing state")
}
}
func capturedSpelling(token string) capturedFlagSpelling {
issues := []error(nil)
return captureFlagSpelling("test", token, &issues)
}

134
flag_values.go Normal file
View File

@@ -0,0 +1,134 @@
package commandkit
import (
"errors"
"fmt"
"sort"
)
// ErrFlagInput identifies a missing required captured flag selected for an input.
var ErrFlagInput = errors.New("required flag input is missing")
// ErrFlagWiring identifies an invalid captured binding or destination.
var ErrFlagWiring = errors.New("flag wiring is invalid")
// FlagValues stores invocation-local converted values and presence for captured
// flags. It does not retain an input instance; values can be applied to
// independently initialized destinations. Captured occurrence flags retain
// their relative command-line order across the selected bindings.
type FlagValues[I any] struct {
values map[*bindingIdentity]preparedValue[I]
}
// CaptureFlags adapts flags declared for I into flags on Outer. During parsing,
// each adapted flag stores its prepared value in target, associated with the
// original inner binding identity. Copies of an original handle remain
// selectable; an independently constructed lookalike does not.
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
captured := make([]Flag[Outer], len(flags))
for index, original := range flags {
inner := original
identity := newBindingIdentity()
binding := flagBinding[Outer]{
identity: identity,
meta: inner.binding.meta,
deferredRequired: inner.binding.meta.Required,
defaultText: inner.binding.defaultText,
repeated: inner.binding.repeated,
boolean: inner.binding.boolean,
}
if target != nil && inner.binding.identity != nil && inner.binding.valid && inner.binding.prepare != nil {
binding.valid = true
binding.prepare = func(raw []string) (preparedValue[Outer], error) {
prepared, err := inner.binding.prepare(raw)
if err != nil {
return preparedValue[Outer]{}, err
}
result := preparedValue[Outer]{
identity: identity,
supplied: prepared.supplied,
apply: func(outer *Outer) {
store := target(outer)
if store == nil {
return
}
if store.values == nil {
store.values = make(map[*bindingIdentity]preparedValue[I])
}
store.values[inner.binding.identity] = prepared
},
}
for _, occurrence := range prepared.ordered {
result.ordered = append(result.ordered, preparedOccurrence[Outer]{ordinal: occurrence.ordinal})
}
return result, nil
}
}
captured[index] = Flag[Outer]{binding: binding}
}
return captured
}
// ApplyTo applies selected original captured flags to an initialized
// destination. A missing captured binding or nil destination matches
// ErrFlagWiring. A required selected value that was not supplied matches
// ErrFlagInput. Ordinary selected values are applied first, followed by selected
// occurrence values in command-line order. Repeated core-owned values are copied
// for each application.
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
return values.applyTo(target, false, selected...)
}
// ApplySuppliedTo applies only explicitly supplied selected flags, preserving
// the destination's existing values for omitted flags. It checks selected
// bindings and required values like ApplyTo and preserves occurrence order.
func (values FlagValues[I]) ApplySuppliedTo(target *I, selected ...Flag[I]) error {
return values.applyTo(target, true, selected...)
}
func (values FlagValues[I]) applyTo(target *I, suppliedOnly bool, selected ...Flag[I]) error {
if target == nil {
return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
}
preparedValues := make([]preparedValue[I], 0, len(selected))
for _, flag := range selected {
binding := flag.binding
if binding.identity == nil {
return fmt.Errorf("%w: selected flag has no identity", ErrFlagWiring)
}
prepared, exists := values.values[binding.identity]
if !exists {
return fmt.Errorf("%w: flag --%s was not captured", ErrFlagWiring, binding.meta.Name)
}
if binding.meta.Required && !prepared.supplied {
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
}
if suppliedOnly && !prepared.supplied {
continue
}
preparedValues = append(preparedValues, prepared)
}
var ordered []preparedOccurrence[I]
for _, prepared := range preparedValues {
if prepared.apply != nil {
prepared.apply(target)
}
for _, occurrence := range prepared.ordered {
if occurrence.apply != nil {
ordered = append(ordered, occurrence)
}
}
}
sort.SliceStable(ordered, func(i, j int) bool { return *ordered[i].ordinal < *ordered[j].ordinal })
for _, occurrence := range ordered {
occurrence.apply(target)
}
return nil
}
func classifyInputError(err error) Outcome {
if errors.Is(err, ErrFlagWiring) {
return Failure(err.Error()).WithCause(err)
}
return Usage(err.Error()).WithCause(err)
}

144
flag_values_test.go Normal file
View File

@@ -0,0 +1,144 @@
package commandkit
import (
"errors"
"fmt"
"testing"
)
type capturedInput struct {
name Supplied[string]
tags Supplied[[]string]
}
type capturedOuter struct {
flags FlagValues[capturedInput]
}
func TestCaptureFlagsAppliesOnlyOriginalBindingWithoutReconversion(t *testing.T) {
parseCalls := 0
original := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) {
parseCalls++
return value, nil
}, func(input *capturedInput, value Supplied[string]) { input.name = value })
copied := original
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare([]string{"chosen"})
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var target capturedInput
if err := outer.flags.ApplyTo(&target, copied); err != nil {
t.Fatal(err)
}
if target.name.Value != "chosen" || !target.name.Set || parseCalls != 1 {
t.Fatalf("target=%#v parse calls=%d", target, parseCalls)
}
lookalike := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) { return value, nil }, func(*capturedInput, Supplied[string]) {})
if err := outer.flags.ApplyTo(&target, lookalike); !errors.Is(err, ErrFlagWiring) {
t.Fatalf("lookalike binding error = %v", err)
}
if err := outer.flags.ApplyTo(nil, copied); !errors.Is(err, ErrFlagWiring) {
t.Fatalf("nil target error = %v", err)
}
if err := (FlagValues[capturedInput]{}).ApplyTo(&target); err != nil {
t.Fatalf("empty selection from zero store: %v", err)
}
}
func TestCaptureFlagsDefersRequiredCheckToSelectedBindings(t *testing.T) {
required := ValueFlag(FlagMeta{Name: "name", Required: true}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
captured := CaptureFlags([]Flag[capturedInput]{required}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare(nil)
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var target capturedInput
if err := outer.flags.ApplyTo(&target); err != nil {
t.Fatalf("unselected required flag failed: %v", err)
}
if err := outer.flags.ApplyTo(&target, required); !errors.Is(err, ErrFlagInput) {
t.Fatalf("selected missing required flag error = %v", err)
}
optional := ValueFlag(FlagMeta{Name: "name"}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
optionalCapture := CaptureFlags([]Flag[capturedInput]{optional}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
optionalPrepared, err := optionalCapture[0].binding.prepare(nil)
if err != nil {
t.Fatal(err)
}
outer = capturedOuter{}
optionalPrepared.apply(&outer)
target = capturedInput{}
if err := outer.flags.ApplyTo(&target, optional); err != nil {
t.Fatal(err)
}
if target.name.Value != "default" || target.name.Set {
t.Fatalf("captured default value = %#v", target.name)
}
badCapture := CaptureFlags[capturedOuter]([]Flag[capturedInput]{required}, nil)
command := NewCommand(CommandSpec[capturedOuter]{Name: "broken", Flags: badCapture})
if _, err := New(Identity{Name: "example"}, command); err == nil {
t.Fatal("installation accepted a nil captured-flag accessor")
}
}
func TestCaptureFlagsCopiesRepeatedValuesOnEachApplication(t *testing.T) {
original := RepeatFlag(FlagMeta{Name: "tag"}, nil, func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[[]string]) { input.tags = value })
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
prepared, err := captured[0].binding.prepare([]string{"a,b", "c"})
if err != nil {
t.Fatal(err)
}
outer := capturedOuter{}
prepared.apply(&outer)
var first, second capturedInput
if err := outer.flags.ApplyTo(&first, original); err != nil {
t.Fatal(err)
}
if err := outer.flags.ApplyTo(&second, original); err != nil {
t.Fatal(err)
}
first.tags.Value[0] = "changed"
if second.tags.Value[0] != "a,b" || second.tags.Value[1] != "c" || !second.tags.Set {
t.Fatalf("second application shares repeat storage: %#v", second.tags)
}
}
func TestCapturedFlagErrorClassificationPreservesWrappedCategories(t *testing.T) {
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagWiring)); got.Kind != OutcomeOperational {
t.Fatalf("wiring outcome = %#v", got)
}
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagInput)); got.Kind != OutcomeUsage {
t.Fatalf("input outcome = %#v", got)
}
}
func TestCheckInputClassifiesWrappedFlagWiringAsOperational(t *testing.T) {
command := NewCommand(CommandSpec[struct{}]{
Name: "run",
CheckInput: func(struct{}) error { return fmt.Errorf("scope selector: %w", ErrFlagWiring) },
Run: func(Invocation, struct{}) Outcome { t.Fatal("run called after wiring error"); return Success() },
})
app, err := New(Identity{Name: "example"}, command)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(t.Context(), []string{"run"}, Streams{}); outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, ErrFlagWiring) {
t.Fatalf("wrapped wiring outcome = %#v", outcome)
}
usage := NewCommand(CommandSpec[struct{}]{Name: "usage", CheckInput: func(struct{}) error { return fmt.Errorf("selected value: %w", ErrFlagInput) }, Run: func(Invocation, struct{}) Outcome { return Success() }})
app, err = New(Identity{Name: "example"}, usage)
if err != nil {
t.Fatal(err)
}
if outcome := app.Execute(t.Context(), []string{"usage"}, Streams{}); outcome.Kind != OutcomeUsage || !errors.Is(outcome.Cause, ErrFlagInput) {
t.Fatalf("wrapped input outcome = %#v", outcome)
}
}
func strptr(value string) *string { return &value }

82
format_flag_test.go Normal file
View File

@@ -0,0 +1,82 @@
package commandkit
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
)
func TestSharedFormatFlag(t *testing.T) {
factories, runs := 0, 0
command := NewCommand(CommandSpec[struct{}]{Name: "run", NewInput: func() struct{} { factories++; return struct{}{} }, Run: func(inv Invocation, _ struct{}) Outcome {
runs++
_ = inv.Reporter().Report(Report{Value: map[string]any{"answer": 42}})
return Success()
}})
app, err := New(Identity{Name: "Example", Version: "1"}, command, FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
expected string
}{
{[]string{"run", "--format", "text"}, "yaml"},
{[]string{"run", "--format=yaml"}, "yaml"},
{[]string{"run", "--fmt=json"}, "json"},
{[]string{"run", "--format=yaml", "--fmt", "json"}, "json"},
{[]string{"run"}, "yaml"},
} {
var out bytes.Buffer
got := app.Execute(context.Background(), tc.args, Streams{Out: &out})
if got.Status != 0 {
t.Fatalf("%v: %#v", tc.args, got)
}
var value any
if tc.expected == "json" {
dec := json.NewDecoder(&out)
dec.UseNumber()
err = dec.Decode(&value)
} else {
value, err = DecodeYAMLReport(&out)
}
if err != nil || !reflect.DeepEqual(value, map[string]any{"answer": json.Number("42")}) {
t.Fatalf("%v: %#v %v", tc.args, value, err)
}
}
beforeFactories, beforeRuns := factories, runs
for _, args := range [][]string{{"run", "--format="}, {"run", "--fmt=xml"}, {"run", "--format=bad", "--format=json"}, {"run", "--format", "--quiet"}} {
var diagnostics bytes.Buffer
got := app.Execute(context.Background(), args, Streams{Err: &diagnostics})
if got.Kind != OutcomeUsage || !strings.Contains(diagnostics.String(), "format") {
t.Fatalf("%v: %#v %q", args, got, &diagnostics)
}
}
if factories != beforeFactories || runs != beforeRuns {
t.Fatalf("invalid formats ran domain code: factories %d, runs %d", factories, runs)
}
var help bytes.Buffer
got := app.Execute(context.Background(), []string{"run", "--help"}, Streams{Out: &help})
if got.Status != 0 || !strings.Contains(help.String(), "--format") || factories != beforeFactories || runs != beforeRuns {
t.Fatalf("help: %#v, %q", got, &help)
}
}
func TestSharedFormatParseOrderAndTerminator(t *testing.T) {
app, err := New(Identity{Name: "Example"}, flagAliasCommand("run", stringFlag("message", "")), FlagAlias("--fmt", "--format"))
if err != nil {
t.Fatal(err)
}
result := parseReportingArgs(app, []string{"run", "--verbose", "--fmt", "json", "--quiet", "--message=--format", "--", "--format=yaml"})
if result.Failure != nil || result.Reporting.Format != "json" || result.Reporting.Mode != commandmeta.ReportingModeQuiet || !reflect.DeepEqual(result.Operands, []string{"--format=yaml"}) {
t.Fatalf("%#v", result)
}
if result.FlagOccurrences[1].Spelling != "--fmt" || result.FlagOccurrences[1].RawValue != "json" {
t.Fatalf("%#v", result.FlagOccurrences)
}
}

12
go.mod
View File

@@ -1,24 +1,18 @@
module gitea.maximumdirect.net/eric/CommandKit module go.maximumdirect.net/commandkit
go 1.26.0 go 1.26.0
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.12.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9 github.com/spf13/pflag v1.0.9
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.6
) )
require ( require (
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/yuin/goldmark/v2 v2.1.6 // indirect github.com/yuin/goldmark/v2 v2.1.6 // indirect
go.maximumdirect.net/releasekit v0.9.0 // indirect
golang.org/x/mod v0.40.0 // indirect golang.org/x/mod v0.40.0 // indirect
golang.org/x/text v0.14.0 // indirect
) )
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit tool go.maximumdirect.net/releasekit/cmd/releasekit

19
go.sum
View File

@@ -1,33 +1,18 @@
gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg=
gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 h1:aTMn+50MEpHpHI2RoCCmJYsy7Ohrpa+RSva63Kq0/tU=
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
gitea.maximumdirect.net/eric/promptkit v0.12.0 h1:oIFWEg6GPxtmX3Plqv0euP+FnK1LCk/t4xDy6oKCpP4=
gitea.maximumdirect.net/eric/promptkit v0.12.0/go.mod h1:pFOOy5v1zH17stEi9rmHYmfbuLjnfVMcUhfmk+4VtJY=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0=
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE= github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ= github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
go.maximumdirect.net/releasekit v0.9.0 h1:sK+Aoj6gS6ATU/1reocjwBekp4YETYENwkcX5ti6mrE=
go.maximumdirect.net/releasekit v0.9.0/go.mod h1:QE4BLrAhVJTzi+/YTcVl21K/qZp9ojRLg5kuoy3Zb44=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

63
help_view.go Normal file
View File

@@ -0,0 +1,63 @@
package commandkit
import (
"sort"
commandmeta "go.maximumdirect.net/commandkit/internal/commandmeta"
"go.maximumdirect.net/commandkit/internal/commandpath"
)
func (a *App) helpView(route routeResult, snapshot *routingSnapshot) commandmeta.HelpView {
view := commandmeta.HelpView{
EnteredPath: clonePath(route.enteredPath),
CanonicalPath: clonePath(route.canonicalPath),
Discovery: route.kind == routeDiscovery,
CoreFlags: commandmeta.ReportingFlags(),
}
if snapshot == nil {
return view
}
view.CommandAliases = routeHelpAliases(snapshot, route)
if view.Discovery {
view.FlagAliases = snapshot.discoveryFlags[route.discoveryPath.Key()].neutralRecords()
return view
}
view.FlagAliases = snapshot.flagAliases[route.canonicalPath.Key()].neutralRecords()
return view
}
func clonePath(path commandpath.Path) []string {
return append([]string(nil), path...)
}
func routeHelpAliases(snapshot *routingSnapshot, route routeResult) []commandmeta.CommandAlias {
if route.kind != routeDiscovery && len(route.canonicalPath) == 0 {
return append([]commandmeta.CommandAlias(nil), snapshot.commandAliases...)
}
bySource := make(map[string]commandmeta.CommandAlias)
for _, prefix := range snapshot.aliases.ResolutionPaths(route.enteredPath) {
for _, alias := range snapshot.commandAliases {
source := commandpath.FromTokens(alias.Source)
if len(source) <= len(prefix) || !source.HasPrefix(prefix) {
continue
}
suffix := source[len(prefix):]
entered := append(route.enteredPath.Clone(), suffix...)
target := snapshot.aliases.Resolve(entered)
if traceCanonicalPath(snapshot.canonical, target).exact == nil {
continue
}
bySource[suffix.Key()] = commandmeta.CommandAlias{Source: clonePath(suffix), Target: clonePath(target)}
}
}
keys := make([]string, 0, len(bySource))
for key := range bySource {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]commandmeta.CommandAlias, 0, len(keys))
for _, key := range keys {
result = append(result, bySource[key])
}
return result
}

View File

@@ -18,8 +18,11 @@ type Identity struct {
Revision string Revision string
} }
// Streams contains the output destinations associated with a command // Streams contains the caller-owned output destinations associated with an
// invocation. // invocation. Out receives substantive results; Err receives messages,
// terminal explanations, usage guidance, and CommandKit diagnostics. Nil
// destinations are replaced with io.Discard. CommandKit does not close or flush
// supplied writers.
type Streams struct { type Streams struct {
// Out receives help and command reports. // Out receives help and command reports.
Out io.Writer Out io.Writer
@@ -27,11 +30,51 @@ type Streams struct {
Err io.Writer Err io.Writer
} }
// Invocation provides execution context and output streams to a command func normalizeStreams(streams Streams) Streams {
// handler. if streams.Out == nil {
streams.Out = io.Discard
}
if streams.Err == nil {
streams.Err = io.Discard
}
return streams
}
// Invocation provides execution context and invocation-scoped reporting to a
// command handler. Execute finalizes its reporter before returning. For command
// aliases, EnteredPath and CanonicalPath expose the route the caller used and
// the selected canonical command path. Both omit the executable name, flags,
// and operands.
type Invocation struct { type Invocation struct {
// Context carries caller cancellation and request-scoped values. // Context carries caller cancellation and request-scoped values.
Context context.Context Context context.Context
// Streams are the caller-selected output destinations.
Streams Streams reporter *Reporter
enteredPath string
canonicalPath string
renderHelp func() Outcome
} }
// Reporter returns the reporting facility supplied for this invocation. A
// manually constructed Invocation has no reporter.
func (inv Invocation) Reporter() *Reporter { return inv.reporter }
// Help renders the selected command's help through this invocation's output.
// It returns an operational failure when the invocation has no help renderer.
func (inv Invocation) Help() Outcome {
if inv.renderHelp == nil {
return Failure("Command help is unavailable.")
}
return inv.renderHelp()
}
// EnteredPath returns the command path used to invoke the selected command,
// excluding the executable name, flags, and operands. It is empty for an
// Invocation that was not created by command execution.
func (inv Invocation) EnteredPath() string { return inv.enteredPath }
// CanonicalPath returns the selected command's canonical path after resolving
// aliases, excluding the executable name, flags, and operands. It equals
// EnteredPath for a direct canonical invocation and is empty for an Invocation
// that was not created by command execution.
func (inv Invocation) CanonicalPath() string { return inv.canonicalPath }

View File

@@ -0,0 +1,20 @@
CommandKit ckconfig
Copyright (C) 2026 Eric Rakestraw
SPDX-License-Identifier: LGPL-3.0-or-later
This library is free software: you can redistribute it and/or modify it
under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The GNU Lesser General Public License version 3 and its incorporated
GNU General Public License version 3 text are provided in LICENSE.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms.

View File

@@ -0,0 +1,304 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License.
"The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version".
The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
CommandKit
Copyright (C) 2026 eric
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
CommandKit Copyright (C) 2026 eric
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.

View File

@@ -0,0 +1,44 @@
# CommandKit ckconfig
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
installing the integration is explicit and does not modify an application by
itself.
## Module
The module path is `go.maximumdirect.net/ckconfig`. The v0.4.0 candidate requires Go
1.27.0 and uses `go.maximumdirect.net/commandkit v0.4.0` and
`go.maximumdirect.net/configkit v1.1.0`; it does not import PromptKit. See the
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
for exact callback and lifetime contracts.
## Example
For one application configuration scope, install the convenience declaration:
```go
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
```
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
scopes, selectors, input checks, and state shared by the selected acquisitions
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
ConfigKit loader. The `config` command family exposes scope discovery and the
validate, show, sources, explain, and diff operations supported by the declared
scopes. Source reports include native destination, placement, destination
fallback, version evidence, and empty-import selections. Comparison returns
ConfigKit's native records to shared report rendering; the
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
owns its operator details.
The maintained [simple example](examples/simple) shows a complete consumer.
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
describes its operator-facing behavior.
## License and attribution
This module is licensed under the [GNU Lesser General Public License v3.0 or
later](COPYRIGHT). The accompanying [LICENSE](LICENSE) contains the full LGPL
v3 and GPL v3 texts. See [third-party attribution](docs/oss/attribution.md)
for incorporated and dependency notices.

View File

@@ -0,0 +1,335 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
type adapterSettings struct {
Greeting string `yaml:"greeting"`
Secret string `yaml:"secret"`
}
func adapterDefinition() configkit.Definition[adapterSettings] {
return configkit.Definition[adapterSettings]{
Defaults: func() adapterSettings { return adapterSettings{Greeting: "Hello"} },
Validate: func(value *adapterSettings, validation *configkit.Validation) {
if value.Greeting == "Hello" {
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
}
},
Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" },
}
}
func adapterSettingsLoad(source configkit.Source) (*configkit.Result[adapterSettings], error) {
return configkit.Load(source, configkit.Options[adapterSettings]{Definition: adapterDefinition()})
}
func comparisonSide(selection Selection, greeting string) ComparisonSide[struct{ Label string }] {
return ComparisonSide[struct{ Label string }]{
Selection: selection,
Input: struct{ Label string }{Label: greeting},
}
}
func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *testing.T) {
selection := Selection{Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}}
var calls []struct {
selection Selection
input struct{ Label string }
state *int
}
scope := FromConfigKit[struct{ Label string }, *int, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, input struct{ Label string }, state *int) (*configkit.Result[adapterSettings], error) {
calls = append(calls, struct {
selection Selection
input struct{ Label string }
state *int
}{selected, input, state})
*state++
return adapterSettingsLoad(configkit.Mapping(input.Label, map[string]any{"greeting": input.Label}))
})
state := new(int)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "before"),
Right: comparisonSide(selection, "after"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(calls) != 2 || !reflect.DeepEqual(calls[0].selection, selection) || !reflect.DeepEqual(calls[1].selection, selection) {
t.Fatalf("loader calls = %#v", calls)
}
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
t.Fatalf("loader inputs/state = %#v, %#v, state=%d", calls[0], calls[1], *state)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "before\n" || acquired.Value[0].Right.YAML != "after\n" {
t.Fatalf("native differences = %#v", acquired.Value)
}
equal, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
Left: comparisonSide(selection, "same"),
Right: comparisonSide(selection, "same"),
}, state)
if err != nil {
t.Fatal(err)
}
if len(equal.Value) != 0 || len(calls) != 4 {
t.Fatalf("equal comparison records/calls = %#v / %d", equal.Value, len(calls))
}
}
func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t *testing.T) {
type resolvedSettings struct {
Greeting string `yaml:"greeting"`
}
definition := configkit.Definition[resolvedSettings]{
Defaults: func() resolvedSettings { return resolvedSettings{Greeting: "resolved"} },
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
if value.Greeting != "resolved" {
value.Greeting = "resolved"
resolution.RecordNormalization(configkit.Keys("greeting"), "canonical greeting")
}
return nil
},
}
left, err := configkit.Load(configkit.NoSource(), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
right, err := configkit.Load(configkit.Mapping("right-source", map[string]any{"greeting": "source value"}), configkit.Options[resolvedSettings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, resolvedSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[resolvedSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if left.Config != right.Config || left.Config.Greeting != "resolved" || len(acquired.Value) != 0 {
t.Fatalf("equal resolved values yielded differences: left=%#v right=%#v records=%#v", left.Config, right.Config, acquired.Value)
}
}
func TestFromConfigKitCompareUsesBaseAndOverlaySelections(t *testing.T) {
dir := t.TempDir()
basePath := filepath.Join(dir, "base.yml")
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
if err := os.WriteFile(basePath, []byte("greeting: Hello\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Dir(overlayPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(overlayPath, []byte("greeting: Goodbye\n"), 0o600); err != nil {
t.Fatal(err)
}
loader := func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
options := configkit.LayeredOptions[adapterSettings]{Definition: adapterDefinition()}
if selection.Overlay.Set {
options.Overlay = selection.Overlay.Value
options.OverlayFilesystem = &configkit.FilesystemContext{Root: dir, BaseDir: dir}
}
return configkit.LoadLayered(configkit.Input{Source: configkit.File(selection.Config.Value)}, options)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", loader)
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: basePath, Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{
Config: commandkit.Supplied[string]{Value: basePath, Set: true},
Overlay: commandkit.Supplied[string]{Value: "testing", Set: true},
}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "Hello\n" || acquired.Value[0].Right.YAML != "Goodbye\n" {
t.Fatalf("base/overlay differences = %#v", acquired.Value)
}
}
func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
_, loadErr := configkit.Load(configkit.Mapping("right-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid right source unexpectedly loaded")
}
wrapper := fmt.Errorf("application loader context: %w", loadErr)
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return nil, wrapper
})
acquired, gotErr := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(gotErr, loadErr) {
t.Fatalf("load error identity not preserved: %v", gotErr)
}
var typed *configkit.LoadError
if !errors.As(gotErr, &typed) || typed != loadErr {
t.Fatalf("load error type not preserved: %#v", typed)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || len(acquired.Explanation.Details) != 1 {
t.Fatalf("retained diagnostics/explanation = %#v / %#v", acquired.Diagnostics, acquired.Explanation)
}
var leftText, rightText bytes.Buffer
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
t.Fatal(err)
}
if err := acquired.Explanation.Details[0](&rightText); err != nil {
t.Fatal(err)
}
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
t.Fatalf("left diagnostic lost side/source identity: %q", leftText.String())
}
if !strings.Contains(rightText.String(), `Configuration scope "app" right:`) || !strings.Contains(rightText.String(), `"right-source"`) {
t.Fatalf("right diagnostic lost side/source identity: %q", rightText.String())
}
}
func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
_, loadErr := configkit.Load(configkit.Mapping("left-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
if loadErr == nil {
t.Fatal("invalid left source unexpectedly loaded")
}
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return nil, loadErr
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, loadErr) || rightCalled {
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
}
var typed *configkit.LoadError
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Explanation.Details) != 1 {
t.Fatalf("left failure identity/explanation = %v / %#v", err, acquired.Explanation)
}
var terminal bytes.Buffer
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(terminal.String(), `Configuration scope "app" left:`) || !strings.Contains(terminal.String(), `"left-source"`) {
t.Fatalf("left terminal context lost side/source identity: %q", terminal.String())
}
}
func TestFromConfigKitCompareCancellationStopsBeforeRightLoad(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rightCalled := false
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
cancel()
return left, nil
}
rightCalled = true
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
})
acquired, err := scope.Compare(ctx, ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if !errors.Is(err, context.Canceled) || rightCalled {
t.Fatalf("cancellation/right load = %v / %v", err, rightCalled)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("left diagnostics lost on cancellation: %#v", acquired.Diagnostics)
}
}
func TestFromConfigKitCompareRejectsNilResultAndRetainsNativeDiffFailureDiagnostics(t *testing.T) {
t.Run("nil result", func(t *testing.T) {
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return nil, nil
})
_, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{}, struct{}{})
if err == nil || !strings.Contains(err.Error(), `"app" left`) || !strings.Contains(err.Error(), "nil result") {
t.Fatalf("nil left result error = %v", err)
}
})
t.Run("native diff error", func(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selection.Config.Value == "left" {
return left, nil
}
return &configkit.Result[adapterSettings]{}, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err == nil || !strings.Contains(err.Error(), "native diff") {
t.Fatalf("native diff error = %v", err)
}
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
t.Fatalf("native diff failure lost diagnostics: %#v", acquired.Diagnostics)
}
})
}
func TestFromConfigKitComparePreservesConcealedNativeDifference(t *testing.T) {
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"secret": "old"}))
if err != nil {
t.Fatal(err)
}
right, err := adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"secret": "new"}))
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
if selected.Config.Value == "left" {
return left, nil
}
return right, nil
})
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
}, struct{}{})
if err != nil {
t.Fatal(err)
}
if len(acquired.Value) != 1 || !acquired.Value[0].HiddenChanged || !acquired.Value[0].Left.Redacted || !acquired.Value[0].Right.Redacted || acquired.Value[0].Left.YAML != acquired.Value[0].Right.YAML {
t.Fatalf("concealed native difference was altered: %#v", acquired.Value)
}
}

View File

@@ -0,0 +1,105 @@
package ckconfig
import (
"context"
"errors"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
type input struct{ Value string }
var inputCalls, stateCalls, acquireCalls, compareCalls int
compare := func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
operations := []Operation{Diff}
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[input, struct{}]{
{Name: "app", Operations: operations, Compare: compare},
{Name: "promptkit", Operations: []Operation{Diff, Show, Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}, Compare: compare},
{Name: "alpha"},
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}
captured := captureSuite(suite)
declaration := Command(suite)
// Capability metadata and callback references are copied during capture.
suite.Scopes[0].Operations[0] = Show
suite.Scopes[0].Compare = nil
app := newConfigTestApp(t, declaration)
var output strings.Builder
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("scope discovery outcome = %#v", outcome)
}
if got, want := output.String(), "app: diff\npromptkit: validate, show, diff\nalpha: \nplain: validate\n"; got != want {
t.Fatalf("scope discovery = %q, want %q", got, want)
}
// A family-wide diff command is available, but unsupported scopes fail before
// suite factories or either provider runs.
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("unsupported scope outcome = %#v", outcome)
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
t.Fatalf("discovery or preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
}
if captured.byName["app"].scope.Compare == nil {
t.Fatal("captured comparison callback was cleared by a source edit")
}
if _, err := captured.byName["app"].scope.Compare(context.Background(), ComparisonRequest[input]{}, struct{}{}); err != nil || compareCalls != 1 {
t.Fatalf("captured comparison callback result=%v calls=%d", err, compareCalls)
}
withoutComparison := newConfigTestApp(t, Command(Suite[input, struct{}]{Scopes: []Scope[input, struct{}]{{
Name: "plain", Operations: []Operation{Validate},
Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, nil
},
}}}))
outcome = withoutComparison.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage {
t.Fatalf("family without comparison exposed diff: %#v", outcome)
}
}
func TestComparisonCallbackWiringAndRetainedAdapterIssues(t *testing.T) {
newApp := func(scope Scope[struct{}, struct{}]) error {
_, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
return err
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}}); err == nil {
t.Fatalf("missing comparison callback error = %v", err)
}
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}}); err == nil {
t.Fatalf("missing ordinary acquisition callback error = %v", err)
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
scope.Operations = []Operation{Diff}
scope.Compare = func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
}
retained := scope.issues[0]
if err := newApp(scope); err == nil || !errors.Is(err, retained) {
t.Fatalf("narrowed adapter lost its retained loader issue: %v", err)
}
}

View File

@@ -0,0 +1,115 @@
package ckconfig
import (
"context"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
scope := Scope[struct{}, struct{}]{
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, nil
},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
help := func(args ...string) string {
t.Helper()
var output strings.Builder
app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
return output.String()
}
diffHelp := help("config", "diff", "--help")
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
}
for _, operation := range []string{"validate", "show", "sources", "explain"} {
operationHelp := help("config", operation, "--help")
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
}
if operation != "explain" && operation != "show" && strings.Contains(operationHelp, "--path") {
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
}
}
explainHelp := help("config", "explain", "--help")
if !strings.Contains(explainHelp, "--path") {
t.Fatalf("explain flags changed:\n%s", explainHelp)
}
}
func TestComparisonPreparation(t *testing.T) {
inputCalls, stateCalls := 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
input := &configCommandInput[struct{}, struct{}]{
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
}
if err := prepareConfigOperation(prepared, Diff, input); err != nil {
t.Fatalf("explicit YAML format rejected: %v", err)
}
if input.plan == nil {
t.Fatalf("comparison plan = %#v", input.plan)
}
if input.plan.scopeName != "app" {
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
}
if want := (Selection{
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
}); !reflect.DeepEqual(input.against, want) {
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
}
if inputCalls != 2 || stateCalls != 0 {
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
}
}
func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) {
inputCalls, stateCalls, acquireCalls, compareCalls := 0, 0, 0, 0
prepared := captureSuite(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
})
for _, scope := range []string{"missing", "all", "plain"} {
input := &configCommandInput[struct{}, struct{}]{
scope: commandkit.Supplied[string]{Value: scope, Set: true},
}
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
t.Errorf("comparison scope %q was accepted", scope)
}
}
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
t.Fatalf("failed preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
}
}

View File

@@ -0,0 +1,358 @@
package ckconfig
import (
"context"
"errors"
"io"
"reflect"
"strconv"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
type input struct {
Enabled commandkit.Supplied[bool]
Limit commandkit.Supplied[int]
Tags commandkit.Supplied[[]string]
}
var inputCalls, checkCalls, stateCalls int
var parseCalls int
var preparedInputs []*input
var selections []Selection
enabled := commandkit.BoolFlag[*input](commandkit.FlagMeta{Name: "enabled"}, true, func(value **input, supplied commandkit.Supplied[bool]) {
(*value).Enabled = supplied
})
limit := commandkit.ValueFlag[*input](commandkit.FlagMeta{Name: "limit", Required: true}, (*int)(nil), func(raw string) (int, error) {
parseCalls++
return strconv.Atoi(raw)
}, func(value **input, supplied commandkit.Supplied[int]) {
(*value).Limit = supplied
})
tags := commandkit.RepeatFlag[*input](commandkit.FlagMeta{Name: "tag"}, nil, func(raw string) (string, error) {
parseCalls++
return raw, nil
}, func(value **input, supplied commandkit.Supplied[[]string]) {
(*value).Tags = supplied
})
suite := Suite[*input, struct{}]{
NewInput: func() *input {
inputCalls++
return &input{}
},
NewState: func() struct{} {
stateCalls++
if checkCalls != 2 {
t.Errorf("shared state created after %d input checks", checkCalls)
}
if len(preparedInputs) != 2 || preparedInputs[0] == preparedInputs[1] {
t.Errorf("inputs were not initialized separately: %#v", preparedInputs)
} else {
left, right := preparedInputs[0].Tags.Value, preparedInputs[1].Tags.Value
if len(left) != 2 || len(right) != 2 || &left[0] == &right[0] {
t.Errorf("repeated values share storage: left=%#v right=%#v", left, right)
} else {
left[0] = "changed"
if right[0] != "alpha" {
t.Errorf("mutating one side changed the other: %#v", right)
}
}
}
return struct{}{}
},
Selectors: []Selector[*input]{{Binding: enabled, AgainstName: "against-enabled"}, {Binding: limit, AgainstName: "against-limit"}, {Binding: tags, AgainstName: "against-tag"}},
CheckInput: func(scope string, selection Selection, value *input) error {
checkCalls++
preparedInputs = append(preparedInputs, value)
selections = append(selections, selection)
if scope != "app" || value == nil {
return errors.New("unexpected scope input")
}
if !value.Enabled.Set || value.Enabled.Value {
return errors.New("explicit false was not preserved")
}
if !value.Limit.Set || value.Limit.Value != 0 {
return errors.New("explicit zero was not preserved")
}
if !value.Tags.Set || len(value.Tags.Value) != 2 || value.Tags.Value[0] != "alpha" || value.Tags.Value[1] != "beta" {
return errors.New("repeated input was not preserved")
}
return nil
},
Scopes: []Scope[*input, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta", "--against-enabled=false", "--against-limit=0", "--against-tag", "alpha", "--against-tag", "beta", "--against-overlay=",
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("valid comparison outcome = %#v", outcome)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
if parseCalls != 6 {
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
}
wantSelection := Selection{
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
Overlay: commandkit.Supplied[string]{Set: true},
}
if len(selections) != 2 || !reflect.DeepEqual(selections[0], wantSelection) || !reflect.DeepEqual(selections[1], wantSelection) {
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
}
}
func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
type input struct{ ID int }
type state struct{ Label string }
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
var checked []input
shared := &state{Label: "shared"}
var request ComparisonRequest[input]
app := newConfigTestApp(t, Command(Suite[input, *state]{
NewInput: func() input {
inputCalls++
return input{ID: inputCalls}
},
NewState: func() *state {
stateCalls++
if checkCalls != 2 {
t.Errorf("shared state created after %d input checks", checkCalls)
}
return shared
},
CheckInput: func(scope string, selection Selection, value input) error {
checkCalls++
if scope != "app" {
return errors.New("unexpected scope")
}
checked = append(checked, value)
return nil
},
Scopes: []Scope[input, *state]{{
Name: "app", Operations: []Operation{Show, Diff},
Acquire: func(context.Context, Request, input, *state) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, errors.New("ordinary acquisition reached during comparison")
},
Compare: func(_ context.Context, pair ComparisonRequest[input], value *state) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
if checkCalls != 2 || stateCalls != 1 || value != shared {
t.Errorf("provider reached with checks=%d states=%d shared=%t", checkCalls, stateCalls, value == shared)
}
request = pair
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}))
outcome := app.Execute(context.Background(), []string{
"config", "diff", "--config", "before.yml", "--against-overlay=",
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("comparison outcome = %#v", outcome)
}
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
right := Selection{Overlay: commandkit.Supplied[string]{Set: true}}
if !reflect.DeepEqual(request.Left.Selection, left) || !reflect.DeepEqual(request.Right.Selection, right) {
t.Fatalf("provider selections = %#v", request)
}
if len(checked) != 2 || request.Left.Input != checked[0] || request.Right.Input != checked[1] {
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
t.Fatalf("invocation calls input=%d check=%d state=%d compare=%d acquire=%d", inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls)
}
}
func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
checkCalls := 0
failure := errors.New("right input rejected")
prepared := captureSuite(Suite[struct{}, struct{}]{
CheckInput: func(string, Selection, struct{}) error {
checkCalls++
if checkCalls == 2 {
return failure
}
return nil
},
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[struct{}, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
t.Fatalf("right-side input failure = %v", err)
}
if commandInput.plan != nil || checkCalls != 2 {
t.Fatalf("failed pair retained partial state: plan=%#v checks=%d", commandInput.plan, checkCalls)
}
}
func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
checkCalls := 0
prepared := captureSuite(Suite[*int, struct{}]{
CheckInput: func(_ string, _ Selection, value *int) error {
checkCalls++
if value != nil {
return errors.New("nil factory did not supply the zero value")
}
return nil
},
Scopes: []Scope[*int, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[*int, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err)
}
request := commandInput.plan.comparison.request
if checkCalls != 2 || request.Left.Input != nil || request.Right.Input != nil {
t.Fatalf("nil input value changed: checks=%d request=%#v", checkCalls, request)
}
}
func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
shared := new(int)
inputCalls, checkCalls := 0, 0
prepared := captureSuite(Suite[*int, struct{}]{
NewInput: func() *int { inputCalls++; return shared },
CheckInput: func(_ string, _ Selection, value *int) error {
checkCalls++
if value != shared {
return errors.New("caller-owned alias was replaced")
}
return nil
},
Scopes: []Scope[*int, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
})
commandInput := &configCommandInput[*int, struct{}]{}
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
t.Fatal(err)
}
request := commandInput.plan.comparison.request
if inputCalls != 2 || checkCalls != 2 || request.Left.Input != shared || request.Right.Input != shared {
t.Fatalf("caller-owned alias changed: calls=%d/%d request=%#v", inputCalls, checkCalls, request)
}
}
func TestComparisonInputFailureStopsBeforeStateAndPreservesWiringClassification(t *testing.T) {
var inputCalls, checkCalls, stateCalls int
suite := Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
CheckInput: func(string, Selection, struct{}) error {
checkCalls++
if checkCalls == 2 {
return commandkit.ErrFlagWiring
}
return nil
},
Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, commandkit.ErrFlagWiring) {
t.Fatalf("right-side wiring failure = %#v", outcome)
}
if !strings.Contains(outcome.Cause.Error(), `scope "app" right input`) {
t.Fatalf("right-side error lacks scope context: %v", outcome.Cause)
}
if inputCalls != 2 || checkCalls != 2 || stateCalls != 0 {
t.Fatalf("failed preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}
func TestComparisonMissingRequiredSelectorIsUsageBeforeState(t *testing.T) {
type input struct{ Token commandkit.Supplied[string] }
var inputCalls, checkCalls, stateCalls int
token := commandkit.ValueFlag(commandkit.FlagMeta{Name: "token", Required: true}, (*string)(nil), parseConfigString, func(value *input, supplied commandkit.Supplied[string]) {
value.Token = supplied
})
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Selectors: []Selector[input]{{Binding: token, Scopes: []string{"app"}}},
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
Scopes: []Scope[input, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}},
}
app := newConfigTestApp(t, Command(suite))
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
t.Fatalf("missing required selector = %#v", outcome)
}
if inputCalls != 1 || checkCalls != 0 || stateCalls != 0 {
t.Fatalf("missing selector calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}
func TestComparisonHelpConvertsInapplicableSelectorsWithoutBuildingInputs(t *testing.T) {
type input struct{ Tenant int }
parseErr := errors.New("invalid tenant")
var parseCalls, inputCalls, checkCalls, stateCalls int
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*int)(nil), func(raw string) (int, error) {
parseCalls++
if raw == "bad" {
return 0, parseErr
}
return strconv.Atoi(raw)
}, func(value *input, supplied commandkit.Supplied[int]) {
value.Tenant = supplied.Value
})
makeScope := func(name string) Scope[input, struct{}] {
return Scope[input, struct{}]{Name: name, Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
}}
}
suite := Suite[input, struct{}]{
NewInput: func() input { inputCalls++; return input{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"promptkit"}}},
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")},
}
app := newConfigTestApp(t, Command(suite))
var output strings.Builder
app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=7", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
if !strings.Contains(output.String(), "--tenant") || parseCalls != 1 {
t.Fatalf("help did not convert the inapplicable supplied selector: calls=%d help=%q", parseCalls, output.String())
}
outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=bad", "--help"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, parseErr) || parseCalls != 2 {
t.Fatalf("malformed help selector outcome=%#v conversions=%d", outcome, parseCalls)
}
if inputCalls != 0 || checkCalls != 0 || stateCalls != 0 {
t.Fatalf("help built suite work: input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
}
}

View File

@@ -0,0 +1,11 @@
package ckconfig
import commandkit "go.maximumdirect.net/commandkit"
func completionChoices(values ...string) commandkit.CompletionHint {
choices := make([]commandkit.CompletionChoice, len(values))
for i, value := range values {
choices[i].Value = value
}
return commandkit.CompleteChoices(choices...)
}

View File

@@ -0,0 +1,66 @@
package ckconfig
import (
"bytes"
"context"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestCompletionUsesInstalledScopeCapabilitiesWithoutAcquisition(t *testing.T) {
var inputs, states, acquisitions, comparisons int
suite := Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputs++; return struct{}{} },
NewState: func() struct{} { states++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
comparisons++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "other", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquisitions++
return commandkit.Acquired[View]{}, nil
}},
},
}
app, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(suite), commandkit.Completion("example"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args []string
contains, omit string
}{
{[]string{"__complete", "config", "diff", "--scope="}, "app\n", "other\n"},
{[]string{"__complete", "config", "validate", "--scope="}, "other\n", "all\n"},
{[]string{"__complete", "config", "diff", "--format="}, "yaml\n", "invalid\n"},
{[]string{"__complete", "config", "validate", "--format="}, "text\n", "invalid\n"},
{[]string{"__complete", "config", "diff", "--against-config=fixtures/"}, ":0\n", ":4\n"},
} {
var output bytes.Buffer
outcome := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Status != 0 || !strings.Contains(output.String(), tc.contains) || strings.Contains(output.String(), tc.omit) {
t.Fatalf("query %q outcome=%#v output=%q", tc.args, outcome, output.String())
}
}
allSuite := Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
{Name: "other", Operations: []Operation{Validate}, Acquire: suite.Scopes[1].Acquire},
}}
allApp, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(allSuite), commandkit.Completion("example"))
if err != nil {
t.Fatal(err)
}
var allOutput bytes.Buffer
allOutcome := allApp.Execute(context.Background(), []string{"__complete", "config", "validate", "--scope="}, commandkit.Streams{Out: &allOutput})
if allOutcome.Status != 0 || !strings.Contains(allOutput.String(), "all\n") {
t.Fatalf("all-scope query outcome=%#v output=%q", allOutcome, allOutput.String())
}
if inputs != 0 || states != 0 || acquisitions != 0 || comparisons != 0 {
t.Fatalf("completion ran suite work: %d %d %d %d", inputs, states, acquisitions, comparisons)
}
}

View File

@@ -0,0 +1,213 @@
package ckconfig
import (
"context"
"errors"
"fmt"
"io"
"strconv"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
// FromConfigKit adapts a typed ConfigKit loader to all five native
// configuration operations. The loader receives the operation's explicit
// Selection, initialized input, and shared invocation state, and runs only when
// an installed operation selects this scope. Comparison invokes it separately
// for each reached side. Construction and help do not call it. A nil original
// loader is retained as a construction issue, even if callers later narrow the
// scope's operation list.
func FromConfigKit[I, S, T any](
name, summary string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
) Scope[I, S] {
scope := Scope[I, S]{
Name: name,
Summary: summary,
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
Features: ScopeFeatures{PathDiscovery: true, PathShow: true},
}
if load == nil {
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
}
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
result, err := load(ctx, request.Selection, input, state)
acquired := commandkit.Acquired[View]{}
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil {
records := loadError.Diagnostics()
messages, explanation := configKitDiagnostics(name, loadError.Sources(), records)
acquired.Value.Sources = loadError
acquired.Value.Findings = &Findings{Records: records, Messages: messages}
acquired.Explanation = explanation
}
return acquired, err
}
if result == nil {
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
}
metadata := result.Metadata()
records := result.Diagnostics()
messages, explanation := configKitDiagnostics(name, result.Sources(), records)
acquired.Value = View{Effective: result, Sources: result, Explanation: result, Metadata: &metadata, Findings: &Findings{Records: records, Messages: messages}}
acquired.Explanation = explanation
return acquired, nil
}
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
if err := ctx.Err(); err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
}
acquired := commandkit.Acquired[[]configkit.Difference]{}
left, leftDiagnostics, leftExplanation, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
acquired.Diagnostics = append(acquired.Diagnostics, leftDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, leftExplanation)
if err != nil {
return acquired, err
}
if err := ctx.Err(); err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
}
right, rightDiagnostics, rightExplanation, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
acquired.Diagnostics = append(acquired.Diagnostics, rightDiagnostics...)
acquired.Explanation = appendConfigKitExplanation(acquired.Explanation, rightExplanation)
if err != nil {
return acquired, err
}
if err := ctx.Err(); err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
}
acquired.Value, err = configkit.Diff(left, right)
if err != nil {
return acquired, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
}
return acquired, nil
}
return scope
}
func loadComparisonSide[I, S, T any](
ctx context.Context,
scope, side string,
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
request ComparisonSide[I],
state S,
) (*configkit.Result[T], []commandkit.Diagnostic, commandkit.Explanation, error) {
result, err := load(ctx, request.Selection, request.Input, state)
var diagnostics []commandkit.Diagnostic
var explanation commandkit.Explanation
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) && loadError != nil {
warnings, terminal := configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
diagnostics = append(diagnostics, warnings...)
explanation = appendConfigKitExplanation(explanation, terminal)
}
return result, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
}
if result == nil {
return nil, diagnostics, explanation, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
}
diagnostics, explanation = configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics())
return result, diagnostics, explanation, nil
}
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
}
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
}
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) ([]commandkit.Diagnostic, commandkit.Explanation) {
if len(records) == 0 {
return nil, commandkit.Explanation{}
}
sources = append([]configkit.SourceInfo(nil), sources...)
records = cloneConfigKitDiagnostics(records)
warnings := make([]configkit.Diagnostic, 0, len(records))
terminal := make([]configkit.Diagnostic, 0, len(records))
for _, record := range records {
if record.Severity == configkit.SeverityError {
terminal = append(terminal, record)
} else {
warnings = append(warnings, record)
}
}
if len(warnings) == 0 {
return nil, explanationWithConfigKitRecords(heading, sources, terminal)
}
warning := commandkit.Diagnostic{
Severity: commandkit.SeverityWarning,
Write: configKitDiagnosticWriter(heading, sources, warnings),
}
return []commandkit.Diagnostic{warning}, explanationWithConfigKitRecords(heading, sources, terminal)
}
func explanationWithConfigKitRecords(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Explanation {
if len(records) == 0 {
return commandkit.Explanation{}
}
return commandkit.Explanation{Details: []commandkit.Detail{configKitDiagnosticWriter(heading, sources, records)}}
}
func configKitDiagnosticWriter(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) commandkit.Detail {
return func(writer io.Writer) error {
if err := writeDiagnosticText(writer, heading); err != nil {
return err
}
if len(sources) > 0 {
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
return err
}
for _, source := range sources {
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
if err := writeDiagnosticText(writer, line); err != nil {
return err
}
}
}
return configkit.WriteDiagnostics(writer, records)
}
}
func cloneConfigKitDiagnostics(records []configkit.Diagnostic) []configkit.Diagnostic {
cloned := append([]configkit.Diagnostic(nil), records...)
for index := range cloned {
if records[index].Primary != nil {
primary := *records[index].Primary
cloned[index].Primary = &primary
}
cloned[index].Related = append([]configkit.Location(nil), records[index].Related...)
}
return cloned
}
func appendConfigKitExplanation(left, right commandkit.Explanation) commandkit.Explanation {
if left.Message == "" {
left.Message = right.Message
}
left.Details = append(left.Details, right.Details...)
return left
}
func writeDiagnosticText(writer io.Writer, value string) error {
written, err := io.WriteString(writer, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}

View File

@@ -0,0 +1,193 @@
package ckconfig
import (
"context"
"io"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
// Selection carries the configuration and overlay choices supplied for a
// configuration scope.
type Selection struct {
// Config is the caller-supplied configuration path, if present.
Config commandkit.Supplied[string]
// Overlay is the caller-supplied overlay path, if present.
Overlay commandkit.Supplied[string]
// Overrides contains ordered CLI override records, or nil when none were supplied.
Overrides []configkit.Override
}
// Operation identifies a supported standard configuration operation.
type Operation string
const (
// Validate checks the selected configuration and reports diagnostics.
Validate Operation = "validate"
// Show writes the selected configuration's effective form.
Show Operation = "show"
// Sources reports the selected configuration's input sources.
Sources Operation = "sources"
// Explain reports the history and provenance of a selected path.
Explain Operation = "explain"
// Diff compares two selections of the same configuration scope.
Diff Operation = "diff"
)
// Request describes one configuration operation and its selected inputs.
type Request struct {
// Operation identifies the requested inspection operation.
Operation Operation
// Selection carries the explicit config and overlay inputs.
Selection Selection
}
// ComparisonSide groups one configuration selection with its independently
// initialized and checked typed input. The supplied-state fields of Selection
// are preserved for the scope's loader.
type ComparisonSide[I any] struct {
// Selection carries the explicit config, overlay, and override inputs for this side.
Selection Selection
// Input is initialized and checked for this side before comparison.
Input I
}
// ComparisonRequest carries the earlier and later selections supplied to a
// scope's comparison callback. Each side has a separately prepared input; the
// callback receives shared invocation state separately.
type ComparisonRequest[I any] struct {
// Left is the earlier configuration selection.
Left ComparisonSide[I]
// Right is the later configuration selection.
Right ComparisonSide[I]
}
// EffectiveWriter writes a configuration's native effective representation.
type EffectiveWriter interface {
WriteEffective(io.Writer) error
}
// SourceReader reports the native source inventory for a configuration.
type SourceReader interface {
Sources() []configkit.SourceInfo
}
// ExplanationReader reads and writes a configuration's native path explanation.
type ExplanationReader interface {
Explain(configkit.Path) (configkit.Explanation, bool)
WriteExplanation(io.Writer, configkit.Path) error
}
// View exposes native configuration facets without reconstructing their
// reports.
type View struct {
Effective EffectiveWriter
Sources SourceReader
Explanation ExplanationReader
// Metadata supplies native configuration path metadata when available.
Metadata *configkit.Metadata
// Findings supplies native diagnostics and their ordinary presentations.
// Nil means unavailable; a nonnil value may contain no records.
Findings *Findings
}
// Findings associates native diagnostic records with their ordinary messages.
type Findings struct {
// Records contains the native diagnostic records, which may be empty.
Records []configkit.Diagnostic
// Messages contains ordinary presentations associated with Records.
Messages []commandkit.Diagnostic
}
// ScopeFeatures describes optional modes within configuration operations.
type ScopeFeatures struct {
// PathDiscovery indicates support for metadata-backed path discovery.
PathDiscovery bool
// PathShow indicates support for displaying a selected effective path.
PathShow bool
// Overrides indicates that the loader accepts explicit configuration overrides.
Overrides bool
}
// Scope declares the operations and providers for one named configuration.
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
// require Acquire unless an ordinary operation is also declared. Construction
// issues are retained privately until family assembly attaches them to its
// command declaration.
type Scope[I, S any] struct {
// Name identifies the scope in CLI selection and reports.
Name string
// Summary describes the scope.
Summary string
// Operations selects the standard handlers exposed for this scope.
Operations []Operation
// Features describes optional modes supported by this scope.
Features ScopeFeatures
// Acquire loads the selected configuration for one ordinary inspection
// invocation. It returns independent diagnostics and terminal context
// separately from the native value and optional operation error.
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
// Compare compares two selections for this scope using one shared state.
// Its Acquired result carries native difference records, independent
// diagnostics, and terminal context; returned errors retain their supported
// cause identity. It is required when Diff is declared in Operations.
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
issues []error
}
// Selector binds a typed flag to the configuration scopes that use it.
type Selector[I any] struct {
// Binding is the original typed flag handle captured by the suite.
Binding commandkit.Flag[I]
// Scopes lists the scope names that receive this flag's value.
Scopes []string
// AgainstName optionally declares the long name of an independent
// right-side comparison flag using the same parser and setter.
AgainstName string
}
// Suite declares the typed input, shared state, selectors, and configuration
// scopes used to assemble configuration commands.
//
// Input initialization is separate for each selected ordinary scope and each
// reached comparison side; state is shared by the invocation's acquisitions or
// comparison callback. Factory results and callback effects remain
// caller-owned: Command does not deep-copy arbitrary objects or repair aliases
// returned by factories. Core-owned repeated flag collections are copied when
// applied to separate inputs. Factories are not invoked during construction,
// installation, help, version, or scope discovery, and need not run after failure.
type Suite[I, S any] struct {
// NewInput is called once for each selected ordinary scope, or once for each
// reached side of a comparison, after operation preflight and before applying
// selectors and calling CheckInput.
// If nil, input is the zero value of I, including nil for pointer types.
NewInput func() I
// NewState is called once when provider execution is reached, after all
// selected inputs pass their checks. The result is shared by selected
// acquisitions or the comparison callback.
// If nil, state is the zero value of S, including nil for pointer types.
NewState func() S
// Selectors declares typed flags and their applicable scopes.
Selectors []Selector[I]
// CheckInput validates one initialized scope input after applicable values
// are applied. Comparison sides are checked separately with their selection.
CheckInput func(scope string, selection Selection, input I) error
// Scopes declares the configuration scopes assembled by Command.
Scopes []Scope[I, S]
}
// WithScopeError returns a copy of scope that retains err as a construction
// issue for ckconfig.Command to report during installation. Existing issues
// remain available to family assembly, and scope is not mutated. A nil error
// returns scope unchanged.
func WithScopeError[I, S any](scope Scope[I, S], err error) Scope[I, S] {
if err == nil {
return scope
}
issues := make([]error, len(scope.issues), len(scope.issues)+1)
copy(issues, scope.issues)
scope.issues = append(issues, err)
return scope
}

View File

@@ -0,0 +1,294 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
first := &scopeCause{message: "first construction problem"}
second := errors.New("second construction problem")
original := Scope[struct{}, struct{}]{issues: []error{fmt.Errorf("wrapped: %w", first)}}
copyWithError := WithScopeError(original, second)
if len(original.issues) != 1 || !errors.Is(original.issues[0], first) {
t.Fatalf("original issues changed: %#v", original.issues)
}
if len(copyWithError.issues) != 2 || !errors.Is(copyWithError.issues[0], first) || !errors.Is(copyWithError.issues[1], second) {
t.Fatalf("attached issues = %#v", copyWithError.issues)
}
if copyWithError.issues[0] != original.issues[0] {
t.Fatal("existing cause identity was not preserved")
}
var retained *scopeCause
if !errors.As(copyWithError.issues[0], &retained) || retained != first {
t.Fatalf("retained concrete cause = %#v, want %#v", retained, first)
}
narrowed := copyWithError
narrowed.Operations = nil
third := errors.New("third construction problem")
withThird := WithScopeError(narrowed, third)
unchanged := WithScopeError(withThird, nil)
if len(unchanged.issues) != len(copyWithError.issues)+1 || !errors.Is(unchanged.issues[2], third) {
t.Fatalf("repeated attachment lost issues: %#v", unchanged.issues)
}
}
type scopeCause struct{ message string }
func (e *scopeCause) Error() string { return e.message }
func TestFromConfigKitRetainsNilLoaderIssue(t *testing.T) {
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
scope.Operations = nil
if scope.Acquire != nil {
t.Fatal("nil loader produced an acquisition callback")
}
if len(scope.issues) != 1 || !strings.Contains(scope.issues[0].Error(), "loader") {
t.Fatalf("retained nil-loader issues = %#v", scope.issues)
}
}
func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
type input struct{ Label string }
type state struct{ Calls int }
type settings struct {
Name string `yaml:"name"`
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
selection := Selection{
Config: commandkit.Supplied[string]{Value: "chosen.yaml", Set: true},
Overlay: commandkit.Supplied[string]{Value: "", Set: true},
}
gotContext, gotSelection, gotInput, gotState := context.Context(nil), Selection{}, input{}, (*state)(nil)
result, err := configkit.Load(configkit.Mapping("source.yaml", map[string]any{"name": "native"}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
if err != nil {
t.Fatal(err)
}
scope := FromConfigKit[input, *state, settings]("app", "Application configuration", func(got context.Context, selected Selection, in input, st *state) (*configkit.Result[settings], error) {
gotContext, gotSelection, gotInput, gotState = got, selected, in, st
st.Calls++
return result, nil
})
if len(scope.Operations) != 5 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain || scope.Operations[4] != Diff {
t.Fatalf("operations = %#v", scope.Operations)
}
if gotContext != nil {
t.Fatal("loader ran during adapter construction")
}
stateValue := &state{}
acquired, err := scope.Acquire(ctx, Request{Operation: Show, Selection: selection}, input{Label: "typed"}, stateValue)
if err != nil {
t.Fatal(err)
}
if gotContext != ctx || !reflect.DeepEqual(gotSelection, selection) || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
}
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result || acquired.Value.Metadata == nil || acquired.Value.Findings == nil {
t.Fatal("adapter did not expose the native result facets directly")
}
var effective bytes.Buffer
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if !strings.Contains(effective.String(), "name: native") {
t.Fatalf("native effective output = %q", effective.String())
}
sources := acquired.Value.Sources.Sources()
if len(sources) != 1 || sources[0].Label != "source.yaml" {
t.Fatalf("native sources = %#v", sources)
}
path, err := configkit.ParsePath("/name")
if err != nil {
t.Fatal(err)
}
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
t.Fatal("native explanation facet did not resolve /name")
}
var explanation bytes.Buffer
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
t.Fatal(err)
}
if explanation.Len() == 0 {
t.Fatal("native explanation writer produced no output")
}
}
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
path := configkit.Keys("schema_version")
_, loadErr := configkit.Load(configkit.Bytes("bad source", []byte("{}\n")), configkit.Options[struct{}]{
Definition: configkit.Definition[struct{}]{Version: &configkit.VersionRule{
Path: path,
Check: func(_ configkit.Version, validation *configkit.Validation) error {
validation.Warning(path, "review schema compatibility")
return errors.New("private checker error")
},
}},
})
if loadErr == nil {
t.Fatal("fixture did not produce a ConfigKit load error")
}
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[struct{}], error) {
return nil, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want original %v", gotErr, loadErr)
}
var typed *configkit.LoadError
if !errors.As(gotErr, &typed) {
t.Fatalf("load error type was not retained: %T", gotErr)
}
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || len(acquired.Value.Findings.Messages) != 1 || acquired.Value.Findings.Messages[0].Severity != commandkit.SeverityWarning {
t.Fatalf("failure evidence = %#v", acquired.Value)
}
if acquired.Explanation.Message != "" || len(acquired.Explanation.Details) != 1 {
t.Fatalf("failure explanation = %#v", acquired.Explanation)
}
var warning, terminal bytes.Buffer
if err := acquired.Value.Findings.Messages[0].Write(&warning); err != nil {
t.Fatal(err)
}
if err := acquired.Explanation.Details[0](&terminal); err != nil {
t.Fatal(err)
}
if !strings.Contains(warning.String(), `Configuration scope "app":`) || !strings.Contains(warning.String(), `review schema compatibility`) {
t.Fatalf("warning output lost its scope or record: %q", warning.String())
}
if !strings.Contains(terminal.String(), `Configuration scope "app":`) || !strings.Contains(terminal.String(), `"bad source"`) || strings.Contains(terminal.String(), "review schema compatibility") {
t.Fatalf("terminal explanation lost native context or included an independent warning: %q", terminal.String())
}
if strings.Contains(terminal.String(), "private checker error") {
t.Fatalf("terminal explanation exposed the upstream cause: %q", terminal.String())
}
}
func TestFromConfigKitDoesNotExposeResultFromFailedLoad(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("partial source", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
loadErr := errors.New("loader returned a value with an error")
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, loadErr
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if gotErr != loadErr {
t.Fatalf("load error identity = %v, want %v", gotErr, loadErr)
}
if acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Sources != nil || acquired.Value.Explanation != nil || acquired.Value.Findings != nil {
t.Fatalf("failed load exposed result facets: %#v", acquired.Value)
}
if len(acquired.Explanation.Details) != 0 {
t.Fatalf("warning-only result acquired terminal context: %#v", acquired.Explanation)
}
}
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
warning, noExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
t.Fatalf("warning diagnostics = %#v", warning)
}
if noExplanation.Message != "" || len(noExplanation.Details) != 0 {
t.Fatalf("warning-only explanation = %#v", noExplanation)
}
withWarning, withExplanation := configKitDiagnostics("app", nil, []configkit.Diagnostic{
{Severity: configkit.SeverityWarning, Message: "safe warning"},
{Severity: configkit.SeverityError, Message: "safe error"},
})
if len(withWarning) != 1 || withWarning[0].Severity != commandkit.SeverityWarning || len(withExplanation.Details) != 1 {
t.Fatalf("mixed diagnostics = %#v, explanation = %#v", withWarning, withExplanation)
}
if err := withWarning[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
}
if err := withExplanation.Details[0](shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("terminal short writer error = %v, want %v", err, io.ErrShortWrite)
}
wantErr := errors.New("writer failed")
if err := withWarning[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("writer error = %v, want %v", err, wantErr)
}
if err := withExplanation.Details[0](failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
t.Fatalf("terminal writer error = %v, want %v", err, wantErr)
}
}
func TestConfigKitTerminalContextRetainsRelatedLocationsAndSnapshots(t *testing.T) {
primary := &configkit.Location{SourceID: "primary-source", Line: 4, Column: 2}
related := []configkit.Location{{SourceID: "related-source", Line: 2, Column: 1}}
sources := []configkit.SourceInfo{
{ID: "primary-source", Label: "primary", Path: "primary.yml"},
{ID: "related-source", Label: "related", Path: "related.yml"},
}
records := []configkit.Diagnostic{{
Severity: configkit.SeverityError,
Path: configkit.Keys("name"),
Message: "native terminal context",
Primary: primary,
Related: related,
}}
_, explanation := configKitDiagnostics("app", sources, records)
primary.SourceID = "mutated-primary"
related[0].SourceID = "mutated-related"
sources[0].Path = "mutated.yml"
if len(explanation.Details) != 1 {
t.Fatalf("explanation = %#v", explanation)
}
var output bytes.Buffer
if err := explanation.Details[0](&output); err != nil {
t.Fatal(err)
}
for _, fragment := range []string{"primary.yml", "primary-source", "related-source", "native terminal context"} {
if !strings.Contains(output.String(), fragment) {
t.Errorf("terminal context %q omits %q", output.String(), fragment)
}
}
if strings.Contains(output.String(), "mutated-") || strings.Contains(output.String(), "mutated.yml") {
t.Fatalf("terminal renderer retained caller mutations: %q", output.String())
}
}
type shortWriter struct{}
func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil }
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestFromConfigKitUsesFailureSnapshotInsteadOfReturnedResult(t *testing.T) {
result, err := adapterSettingsLoad(configkit.Mapping("successful", map[string]any{"greeting": "Hello"}))
if err != nil {
t.Fatal(err)
}
_, failure := configkit.Load[map[string]any](configkit.Bytes("failed", []byte("broken: [")))
var snapshot *configkit.LoadError
if !errors.As(failure, &snapshot) {
t.Fatalf("load failure: %v", failure)
}
wrapped := fmt.Errorf("loading: %w", failure)
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
return result, wrapped
})
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
if !errors.Is(gotErr, failure) || acquired.Value.Effective != nil || acquired.Value.Metadata != nil || acquired.Value.Explanation != nil || acquired.Value.Sources == nil || acquired.Value.Findings == nil || !reflect.DeepEqual(acquired.Value.Sources.Sources(), snapshot.Sources()) || !reflect.DeepEqual(acquired.Value.Findings.Records, snapshot.Diagnostics()) || len(acquired.Diagnostics) != 0 {
t.Fatalf("failed acquisition = %#v, %v", acquired, gotErr)
}
}

View File

@@ -0,0 +1,104 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func TestDetailedDiffPreservesNativeEvidenceAndAssertions(t *testing.T) {
values := []configkit.Difference{
{Path: configkit.Keys("added"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
{Path: configkit.Keys("secret"), Left: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, Right: configkit.ValueSnapshot{Present: true, Redacted: true, YAML: "'***'\n"}, HiddenChanged: true},
{Path: configkit.Keys("list"), Left: configkit.ValueSnapshot{Present: true, YAML: "[one, two]\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "[three]\n"}},
{Path: configkit.Keys("marker"), Left: configkit.ValueSnapshot{Present: true, YAML: "'redacted'\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "'***'\n"}},
}
calls := 0
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
calls++
return commandkit.Acquired[[]configkit.Difference]{Value: values}, nil
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
for _, format := range []string{"json", "yaml", "text"} {
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "diff", "--details", "--check", "--format=" + format}, commandkit.Streams{Out: &out, Err: io.Discard})
var logical any
var err error
if format == "json" {
err = json.Unmarshal(out.Bytes(), &logical)
} else {
logical, err = commandkit.DecodeYAMLReport(&out)
}
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || err != nil {
t.Fatalf("format %s = %#v %v %q", format, result, err, out.String())
}
report := logical.(map[string]any)
entries := report["differences"].([]any)
if report["scope"] != "app" || len(entries) != len(values) {
t.Fatalf("format %s report %#v", format, report)
}
added := entries[0].(map[string]any)
before, after := added["before"].(map[string]any), added["after"].(map[string]any)
if before["present"] != false || before["value"] != nil || after["present"] != true || after["value"] != nil {
t.Fatalf("null/absence: %#v", added)
}
secret := entries[2].(map[string]any)
if secret["hidden_changed"] != true || !reflect.DeepEqual(secret["before"].(map[string]any)["value"], secret["after"].(map[string]any)["value"]) {
t.Fatalf("hidden change: %#v", secret)
}
list := entries[3].(map[string]any)["after"].(map[string]any)["value"]
if !reflect.DeepEqual(list, []any{"three"}) {
t.Fatalf("atomic sequence: %#v", list)
}
marker := entries[4].(map[string]any)["after"].(map[string]any)
if marker["value"] != "***" || marker["redacted"] != false {
t.Fatalf("marker string: %#v", marker)
}
}
if calls != 3 {
t.Fatalf("provider calls=%d", calls)
}
result := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || calls != 4 {
t.Fatalf("ordinary diff: %#v calls=%d", result, calls)
}
values = nil
var out bytes.Buffer
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), `"differences": []`) {
t.Fatalf("equality: %#v %q", result, out.String())
}
}
func TestDiffAssertionRetainsDomainOutcomeOnDeliveryFailures(t *testing.T) {
values := []configkit.Difference{{Path: configkit.Keys("x"), Right: configkit.ValueSnapshot{Present: true, YAML: "true\n"}}}
cause := errors.New("provider failed")
var providerErr error
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Value: values}, providerErr
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result := app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("writer failure: %#v", result)
}
values[0].Right.YAML = "[invalid"
result = app.Execute(context.Background(), []string{"config", "diff", "--check", "--details"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeDomain || result.Status != 3 || result.Presentation.Output.RenderError == nil {
t.Fatalf("render failure: %#v", result)
}
providerErr = cause
result = app.Execute(context.Background(), []string{"config", "diff", "--check"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, cause) {
t.Fatalf("provider failure: %#v", result)
}
}

View File

@@ -0,0 +1,136 @@
package ckconfig
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func validationDiagnostic(message string) commandkit.Diagnostic {
return commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(w io.Writer) error { _, err := io.WriteString(w, message+"\n"); return err }}
}
func TestDetailedValidationFiltersAndRetainsFailureEvidence(t *testing.T) {
cause := errors.New("second scope failed")
calls := []string{}
reader := &sourceInventory{values: []configkit.SourceInfo{{ID: "primary", Label: "main", Path: "main.yml"}, {ID: "related", Label: "included", Path: "include.yml"}, {ID: "unused", Label: "unused"}}}
records := []configkit.Diagnostic{
{Code: "chosen", Severity: configkit.SeverityWarning, Path: configkit.Keys("a/b"), Message: "selected warning", Primary: &configkit.Location{SourceID: "primary", Line: 2, Column: 3}, Related: []configkit.Location{{SourceID: "related", Line: 7, Column: 8}}},
{Code: "other", Severity: configkit.SeverityWarning, Path: configkit.Keys("other"), Message: "other warning"},
}
scopes := []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "app")
return commandkit.Acquired[View]{Value: View{Sources: reader, Findings: &Findings{Records: records, Messages: []commandkit.Diagnostic{validationDiagnostic("native warnings")}}}, Diagnostics: []commandkit.Diagnostic{validationDiagnostic("application warning")}}, nil
}},
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "second")
return commandkit.Acquired[View]{Value: View{Findings: &Findings{Records: []configkit.Diagnostic{{Code: "failed", Severity: configkit.SeverityError, Message: "failed finding"}}}}, Explanation: commandkit.Explanation{Message: "terminal failure"}}, cause
}},
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls = append(calls, "third")
return commandkit.Acquired[View]{}, nil
}},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
var out, diag bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "validate", "--scope=all", "--details", "--severity=warning", "--code=chosen", "--format=json", "--verbose"}, commandkit.Streams{Out: &out, Err: &diag})
decoder := json.NewDecoder(&out)
var report validationReport
if err := decoder.Decode(&report); err != nil {
t.Fatal(err)
}
if err := decoder.Decode(new(any)); err != io.EOF {
t.Fatalf("extra JSON: %v", err)
}
if !errors.Is(result.Cause, cause) || result.Kind != commandkit.OutcomeOperational || len(calls) != 2 || calls[0] != "app" || calls[1] != "second" || len(report.Scopes) != 2 || report.Scopes[0].Outcome != "valid" || report.Scopes[1].Outcome != "failed" {
t.Fatalf("result=%#v calls=%v report=%#v", result, calls, report)
}
first, second := report.Scopes[0].Details, report.Scopes[1].Details
if first == nil || first.State != "present" || len(first.Findings) != 1 || first.Findings[0].Path != "/a~1b" || len(first.Sources) != 2 || first.Sources[0].ID != "primary" || first.Sources[1].ID != "related" || second == nil || second.State != "filtered" || len(second.Findings) != 0 {
t.Fatalf("details=%#v %#v", first, second)
}
if strings.Contains(diag.String(), "native warnings") || !strings.Contains(diag.String(), "application warning") || !strings.Contains(diag.String(), "terminal failure") {
t.Fatalf("diagnostics=%q", diag.String())
}
before := len(calls)
for _, args := range [][]string{{"--severity=warning"}, {"--code=x"}, {"--details", "--severity=unknown"}} {
result = app.Execute(context.Background(), append([]string{"config", "validate"}, args...), commandkit.Streams{Out: io.Discard, Err: io.Discard})
if result.Kind != commandkit.OutcomeUsage || len(calls) != before {
t.Fatalf("invalid %v: %#v", args, result)
}
}
}
func TestValidationCancellationAndDeliveryRetainReachedScopes(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
scopes := []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{Value: View{Findings: &Findings{}}}, nil
}},
{Name: "second", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
cancel()
return commandkit.Acquired[View]{}, nil
}},
{Name: "third", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
calls++
return commandkit.Acquired[View]{}, nil
}},
}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: scopes}))
var out bytes.Buffer
result := app.Execute(ctx, []string{"config", "validate", "--scope=all", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
var report validationReport
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
t.Fatal(err)
}
if !errors.Is(result.Cause, context.Canceled) || calls != 2 || len(report.Scopes) != 2 || report.Scopes[0].Details.State != "empty" || report.Scopes[1].Details.State != "unavailable" || report.Scopes[1].Outcome != "canceled" {
t.Fatalf("result=%#v calls=%d report=%#v", result, calls, report)
}
failure := errors.New("load failure")
scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
return commandkit.Acquired[View]{}, failure
}}
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
result = app.Execute(context.Background(), []string{"config", "validate", "--details"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard})
if !errors.Is(result.Cause, failure) || result.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("failure and delivery: %#v", result)
}
out.Reset()
result = app.Execute(context.Background(), []string{"config", "validate", "--details", "--quiet"}, commandkit.Streams{Out: &out, Err: io.Discard})
if !errors.Is(result.Cause, failure) || out.Len() != 0 {
t.Fatalf("quiet failure: %#v %q", result, out.String())
}
}
func TestDetailedValidationReportsNativeLoadError(t *testing.T) {
_, failure := configkit.Load[map[string]any](configkit.Bytes("invalid source", []byte("invalid: [")))
var native *configkit.LoadError
if !errors.As(failure, &native) {
t.Fatalf("fixture: %v", failure)
}
scope := FromConfigKit("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[map[string]any], error) {
return nil, failure
})
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
var out bytes.Buffer
result := app.Execute(context.Background(), []string{"config", "validate", "--details", "--format=json"}, commandkit.Streams{Out: &out, Err: io.Discard})
var report validationReport
if err := json.Unmarshal(out.Bytes(), &report); err != nil {
t.Fatal(err)
}
if !errors.Is(result.Cause, failure) || len(report.Scopes) != 1 || report.Scopes[0].Outcome != "failed" || report.Scopes[0].Details.State != "present" || len(report.Scopes[0].Details.Findings) != len(native.Diagnostics()) {
t.Fatalf("native failure: %#v %q", result, out.String())
}
}

View File

@@ -0,0 +1,43 @@
package ckconfig
import (
"errors"
"fmt"
commandkit "go.maximumdirect.net/commandkit"
)
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
if plan.comparison == nil || plan.comparison.scope == nil {
cause := errors.New("configuration comparison was not prepared")
return configFailure("Configuration comparison was not prepared.", commandkit.Explanation{}, cause)
}
selected := plan.comparison
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", commandkit.Explanation{}, err)
}
compare := selected.scope.scope.Compare
if compare == nil {
cause := fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name)
return configFailure("Configuration scope does not support comparison.", commandkit.Explanation{}, cause)
}
acquired, err := compare(invocation.Context, selected.request, state)
reporter := invocation.Reporter()
queueConfigDiagnostics(reporter, acquired.Diagnostics)
if err != nil {
return configFailure(fmt.Sprintf("Unable to compare configuration scope %q.", selected.scope.scope.Name), acquired.Explanation, err)
}
if err := invocation.Context.Err(); err != nil {
return configFailure("Configuration comparison was canceled.", acquired.Explanation, err)
}
outcome := renderPreparedConfigReport(invocation, acquired.Explanation, func() (any, error) {
if plan.details {
return renderDetailedDiff(selected.scope.scope.Name, acquired.Value)
}
return renderDiffReport(acquired.Value)
})
if outcome.Kind == commandkit.OutcomeSuccess && plan.check && len(acquired.Value) > 0 {
return commandkit.Domain("configuration differences", 3)
}
return outcome
}

View File

@@ -0,0 +1,288 @@
package ckconfig
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4"
)
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndIndependentSelections(t *testing.T) {
type settings struct {
Greeting string `yaml:"greeting"`
}
var selections []Selection
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
selections = append(selections, selection)
value := "Hello"
if selection.Config.Set && selection.Config.Value == "after.yml" {
value = "Goodbye"
}
return configkit.Load(configkit.Mapping("selected configuration", map[string]any{"greeting": value}), configkit.Options[settings]{})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before.yml", "--overlay", "base", "--against-config", "after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("default YAML comparison outcome = %#v", outcome)
}
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay.Set {
t.Fatalf("comparison selections = %#v", selections)
}
var document yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &document); err != nil {
t.Fatalf("default report is not YAML: %q: %v", output.String(), err)
}
changes := reportField(document.Content[0], "changes")
if value := reportField(changes, "greeting"); value == nil || value.Value != "Goodbye" {
t.Fatalf("public comparison output = %#v", document.Content[0])
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "yaml", "--config", "before.yml", "--against-config", "before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("explicit YAML comparison outcome = %#v", outcome)
}
var equal yaml.Node
if err := yaml.Unmarshal(output.Bytes(), &equal); err != nil {
t.Fatalf("explicit YAML report is not YAML: %q: %v", output.String(), err)
}
if len(reportField(equal.Content[0], "additions").Content) != 0 || len(reportField(equal.Content[0], "changes").Content) != 0 || len(reportField(equal.Content[0], "deletions").Content) != 0 {
t.Fatalf("equal comparison did not return empty categories: %q", output.String())
}
loadsBeforeText := len(selections)
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || len(selections) != loadsBeforeText+2 || !strings.Contains(output.String(), "deletions:") {
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
}
}
func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t *testing.T) {
var inputCalls, stateCalls, acquireCalls, compareCalls int
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
NewInput: func() struct{} { inputCalls++; return struct{}{} },
NewState: func() struct{} { stateCalls++; return struct{}{} },
Scopes: []Scope[struct{}, struct{}]{
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
compareCalls++
return commandkit.Acquired[[]configkit.Difference]{}, nil
}},
{Name: "plain", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
acquireCalls++
return commandkit.Acquired[View]{}, nil
}},
},
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "app: diff") {
t.Fatalf("comparison-only discovery = %#v, %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || compareCalls != 1 {
t.Fatalf("comparison-only execution = %#v calls=%d", outcome, compareCalls)
}
inputsBefore, statesBefore := inputCalls, stateCalls
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeUsage || inputCalls != inputsBefore || stateCalls != statesBefore || acquireCalls != 0 || compareCalls != 1 || output.Len() != 0 {
t.Fatalf("unsupported scope outcome/work/output = %#v / %d,%d,%d,%d / %q", outcome, inputCalls, stateCalls, acquireCalls, compareCalls, output.String())
}
}
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
wantErr := errors.New("provider unavailable")
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{diagnostic},
Explanation: commandkit.Explanation{Details: []commandkit.Detail{func(writer io.Writer) error {
_, err := io.WriteString(writer, "native comparison context\n")
return err
}}},
}, fmt.Errorf("comparison setup: %w", wantErr)
},
}}}))
var output, diagnosticOutput bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: &diagnosticOutput})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || output.Len() != 0 {
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
}
if got := diagnosticOutput.String(); !strings.Contains(got, "provider warning") || !strings.Contains(got, "native comparison context") || strings.Index(got, "provider warning") > strings.Index(got, "native comparison context") || strings.Contains(got, wantErr.Error()) {
t.Fatalf("provider messages and terminal context = %q", got)
}
}
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{Value: []configkit.Difference{{
Path: configkit.Keys("invalid"), Right: configkit.ValueSnapshot{Present: true, YAML: "bad: ["},
}}}, nil
},
}}}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--quiet"}, commandkit.Streams{Out: failingOutput{err: errors.New("quiet destination should not be called")}, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete || outcome.Presentation.Output.RenderError == nil || output.Len() != 0 {
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(io.Writer) error { return nil }}
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
cancel()
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
},
}}}))
defer cancel()
var output bytes.Buffer
outcome := app.Execute(ctx, []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || output.Len() != 0 {
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
}
}
func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{}, nil
},
}}}))
wantErr := errors.New("output failed")
for _, test := range []struct {
name string
writer io.Writer
want error
}{
{name: "writer error", writer: failingOutput{err: wantErr}, want: wantErr},
{name: "short write", writer: shortWriter{}, want: io.ErrShortWrite},
} {
t.Run(test.name, func(t *testing.T) {
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Output.WriteError, test.want) || outcome.Presentation.Output.State != commandkit.DeliveryIncomplete {
t.Fatalf("delivery failure outcome = %#v", outcome)
}
})
}
}
func TestConfigDiffDiagnosticDestinationFailureIsPresentationEvidence(t *testing.T) {
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
Name: "app", Operations: []Operation{Diff},
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
return commandkit.Acquired[[]configkit.Difference]{
Diagnostics: []commandkit.Diagnostic{{Severity: commandkit.SeverityWarning, Write: func(writer io.Writer) error {
_, err := io.WriteString(writer, "provider warning\n")
return err
}}},
}, nil
},
}}}))
writeErr := errors.New("diagnostic destination failed")
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--verbose"}, commandkit.Streams{Out: &output, Err: failingOutput{err: writeErr}})
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != nil || !errors.Is(outcome.Presentation.Diagnostics.WriteError, writeErr) || outcome.Presentation.Diagnostics.State != commandkit.DeliveryIncomplete {
t.Fatalf("diagnostic delivery failure outcome = %#v", outcome)
}
if output.Len() == 0 {
t.Fatal("healthy result destination did not receive the diff report")
}
}
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
type input struct {
ID int
Label string
}
type state struct{ ID int }
label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString,
func(target *input, value commandkit.Supplied[string]) { target.Label = value.Value })
var inputID, stateID int
var observed []struct {
left, right input
state *state
}
app := newConfigTestApp(t, Command(Suite[input, *state]{
NewInput: func() input { inputID++; return input{ID: inputID} },
NewState: func() *state { stateID++; return &state{ID: stateID} },
Selectors: []Selector[input]{{Binding: label, Scopes: []string{"app"}}},
Scopes: []Scope[input, *state]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], shared *state) (commandkit.Acquired[[]configkit.Difference], error) {
observed = append(observed, struct {
left, right input
state *state
}{request.Left.Input, request.Right.Input, shared})
left, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("left", map[string]any{"label": request.Left.Input.Label}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
right, err := configkit.Load[struct {
Label string `yaml:"label"`
}](configkit.Mapping("right", map[string]any{"label": request.Right.Input.Label + "-right"}))
if err != nil {
return commandkit.Acquired[[]configkit.Difference]{}, err
}
differences, err := configkit.Diff(left, right)
return commandkit.Acquired[[]configkit.Difference]{Value: differences}, err
}}},
}))
for range 2 {
outcome := app.Execute(context.Background(), []string{"config", "diff", "--label", "current"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess {
t.Fatalf("comparison invocation outcome = %#v", outcome)
}
}
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
t.Fatalf("input instances were reused: %#v", observed)
}
if observed[0].left.Label != "current" || observed[0].right.Label != "" || observed[1].left.Label != "current" || observed[1].right.Label != "" {
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
}
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
t.Fatalf("state was not fresh per invocation: %#v", observed)
}
}
func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
type settings struct {
Secret string `yaml:"secret"`
}
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
value := "same"
if selection.Config.Set && selection.Config.Value == "after" {
value = "changed-secret"
}
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}})
}))
var output bytes.Buffer
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "[REDACTED]") || strings.Contains(output.String(), "changed-secret") {
t.Fatalf("concealed comparison outcome/report = %#v / %q", outcome, output.String())
}
output.Reset()
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "before"}, commandkit.Streams{Out: &output, Err: io.Discard})
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "additions:") || !strings.Contains(output.String(), "changes:") || !strings.Contains(output.String(), "deletions:") {
t.Fatalf("empty comparison outcome/report = %#v / %q", outcome, output.String())
}
}

View File

@@ -0,0 +1,156 @@
package ckconfig
import (
"fmt"
"strings"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
)
func decodeJSONPointer(pointer string) ([]string, error) {
if pointer == "" {
return nil, nil
}
if pointer[0] != '/' {
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
}
parts := strings.Split(pointer[1:], "/")
for index, part := range parts {
var decoded strings.Builder
decoded.Grow(len(part))
for offset := 0; offset < len(part); offset++ {
if part[offset] != '~' {
decoded.WriteByte(part[offset])
continue
}
if offset+1 >= len(part) {
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
}
offset++
switch part[offset] {
case '0':
decoded.WriteByte('~')
case '1':
decoded.WriteByte('/')
default:
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
}
}
parts[index] = decoded.String()
}
return parts, nil
}
func placeReportValue(root any, path []string, value any) (any, error) {
if len(path) == 0 {
return value, nil
}
current, ok := root.(map[string]any)
if !ok {
return nil, fmt.Errorf("report path ancestor is not a mapping")
}
for index, token := range path {
if index == len(path)-1 {
current[token] = value
return root, nil
}
child, exists := current[token]
if !exists {
next := make(map[string]any)
current[token] = next
current = next
continue
}
next, ok := child.(map[string]any)
if !ok {
return nil, fmt.Errorf("report path ancestor %q is not a mapping", token)
}
current = next
}
return root, nil
}
type diffReport struct {
Additions any `json:"additions"`
Changes any `json:"changes"`
Deletions []string `json:"deletions"`
}
type detailedDiffReport struct {
Scope string `json:"scope"`
Differences []detailedDifference `json:"differences"`
}
type detailedDifference struct {
Path string `json:"path"`
Before detailedDiffSnapshot `json:"before"`
After detailedDiffSnapshot `json:"after"`
HiddenChanged bool `json:"hidden_changed"`
}
type detailedDiffSnapshot struct {
Present bool `json:"present"`
Redacted bool `json:"redacted"`
Value any `json:"value"`
}
func renderDetailedDiff(scope string, differences []configkit.Difference) (any, error) {
report := detailedDiffReport{Scope: scope, Differences: make([]detailedDifference, 0, len(differences))}
for _, difference := range differences {
before, err := detailedSnapshot(difference.Left)
if err != nil {
return nil, fmt.Errorf("difference at %q before: %w", difference.Path.String(), err)
}
after, err := detailedSnapshot(difference.Right)
if err != nil {
return nil, fmt.Errorf("difference at %q after: %w", difference.Path.String(), err)
}
report.Differences = append(report.Differences, detailedDifference{Path: difference.Path.String(), Before: before, After: after, HiddenChanged: difference.HiddenChanged})
}
return report, nil
}
func detailedSnapshot(snapshot configkit.ValueSnapshot) (detailedDiffSnapshot, error) {
report := detailedDiffSnapshot{Present: snapshot.Present, Redacted: snapshot.Redacted}
if snapshot.Present {
value, err := commandkit.DecodeYAMLReport(strings.NewReader(snapshot.YAML))
if err != nil {
return report, err
}
report.Value = value
}
return report, nil
}
func renderDiffReport(differences []configkit.Difference) (any, error) {
report := diffReport{Additions: map[string]any{}, Changes: map[string]any{}, Deletions: []string{}}
for _, difference := range differences {
path := difference.Path.String()
if difference.Left.Present && !difference.Right.Present {
report.Deletions = append(report.Deletions, path)
continue
}
if !difference.Right.Present {
continue
}
tokens, err := decodeJSONPointer(path)
if err != nil {
return nil, fmt.Errorf("difference at %q: %w", path, err)
}
value, err := commandkit.DecodeYAMLReport(strings.NewReader(difference.Right.YAML))
if err != nil {
return nil, fmt.Errorf("difference at %q: %w", path, err)
}
if difference.Left.Present {
report.Changes, err = placeReportValue(report.Changes, tokens, value)
} else {
report.Additions, err = placeReportValue(report.Additions, tokens, value)
}
if err != nil {
return nil, fmt.Errorf("difference at %q: %w", path, err)
}
}
return report, nil
}

View File

@@ -0,0 +1,169 @@
package ckconfig
import (
"bytes"
"strings"
"testing"
commandkit "go.maximumdirect.net/commandkit"
configkit "go.maximumdirect.net/configkit"
"go.yaml.in/yaml/v4"
)
func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.Node, []byte) {
t.Helper()
report, err := renderDiffReport(differences)
if err != nil {
t.Fatal(err)
}
var rendered bytes.Buffer
if err := commandkit.RenderReport(&rendered, commandkit.Report{Value: report}, commandkit.FormatYAML); err != nil {
t.Fatal(err)
}
data := rendered.Bytes()
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
t.Fatalf("decode rendered report %q: %v", report, err)
}
return document.Content[0], data
}
func reportField(mapping *yaml.Node, key string) *yaml.Node {
for index := 0; index+1 < len(mapping.Content); index += 2 {
if mapping.Content[index].Value == key {
return mapping.Content[index+1]
}
}
return nil
}
func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("z-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
{Path: configkit.Keys("count"), Left: configkit.ValueSnapshot{Present: true, YAML: "1\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "9007199254740993\n"}},
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "unused"}},
{Path: configkit.Keys("a-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "'123'\n"}},
}
root, _ := renderAndDecode(t, differences)
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
t.Fatalf("report root = %#v", root)
}
additions := reportField(root, "additions")
if value := reportField(additions, "z-added"); value == nil || value.Tag != "!!bool" || value.Value != "false" {
t.Fatalf("boolean addition = %#v", value)
}
if value := reportField(additions, "a-added"); value == nil || value.Tag != "!!str" || value.Value != "123" {
t.Fatalf("string addition = %#v", value)
}
changes := reportField(root, "changes")
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
t.Fatalf("change value/type = %#v", changes)
}
deletions := reportField(root, "deletions")
if deletions.Kind != yaml.SequenceNode || len(deletions.Content) != 1 || deletions.Content[0].Tag != "!!str" || deletions.Content[0].Value != "/removed" {
t.Fatalf("deletion records = %#v", deletions)
}
}
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
root, _ := renderAndDecode(t, nil)
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
t.Fatalf("empty report categories = %#v", root)
}
root, _ = renderAndDecode(t, []configkit.Difference{{Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}})
additions := reportField(root, "additions")
if additions.Tag != "!!null" || additions.Value != "null" {
t.Fatalf("whole-root addition was wrapped: %#v", additions)
}
}
func TestRenderDiffReportDistinguishesDeletionFromNullAndEmptyMappings(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("nullable"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
{Path: configkit.Keys("settings", "first"), Left: configkit.ValueSnapshot{Present: true, YAML: "intentionally not parsed"}},
{Path: configkit.Keys("settings", "second"), Left: configkit.ValueSnapshot{Present: true, YAML: "also not parsed"}},
}
root, _ := renderAndDecode(t, differences)
if got := reportField(root, "additions").Content; len(got) != 2 || got[0].Value != "nullable" || got[1].Tag != "!!null" {
t.Fatalf("explicit null addition = %#v", got)
}
deletions := reportField(root, "deletions")
if len(deletions.Content) != 2 || deletions.Content[0].Value != "/settings/first" || deletions.Content[1].Value != "/settings/second" {
t.Fatalf("mapping-to-empty deletion records = %#v", deletions)
}
}
func TestRenderDiffReportKeepsAtomicSequencesAndStructuralValues(t *testing.T) {
differences := []configkit.Difference{
{Path: configkit.Keys("items"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "- one\n- two\n"}},
{Path: configkit.Keys("settings"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "nested:\n enabled: true\n"}},
}
root, _ := renderAndDecode(t, differences)
changes := reportField(root, "changes")
if changes.Content[1].Kind != yaml.SequenceNode || len(changes.Content[1].Content) != 2 {
t.Fatalf("sequence change was expanded or altered: %#v", changes.Content[1])
}
settings := reportField(changes, "settings")
if settings.Kind != yaml.MappingNode || reportField(settings, "nested").Kind != yaml.MappingNode || reportField(reportField(settings, "nested"), "enabled").Tag != "!!bool" {
t.Fatalf("structural replacement was altered: %#v", settings)
}
}
func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.T) {
type settings struct {
Public string `yaml:"public"`
Secret string `yaml:"secret"`
}
definition := configkit.Definition[settings]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }}
load := func(public, secret string) *configkit.Result[settings] {
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
if err != nil {
t.Fatal(err)
}
return result
}
left := load("visible", "old-secret")
right := load("[REDACTED]", "new-secret")
differences, err := configkit.Diff(left, right)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 || differences[0].Path.String() != "/public" || differences[1].Path.String() != "/secret" || !differences[1].HiddenChanged || !differences[1].Right.Redacted {
t.Fatalf("native changes/mask metadata = %#v", differences)
}
root, report := renderAndDecode(t, differences)
changes := reportField(root, "changes")
for _, key := range []string{"public", "secret"} {
value := reportField(changes, key)
if value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
t.Fatalf("safe value for %q = %#v", key, value)
}
}
if bytes.Contains(report, []byte("HiddenChanged")) || bytes.Contains(report, []byte("Redacted")) || strings.Contains(string(report), "old-secret") || strings.Contains(string(report), "new-secret") {
t.Fatalf("report exposed native metadata or secret contents: %q", report)
}
concealedOnly := load("[REDACTED]", "later-secret")
concealedDifferences, err := configkit.Diff(right, concealedOnly)
if err != nil {
t.Fatal(err)
}
if len(concealedDifferences) != 1 || concealedDifferences[0].Path.String() != "/secret" || !concealedDifferences[0].HiddenChanged {
t.Fatalf("concealed-only native records = %#v", concealedDifferences)
}
concealedRoot, _ := renderAndDecode(t, concealedDifferences)
if value := reportField(reportField(concealedRoot, "changes"), "secret"); value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
t.Fatalf("concealed-only safe value = %#v", value)
}
}
func TestRenderDiffReportReportsMalformedPresentSnapshot(t *testing.T) {
report, err := renderDiffReport([]configkit.Difference{{
Path: configkit.Keys("setting"),
Right: configkit.ValueSnapshot{Present: true, YAML: "setting: [unterminated\n"},
}})
if err == nil || report != nil || !strings.Contains(err.Error(), "/setting") {
t.Fatalf("malformed snapshot report/error = %q / %v", report, err)
}
}

View File

@@ -0,0 +1,57 @@
package ckconfig
import (
"reflect"
"testing"
)
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
tests := []struct {
pointer string
want []string
}{
{pointer: "", want: nil},
{pointer: "/", want: []string{""}},
{pointer: "/parent/child", want: []string{"parent", "child"}},
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
{pointer: "/~01", want: []string{"~1"}},
}
for _, test := range tests {
got, err := decodeJSONPointer(test.pointer)
if err != nil {
t.Fatalf("decode %q: %v", test.pointer, err)
}
if !reflect.DeepEqual(got, test.want) {
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
}
}
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
if _, err := decodeJSONPointer(pointer); err == nil {
t.Errorf("decode %q unexpectedly succeeded", pointer)
}
}
}
func TestPlaceReportValue(t *testing.T) {
var root any = map[string]any{}
var err error
root, err = placeReportValue(root, []string{"a/b", "~1"}, false)
if err != nil {
t.Fatal(err)
}
root, err = placeReportValue(root, []string{""}, nil)
if err != nil {
t.Fatal(err)
}
want := map[string]any{"a/b": map[string]any{"~1": false}, "": nil}
if !reflect.DeepEqual(root, want) {
t.Fatalf("got %#v", root)
}
if _, err := placeReportValue(root, []string{"a/b", "~1", "child"}, 1); err == nil {
t.Fatal("scalar ancestor accepted")
}
if value, err := placeReportValue(root, nil, "replacement"); err != nil || value != "replacement" {
t.Fatalf("root: %#v, %v", value, err)
}
}

Some files were not shown because too many files have changed in this diff Show More