@@ -16,6 +16,7 @@ import type { Command } from 'commander'
1616import { CLI_CONTRACT } from '../src/contract/commands'
1717import { V2_OPERATIONS } from '../src/generated/v2-api'
1818import { buildProgram } from '../src/program'
19+ import { deriveCommandPath } from '../src/runtime/derive'
1920
2021const ROOT = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , '../../..' )
2122const HELP_COMMAND = 'help'
@@ -35,6 +36,8 @@ interface InventoryOption {
3536 required : boolean
3637 description : string
3738 defaultValue ?: string
39+ /** Allowed values when the option is an enum (commander `choices`). */
40+ choices ?: string [ ]
3841}
3942
4043interface InventoryShapeField {
@@ -49,6 +52,11 @@ interface InventoryCommand {
4952 options : InventoryOption [ ]
5053 /** Top-level fields of the JSON response's `data`, when the operation is known. */
5154 shape ?: InventoryShapeField [ ]
55+ /**
56+ * Fields of the JSON request body, two levels deep, for commands that take a
57+ * `<json|@file>` option — the nested payloads an agent otherwise learns from errors.
58+ */
59+ body ?: InventoryShapeField [ ]
5260}
5361
5462function isHiddenCommand ( command : Command ) : boolean {
@@ -67,8 +75,15 @@ function collectLeaves(command: Command, prefix: string[]): { path: string[]; co
6775 return children . flatMap ( ( child ) => collectLeaves ( child , [ ...prefix , child . name ( ) ] ) )
6876}
6977
70- /** Command path → v2 operation name, through the contract's declared command strings. */
78+ /**
79+ * Command path → v2 operation name. Every operation names its command the way the
80+ * program builder does (`deriveCommandPath`); a contract entry's explicit `command`
81+ * string overrides that, exactly as it does when the program is built.
82+ */
7183const OPERATION_BY_PATH = new Map < string , string > ( )
84+ for ( const operation of Object . keys ( V2_OPERATIONS ) as ( keyof typeof V2_OPERATIONS ) [ ] ) {
85+ OPERATION_BY_PATH . set ( deriveCommandPath ( operation ) . join ( ' ' ) , operation )
86+ }
7287for ( const [ operation , spec ] of Object . entries ( CLI_CONTRACT ) ) {
7388 if ( spec && 'command' in spec && typeof spec . command === 'string' ) {
7489 OPERATION_BY_PATH . set ( spec . command , operation )
@@ -85,14 +100,19 @@ type JsonSchema = {
85100 allOf ?: JsonSchema [ ]
86101 nullable ?: boolean
87102 enum ?: unknown [ ]
103+ const ?: unknown
104+ required ?: string [ ]
88105}
89106
90107interface OpenApiDoc {
91108 paths : Record <
92109 string ,
93110 Record <
94111 string ,
95- { responses ?: Record < string , { content ?: Record < string , { schema ?: JsonSchema } > } > }
112+ {
113+ responses ?: Record < string , { content ?: Record < string , { schema ?: JsonSchema } > } >
114+ requestBody ?: { content ?: Record < string , { schema ?: JsonSchema } > }
115+ }
96116 >
97117 >
98118 components ?: { schemas ?: Record < string , JsonSchema > }
@@ -114,19 +134,44 @@ function resolveRef(doc: OpenApiDoc, schema: JsonSchema | undefined): JsonSchema
114134 return current
115135}
116136
117- function typeLabel ( doc : OpenApiDoc , schema : JsonSchema | undefined ) : string {
137+ /**
138+ * A compact type for one schema. `depth` is how many object levels render their fields
139+ * with types (`{name:string,options:{id,name}[]}`); below it an object lists key names
140+ * only, as the response shapes always have.
141+ */
142+ function typeLabel (
143+ doc : OpenApiDoc ,
144+ schema : JsonSchema | undefined ,
145+ depth = 0 ,
146+ enums : 'full' | 'brief' = 'full'
147+ ) : string {
118148 const resolved = resolveRef ( doc , schema )
119149 if ( ! resolved ) return 'unknown'
120- if ( resolved . enum ) return resolved . enum . map ( ( v ) => JSON . stringify ( v ) ) . join ( '|' )
150+ if ( resolved . const !== undefined ) return JSON . stringify ( resolved . const )
151+ if ( resolved . enum ) {
152+ // A response is read, not written: one value and the count is enough to recognise
153+ // the field; a request body needs every legal value.
154+ if ( enums === 'brief' && resolved . enum . length > 2 ) {
155+ return `${ JSON . stringify ( resolved . enum [ 0 ] ) } |…(${ resolved . enum . length } )`
156+ }
157+ return resolved . enum . map ( ( v ) => JSON . stringify ( v ) ) . join ( '|' )
158+ }
121159 const variants = resolved . anyOf ?? resolved . oneOf
122- if ( variants ) return variants . map ( ( v ) => typeLabel ( doc , v ) ) . join ( '|' )
160+ if ( variants ) return variants . map ( ( v ) => typeLabel ( doc , v , depth , enums ) ) . join ( '|' )
123161 const type = Array . isArray ( resolved . type ) ? resolved . type . join ( '|' ) : resolved . type
124- if ( type === 'array' ) return `${ typeLabel ( doc , resolved . items ) } []`
162+ if ( type === 'array' ) return `${ typeLabel ( doc , resolved . items , depth , enums ) } []`
125163 if ( type === 'object' || resolved . properties ) {
126- const keys = Object . keys ( resolved . properties ?? { } )
127- return keys . length > 0
128- ? `{${ keys . slice ( 0 , 8 ) . join ( ',' ) } ${ keys . length > 8 ? ',…' : '' } }`
129- : 'object'
164+ const props = resolved . properties ?? { }
165+ const keys = Object . keys ( props )
166+ if ( keys . length === 0 ) return 'object'
167+ if ( depth > 0 ) {
168+ const required = new Set ( resolved . required ?? [ ] )
169+ return `{${ keys
170+ . slice ( 0 , 12 )
171+ . map ( ( k ) => `${ k } ${ required . has ( k ) ? '' : '?' } :${ typeLabel ( doc , props [ k ] , depth - 1 ) } ` )
172+ . join ( ',' ) } ${ keys . length > 12 ? ',…' : '' } }`
173+ }
174+ return `{${ keys . slice ( 0 , 8 ) . join ( ',' ) } ${ keys . length > 8 ? ',…' : '' } }`
130175 }
131176 return type ?? 'unknown'
132177}
@@ -145,15 +190,53 @@ function responseShape(operation: string): InventoryShapeField[] | undefined {
145190 if ( ! props ) {
146191 const items = resolveRef ( doc , data . items )
147192 if ( items ?. properties ) {
148- return [ { name : '[]' , type : typeLabel ( doc , items ) } ]
193+ return [ { name : '[]' , type : typeLabel ( doc , items , 0 , 'brief' ) } ]
149194 }
150195 return undefined
151196 }
152- return Object . entries ( props ) . map ( ( [ name , s ] ) => ( { name, type : typeLabel ( doc , s ) } ) )
197+ return Object . entries ( props ) . map ( ( [ name , s ] ) => ( {
198+ name,
199+ type : typeLabel ( doc , s , 0 , 'brief' ) ,
200+ } ) )
201+ }
202+ return undefined
203+ }
204+
205+ /** The JSON request body's fields, two levels deep — only asked for commands with a `<json|@file>` option. */
206+ function requestShape (
207+ operation : string ,
208+ jsonFields : Set < string >
209+ ) : InventoryShapeField [ ] | undefined {
210+ const op = V2_OPERATIONS [ operation as keyof typeof V2_OPERATIONS ]
211+ if ( ! op ) return undefined
212+ const docPath = op . path . replace ( / \[ ( [ ^ \] ] + ) \] / g, '{$1}' )
213+ for ( const doc of OPENAPI_DOCS ) {
214+ const entry = doc . paths [ docPath ] ?. [ op . method . toLowerCase ( ) ]
215+ const schema = entry ?. requestBody ?. content ?. [ 'application/json' ] ?. schema
216+ const resolved = resolveRef ( doc , schema )
217+ if ( ! resolved ?. properties ) continue
218+ const required = new Set ( resolved . required ?? [ ] )
219+ const entries = Object . entries ( resolved . properties )
220+ // The CLI injects the workspace; the model never writes it.
221+ . filter ( ( [ name ] ) => name !== 'workspaceId' )
222+ // Scalar flags are already on the signature; the card carries the JSON-valued
223+ // fields only (the ones whose shape is otherwise learned from error messages).
224+ const nested = entries . filter ( ( [ name ] ) => jsonFields . has ( name ) )
225+ return ( nested . length > 0 ? nested : entries ) . map ( ( [ name , s ] ) => ( {
226+ name : required . has ( name ) ? name : `${ name } ?` ,
227+ type : capType ( typeLabel ( doc , s , 2 ) ) ,
228+ } ) )
153229 }
154230 return undefined
155231}
156232
233+ const MAX_BODY_TYPE_CHARS = 220
234+
235+ /** A recursive grammar (the row predicate) expands past what a card line can carry. */
236+ function capType ( label : string ) : string {
237+ return label . length > MAX_BODY_TYPE_CHARS ? `${ label . slice ( 0 , MAX_BODY_TYPE_CHARS ) } …` : label
238+ }
239+
157240const program = buildProgram ( )
158241const inventory : InventoryCommand [ ] = collectLeaves ( program , [ ] ) . map (
159242 ( { path : cmdPath , command } ) => {
@@ -172,15 +255,21 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map(
172255 required : option . mandatory ,
173256 description : option . description ,
174257 ...( option . defaultValue !== undefined ? { defaultValue : String ( option . defaultValue ) } : { } ) ,
258+ ...( option . argChoices ? { choices : option . argChoices } : { } ) ,
175259 } ) )
176260 const operation = OPERATION_BY_PATH . get ( cmdPath . join ( ' ' ) )
177261 const shape = operation ? responseShape ( operation ) : undefined
262+ const jsonFields = new Set (
263+ options . filter ( ( option ) => / < j s o n \| @ f i l e > / . test ( option . flags ) ) . map ( ( option ) => option . name )
264+ )
265+ const body = operation && jsonFields . size > 0 ? requestShape ( operation , jsonFields ) : undefined
178266 return {
179267 path : cmdPath ,
180268 description : command . description ( ) ,
181269 args,
182270 options,
183271 ...( shape ? { shape } : { } ) ,
272+ ...( body ? { body } : { } ) ,
184273 }
185274 }
186275)
0 commit comments