@@ -246,6 +246,124 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
246246 printCompletionScript ( shell ) ;
247247 } ) ;
248248
249+ // ── Built-in: contract (API schema drift detection) ──────────────────────
250+
251+ const contractCmd = program . command ( 'contract' ) . description ( 'API schema drift detection' ) ;
252+
253+ contractCmd
254+ . command ( 'snapshot' )
255+ . description ( 'Run a command and save its response schema as baseline' )
256+ . argument ( '<site>' , 'Site name (e.g. hackernews)' )
257+ . argument ( '<command>' , 'Command name (e.g. top)' )
258+ . argument ( '[args...]' , 'Extra arguments forwarded to the command' )
259+ . action ( async ( site : string , command : string , extraArgs : string [ ] ) => {
260+ const { captureSchema, saveContract, formatSchemaTree } = await import ( './contract.js' ) ;
261+ const { getRegistry } = await import ( './registry.js' ) ;
262+ const { executeCommand } = await import ( './execution.js' ) ;
263+
264+ const key = `${ site } /${ command } ` ;
265+ const cmd = getRegistry ( ) . get ( key ) ;
266+ if ( ! cmd ) {
267+ console . error ( chalk . red ( `Command not found: ${ key } ` ) ) ;
268+ process . exitCode = 1 ;
269+ return ;
270+ }
271+
272+ // Parse extra args as --key value pairs
273+ const kwargs : Record < string , string > = { } ;
274+ for ( let i = 0 ; i < extraArgs . length ; i ++ ) {
275+ const arg = extraArgs [ i ] ;
276+ if ( arg . startsWith ( '--' ) && i + 1 < extraArgs . length ) {
277+ kwargs [ arg . slice ( 2 ) ] = extraArgs [ ++ i ] ;
278+ }
279+ }
280+
281+ try {
282+ const result = await executeCommand ( cmd , kwargs ) ;
283+ const schema = captureSchema ( result ) ;
284+ const filePath = saveContract ( site , command , schema ) ;
285+ console . log ( chalk . green ( `Schema snapshot saved: ${ filePath } ` ) ) ;
286+ console . log ( formatSchemaTree ( schema ) ) ;
287+ } catch ( err : any ) {
288+ console . error ( chalk . red ( `Error executing ${ key } : ${ err . message } ` ) ) ;
289+ process . exitCode = 1 ;
290+ }
291+ } ) ;
292+
293+ contractCmd
294+ . command ( 'check' )
295+ . description ( 'Run a command and diff its response schema against the saved baseline' )
296+ . argument ( '<site>' , 'Site name' )
297+ . argument ( '<command>' , 'Command name' )
298+ . argument ( '[args...]' , 'Extra arguments forwarded to the command' )
299+ . action ( async ( site : string , command : string , extraArgs : string [ ] ) => {
300+ const { captureSchema, loadContract, diffSchema, formatDiff } = await import ( './contract.js' ) ;
301+ const { getRegistry } = await import ( './registry.js' ) ;
302+ const { executeCommand } = await import ( './execution.js' ) ;
303+
304+ const key = `${ site } /${ command } ` ;
305+ const cmd = getRegistry ( ) . get ( key ) ;
306+ if ( ! cmd ) {
307+ console . error ( chalk . red ( `Command not found: ${ key } ` ) ) ;
308+ process . exitCode = 1 ;
309+ return ;
310+ }
311+
312+ const baseline = loadContract ( site , command ) ;
313+ if ( ! baseline ) {
314+ console . error ( chalk . red ( `No baseline found for ${ key } . Run 'opencli contract snapshot ${ site } ${ command } ' first.` ) ) ;
315+ process . exitCode = 1 ;
316+ return ;
317+ }
318+
319+ const kwargs : Record < string , string > = { } ;
320+ for ( let i = 0 ; i < extraArgs . length ; i ++ ) {
321+ const arg = extraArgs [ i ] ;
322+ if ( arg . startsWith ( '--' ) && i + 1 < extraArgs . length ) {
323+ kwargs [ arg . slice ( 2 ) ] = extraArgs [ ++ i ] ;
324+ }
325+ }
326+
327+ try {
328+ const result = await executeCommand ( cmd , kwargs ) ;
329+ const currentSchema = captureSchema ( result ) ;
330+ const diffs = diffSchema ( baseline . schema , currentSchema ) ;
331+
332+ if ( diffs . length === 0 ) {
333+ console . log ( chalk . green ( `No schema drift detected for ${ key } (baseline from ${ baseline . capturedAt } )` ) ) ;
334+ } else {
335+ console . log ( chalk . yellow ( formatDiff ( diffs ) ) ) ;
336+ console . log ( ) ;
337+ console . log ( chalk . dim ( `Baseline captured: ${ baseline . capturedAt } ` ) ) ;
338+ process . exitCode = 1 ;
339+ }
340+ } catch ( err : any ) {
341+ console . error ( chalk . red ( `Error executing ${ key } : ${ err . message } ` ) ) ;
342+ process . exitCode = 1 ;
343+ }
344+ } ) ;
345+
346+ contractCmd
347+ . command ( 'list' )
348+ . description ( 'List saved contract baselines' )
349+ . action ( async ( ) => {
350+ const { listContracts } = await import ( './contract.js' ) ;
351+ const contracts = listContracts ( ) ;
352+ if ( contracts . length === 0 ) {
353+ console . log ( chalk . dim ( ' No saved contracts. Use "opencli contract snapshot <site> <command>" to create one.' ) ) ;
354+ return ;
355+ }
356+ console . log ( ) ;
357+ console . log ( chalk . bold ( ' Saved contract baselines' ) ) ;
358+ console . log ( ) ;
359+ for ( const c of contracts ) {
360+ console . log ( ` ${ chalk . cyan ( `${ c . site } /${ c . command } ` ) } ${ chalk . dim ( `captured ${ c . capturedAt } ` ) } ` ) ;
361+ }
362+ console . log ( ) ;
363+ console . log ( chalk . dim ( ` ${ contracts . length } contract(s)` ) ) ;
364+ console . log ( ) ;
365+ } ) ;
366+
249367 // ── Plugin management ──────────────────────────────────────────────────────
250368
251369 const pluginCmd = program . command ( 'plugin' ) . description ( 'Manage opencli plugins' ) ;
0 commit comments