@@ -14,11 +14,17 @@ import { readFileSync, existsSync } from "node:fs";
1414import { resolve , dirname , join } from "node:path" ;
1515import { fileURLToPath } from "node:url" ;
1616import type { LoadoutIndex } from "./types.js" ;
17- import { readUsage , summarizeUsage , summaryToJSON } from "./usage.js" ;
17+ import { readUsage , readUsageWithStats , summarizeUsage , summaryToJSON } from "./usage.js" ;
1818import { findDeadEntries , findKeywordOverlaps , analyzeBudget } from "./analysis.js" ;
1919import { resolveLoadout , explainEntry } from "./resolve.js" ;
2020import type { ResolveOptions } from "./resolve.js" ;
2121import { validateIndex } from "./validate.js" ;
22+ import {
23+ isLoadoutIndex ,
24+ describeIndexShapeProblem ,
25+ getFlagValue ,
26+ diagnoseFlagValue ,
27+ } from "./cli-helpers.js" ;
2228
2329// ── Colors ────────────────────────────────────────────────────
2430const BOLD = "\x1b[1m" ;
@@ -49,36 +55,60 @@ function positionalArgs(args: string[]): string[] {
4955 return args . filter ( ( a ) => ! a . startsWith ( "--" ) ) ;
5056}
5157
52- function getFlagValue ( args : string [ ] , flag : string ) : string | undefined {
53- const prefix = `--${ flag } =` ;
54- for ( const a of args ) {
55- if ( a . startsWith ( prefix ) ) return a . slice ( prefix . length ) ;
56- }
58+ // getFlagValue / diagnoseFlagValue live in cli-helpers.ts (testable, pure).
59+
60+ /**
61+ * Read a path-valued flag, failing with a clear, actionable error when the
62+ * value was swallowed by a following flag (e.g. `--project --json`). A truly
63+ * absent flag returns undefined (these flags all have sensible defaults).
64+ */
65+ function getPathFlag ( args : string [ ] , flag : string ) : string | undefined {
66+ if ( diagnoseFlagValue ( args , flag ) === "swallowed" ) {
67+ fail (
68+ "MISSING_FLAG_VALUE" ,
69+ `--${ flag } was given without a value (the next token "--${ nextToken ( args , flag ) } " is a flag, not a value).` ,
70+ `Provide a value, e.g. '--${ flag } <path>' or '--${ flag } =<path>'.`
71+ ) ;
72+ }
73+ return getFlagValue ( args , flag ) ;
74+ }
75+
76+ function nextToken ( args : string [ ] , flag : string ) : string {
5777 const idx = args . indexOf ( `--${ flag } ` ) ;
58- if ( idx !== - 1 && idx + 1 < args . length && ! args [ idx + 1 ] . startsWith ( "--" ) ) {
59- return args [ idx + 1 ] ;
60- }
61- return undefined ;
78+ return idx !== - 1 ? ( args [ idx + 1 ] ?? "" ) . replace ( / ^ - - / , "" ) : "" ;
6279}
6380
6481function getResolveOpts ( args : string [ ] ) : ResolveOptions {
6582 return {
66- projectRoot : getFlagValue ( args , "project" ) ,
67- globalDir : getFlagValue ( args , "global" ) ,
68- orgPath : getFlagValue ( args , "org" ) ,
69- sessionPath : getFlagValue ( args , "session" ) ,
83+ projectRoot : getPathFlag ( args , "project" ) ,
84+ globalDir : getPathFlag ( args , "global" ) ,
85+ orgPath : getPathFlag ( args , "org" ) ,
86+ sessionPath : getPathFlag ( args , "session" ) ,
7087 } ;
7188}
7289
7390function loadIndex ( path : string ) : LoadoutIndex {
7491 if ( ! existsSync ( path ) ) {
7592 fail ( "FILE_NOT_FOUND" , `Index not found: ${ path } ` ) ;
7693 }
94+ let parsed : unknown ;
7795 try {
78- return JSON . parse ( readFileSync ( path , "utf-8" ) ) as LoadoutIndex ;
96+ parsed = JSON . parse ( readFileSync ( path , "utf-8" ) ) ;
7997 } catch ( e ) {
8098 fail ( "PARSE_ERROR" , `Failed to parse index: ${ path } ` , ( e as Error ) . message ) ;
8199 }
100+ // Trust boundary: valid JSON is not necessarily a valid index. Assert the
101+ // shape here so a wrong-but-parseable file fails with a clear, actionable
102+ // message instead of a raw TypeError later in validate/budget/dead/overlaps.
103+ if ( ! isLoadoutIndex ( parsed ) ) {
104+ const problem = describeIndexShapeProblem ( parsed ) ;
105+ fail (
106+ "INVALID_INDEX" ,
107+ `File is valid JSON but not a loadout index: ${ path } ` ,
108+ `Expected an object with an 'entries' array${ problem ? ` (${ problem } )` : "" } . Run 'ai-loadout validate' for details.`
109+ ) ;
110+ }
111+ return parsed ;
82112}
83113
84114function getVersion ( ) : string {
@@ -135,9 +165,15 @@ function cmdUsage(args: string[]) {
135165 }
136166
137167 const jsonlPath = resolve ( positional [ 0 ] ) ;
138- const events = readUsage ( jsonlPath ) ;
168+ const { events, skipped } = readUsageWithStats ( jsonlPath ) ;
139169 const json = hasFlag ( args , "json" ) ;
140170
171+ // Surface dropped lines so a corrupt log doesn't silently undercount.
172+ // (Stay quiet in --json mode to keep stdout machine-parseable.)
173+ if ( skipped > 0 && ! json ) {
174+ warn ( `Skipped ${ skipped } malformed line(s) in ${ jsonlPath } ` ) ;
175+ }
176+
141177 if ( events . length === 0 ) {
142178 if ( json ) { log ( "[]" ) ; return ; }
143179 info ( "No usage events found" ) ;
@@ -346,14 +382,25 @@ function cmdResolve(args: string[]) {
346382 for ( const s of result . searched ) {
347383 if ( s . found ) {
348384 ok ( `${ s . name . padEnd ( 10 ) } ${ DIM } ${ s . path } ${ RESET } ` ) ;
385+ } else if ( s . malformed ) {
386+ // Present but unparseable — call it out distinctly from a missing file
387+ // so the user knows there's a corrupt file to fix, not one to create.
388+ warn ( `${ s . name . padEnd ( 10 ) } ${ DIM } ${ s . path } ${ RESET } ${ YELLOW } (malformed JSON — skipped)${ RESET } ` ) ;
349389 } else {
350390 log ( ` ${ DIM } —${ RESET } ${ s . name . padEnd ( 10 ) } ${ DIM } ${ s . path } (not found)${ RESET } ` ) ;
351391 }
352392 }
353393
354394 if ( result . layers . length === 0 ) {
355- log ( `\n ${ YELLOW } No loadout indexes found.${ RESET } ` ) ;
356- log ( ` ${ DIM } Create .claude/loadout/index.json or ~/.ai-loadout/index.json${ RESET } \n` ) ;
395+ // Echo the ACTUAL searched paths rather than a hardcoded default list, so
396+ // the message reflects any --global/--org/--project/--session overrides and
397+ // tells the user exactly where to put a file.
398+ const searchedPaths = result . searched . map ( ( s ) => s . path ) ;
399+ log ( `\n ${ YELLOW } No loadout indexes found in any of:${ RESET } ` ) ;
400+ for ( const p of searchedPaths ) {
401+ log ( ` ${ DIM } • ${ p } ${ RESET } ` ) ;
402+ }
403+ log ( ` ${ DIM } Create one of the above to get started.${ RESET } \n` ) ;
357404 return ;
358405 }
359406
0 commit comments