11import type { Command } from "commander" ;
22import JSON5 from "json5" ;
33import { readConfigFileSnapshot , writeConfigFile } from "../config/config.js" ;
4+ import { CONFIG_PATH } from "../config/paths.js" ;
45import { isBlockedObjectKey } from "../config/prototype-keys.js" ;
56import { redactConfigObject } from "../config/redact-snapshot.js" ;
6- import { danger , info } from "../globals.js" ;
7+ import { danger , info , success } from "../globals.js" ;
78import type { RuntimeEnv } from "../runtime.js" ;
89import { defaultRuntime } from "../runtime.js" ;
910import { formatDocsLink } from "../terminal/links.js" ;
@@ -15,6 +16,10 @@ type PathSegment = string;
1516type ConfigSetParseOpts = {
1617 strictJson ?: boolean ;
1718} ;
19+ type ConfigIssue = {
20+ path : string ;
21+ message : string ;
22+ } ;
1823
1924const OLLAMA_API_KEY_PATH : PathSegment [ ] = [ "models" , "providers" , "ollama" , "apiKey" ] ;
2025const OLLAMA_PROVIDER_PATH : PathSegment [ ] = [ "models" , "providers" , "ollama" ] ;
@@ -97,6 +102,21 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
97102 return Object . prototype . hasOwnProperty . call ( value , key ) ;
98103}
99104
105+ function normalizeConfigIssues ( issues : ReadonlyArray < ConfigIssue > ) : ConfigIssue [ ] {
106+ return issues . map ( ( issue ) => ( {
107+ path : issue . path || "<root>" ,
108+ message : issue . message ,
109+ } ) ) ;
110+ }
111+
112+ function formatConfigIssueLines ( issues : ReadonlyArray < ConfigIssue > , marker : string ) : string [ ] {
113+ return normalizeConfigIssues ( issues ) . map ( ( issue ) => `${ marker } ${ issue . path } : ${ issue . message } ` ) ;
114+ }
115+
116+ function formatDoctorHint ( message : string ) : string {
117+ return `Run \`${ formatCliCommand ( "openclaw doctor" ) } \` ${ message } ` ;
118+ }
119+
100120function validatePathSegments ( path : PathSegment [ ] ) : void {
101121 for ( const segment of path ) {
102122 if ( ! isIndexSegment ( segment ) && isBlockedObjectKey ( segment ) ) {
@@ -229,10 +249,10 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
229249 return snapshot ;
230250 }
231251 runtime . error ( `Config invalid at ${ shortenHomePath ( snapshot . path ) } .` ) ;
232- for ( const issue of snapshot . issues ) {
233- runtime . error ( `- ${ issue . path || "<root>" } : ${ issue . message } ` ) ;
252+ for ( const line of formatConfigIssueLines ( snapshot . issues , "-" ) ) {
253+ runtime . error ( line ) ;
234254 }
235- runtime . error ( `Run \` ${ formatCliCommand ( "openclaw doctor" ) } \` to repair, then retry.` ) ;
255+ runtime . error ( formatDoctorHint ( " to repair, then retry." ) ) ;
236256 runtime . exit ( 1 ) ;
237257 return snapshot ;
238258}
@@ -335,11 +355,62 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
335355 }
336356}
337357
358+ export async function runConfigValidate ( opts : { json ?: boolean ; runtime ?: RuntimeEnv } = { } ) {
359+ const runtime = opts . runtime ?? defaultRuntime ;
360+ let outputPath = CONFIG_PATH ?? "openclaw.json" ;
361+
362+ try {
363+ const snapshot = await readConfigFileSnapshot ( ) ;
364+ outputPath = snapshot . path ;
365+ const shortPath = shortenHomePath ( outputPath ) ;
366+
367+ if ( ! snapshot . exists ) {
368+ if ( opts . json ) {
369+ runtime . log ( JSON . stringify ( { valid : false , path : outputPath , error : "file not found" } ) ) ;
370+ } else {
371+ runtime . error ( danger ( `Config file not found: ${ shortPath } ` ) ) ;
372+ }
373+ runtime . exit ( 1 ) ;
374+ return ;
375+ }
376+
377+ if ( ! snapshot . valid ) {
378+ const issues = normalizeConfigIssues ( snapshot . issues ) ;
379+
380+ if ( opts . json ) {
381+ runtime . log ( JSON . stringify ( { valid : false , path : outputPath , issues } , null , 2 ) ) ;
382+ } else {
383+ runtime . error ( danger ( `Config invalid at ${ shortPath } :` ) ) ;
384+ for ( const line of formatConfigIssueLines ( issues , danger ( "×" ) ) ) {
385+ runtime . error ( ` ${ line } ` ) ;
386+ }
387+ runtime . error ( "" ) ;
388+ runtime . error ( formatDoctorHint ( "to repair, or fix the keys above manually." ) ) ;
389+ }
390+ runtime . exit ( 1 ) ;
391+ return ;
392+ }
393+
394+ if ( opts . json ) {
395+ runtime . log ( JSON . stringify ( { valid : true , path : outputPath } ) ) ;
396+ } else {
397+ runtime . log ( success ( `Config valid: ${ shortPath } ` ) ) ;
398+ }
399+ } catch ( err ) {
400+ if ( opts . json ) {
401+ runtime . log ( JSON . stringify ( { valid : false , path : outputPath , error : String ( err ) } ) ) ;
402+ } else {
403+ runtime . error ( danger ( `Config validation error: ${ String ( err ) } ` ) ) ;
404+ }
405+ runtime . exit ( 1 ) ;
406+ }
407+ }
408+
338409export function registerConfigCli ( program : Command ) {
339410 const cmd = program
340411 . command ( "config" )
341412 . description (
342- "Non-interactive config helpers (get/set/unset/file). Run without subcommand for the setup wizard." ,
413+ "Non-interactive config helpers (get/set/unset/file/validate ). Run without subcommand for the setup wizard." ,
343414 )
344415 . addHelpText (
345416 "after" ,
@@ -408,4 +479,12 @@ export function registerConfigCli(program: Command) {
408479 . action ( async ( ) => {
409480 await runConfigFile ( { } ) ;
410481 } ) ;
482+
483+ cmd
484+ . command ( "validate" )
485+ . description ( "Validate the current config against the schema without starting the gateway" )
486+ . option ( "--json" , "Output validation result as JSON" , false )
487+ . action ( async ( opts ) => {
488+ await runConfigValidate ( { json : Boolean ( opts . json ) } ) ;
489+ } ) ;
411490}
0 commit comments