// lib/parsers/arg-parser.mjs // Parse $ARGUMENTS strings for the seven voyage commands. // // Each command has its own valid-flag set; passing flags from another command // produces an `unknown_flags` array but does not error — the caller decides. // // The commands hand $ARGUMENTS to the CLI as stdin text through a quoted // heredoc (`--stdin`), never as shell words, so nothing in the arguments is // expanded: no `~`, no `$VAR` or `$HOME`, no glob, no command substitution, // and `;` is an ordinary character. Write a path out in full; `~/x` stays the // three characters `~/x`. Quoting is the parser's own (see `tokenize`). import { readFileSync } from 'node:fs'; import { isMain } from '../util/is-main.mjs'; const FLAG_SCHEMA = { trekbrief: { boolean: ['--quick', '--fg'], valued: ['--profile', '--approve'], aliases: {}, }, trekresearch: { boolean: ['--quick', '--local', '--external', '--fg'], valued: ['--project', '--profile'], aliases: {}, }, trekplan: { boolean: ['--quick', '--fg'], valued: ['--project', '--brief', '--export', '--decompose', '--profile'], multi: ['--research'], aliases: {}, }, trekexecute: { boolean: ['--resume', '--dry-run', '--validate', '--fg'], valued: ['--project', '--step', '--session', '--profile'], aliases: {}, }, trekreview: { boolean: ['--quick', '--fg', '--dry-run', '--validate', '--workflow'], valued: ['--project', '--since', '--profile'], aliases: {}, }, trekcontinue: { boolean: ['--help', '--cleanup', '--confirm', '--dry-run'], valued: ['--profile'], aliases: {}, }, trekendsession: { boolean: ['--help'], valued: [], aliases: {}, }, }; /** * @param {string} argString Raw $ARGUMENTS as the command sees it. * @param {keyof FLAG_SCHEMA} command * @returns {{ * command: string, * flags: Record, * positional: string[], * unknown: string[], * errors: Array<{code: string, message: string}>, * tail: string, * }} * `tail` is the raw text after the first token, trimmed, with one balanced * quote pair around the whole of it removed ('' when there is none): a * label read whole, with its own spaces and apostrophes. */ export function parseArgs(argString, command) { const tail = tailOf(argString); const schema = FLAG_SCHEMA[command]; if (!schema) { return { command, flags: {}, positional: [], unknown: [], errors: [{ code: 'ARG_UNKNOWN_COMMAND', message: `Unknown command: ${command}` }], tail, }; } const tokens = tokenize(argString).map((t) => t.value); const flags = {}; const positional = []; const unknown = []; const errors = []; for (let i = 0; i < tokens.length; i++) { const tok = tokens[i]; if (!tok.startsWith('--')) { positional.push(tok); continue; } if (schema.boolean.includes(tok)) { flags[tok] = true; continue; } if (schema.valued.includes(tok)) { const next = tokens[i + 1]; if (next === undefined || next.startsWith('--')) { errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires a value` }); } else { flags[tok] = next; i++; } continue; } if (schema.multi && schema.multi.includes(tok)) { const collected = []; while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) { collected.push(tokens[i + 1]); i++; } if (collected.length === 0) { errors.push({ code: 'ARG_MISSING_VALUE', message: `Flag ${tok} requires at least one value` }); } else { flags[tok] = collected; } continue; } unknown.push(tok); } return { command, flags, positional, unknown, errors, tail }; } const isSpace = (c) => /\s/.test(c); /** * Split argument text into words. Words are separated by whitespace. A word * that starts with `'` or `"` opens a quote. Its closing quote is the next * same-kind quote that ends a word (followed by whitespace or the end of the * text), whatever stands before it: a letter, whitespace or a line break. A * same-kind quote that starts a word (whitespace before it, a non-whitespace * character after it) stops the search: then there is no pair. Any other * same-kind quote (inside a word) is skipped. A pair groups the words between * the quotes into one value and is removed. A quote with no partner is a * literal character (`user's` stays `user's`). Examples: `"my dir "` is one * value, `my dir `; `'Fix the user's login'` is one value, * `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the literal * words `"my` and `dir"/x`, then `--since` and `HEAD~1`. Nothing is expanded: * the function only slices the string. * * @param {string} text * @returns {Array<{ value: string, start: number, end: number }>} * `start` / `end` are offsets into `text` (end exclusive), quotes included. */ export function tokenize(text) { if (typeof text !== 'string') return []; const out = []; const n = text.length; let i = 0; while (i < n) { while (i < n && isSpace(text[i])) i++; if (i >= n) break; const start = i; const q = text[i]; if (q === '"' || q === "'") { let j = text.indexOf(q, i + 1); while (j !== -1 && j + 1 < n && !isSpace(text[j + 1])) { if (isSpace(text[j - 1])) { j = -1; break; } j = text.indexOf(q, j + 1); } if (j !== -1) { out.push({ value: text.slice(i + 1, j), start, end: j + 1 }); i = j + 1; continue; } } while (i < n && !isSpace(text[i])) i++; out.push({ value: text.slice(start, i), start, end: i }); } return out; } // The raw text after the first token, trimmed; one balanced quote pair that // encloses the whole of it is removed. '' when there is no second token. function tailOf(text) { const tokens = tokenize(text); if (tokens.length === 0) return ''; const rest = text.slice(tokens[0].end).trim(); const inner = tokenize(rest); if (inner.length === 1 && inner[0].start === 0 && inner[0].end === rest.length && (rest[0] === '"' || rest[0] === "'") && inner[0].value.length === rest.length - 2) { return inner[0].value; } return rest; } export { FLAG_SCHEMA }; // Flags a command cannot run without, and its usage line. The CLI below emits // the error, so the command prose relays code output instead of composing the // message (a model does not always repeat prose word for word — proevesett // case review-requires-project failed 2 of 10 on backticks around the flag). const REQUIRED = { trekreview: { flags: [['--project', '']], usage: 'Usage: /trekreview --project [--since ] [--quick] [--validate] [--dry-run]', }, }; // CLI: node lib/parsers/arg-parser.mjs --command --stdin <<'VOYAGE_ARGS_EOF_7F3C9E' // $ARGUMENTS // VOYAGE_ARGS_EOF_7F3C9E // reads the whole stdin as the argument text (one trailing newline // removed). The quoted heredoc keeps the shell from expanding anything; the // Bash guard (hooks/scripts/pre-bash-executor.mjs, args-heredoc-closed-early) // refuses a command in which that heredoc would close early. // CLI: node lib/parsers/arg-parser.mjs --command -- ... // the older argv form, kept: the words after `--` are the arguments. // stdout: the parse result as JSON (with `tail`). exit 0, or exit 1 with // each error as "Error: " plus the usage line on stderr. if (isMain(import.meta.url)) { const argv = process.argv.slice(2); const ci = argv.indexOf('--command'); const command = ci >= 0 ? argv[ci + 1] : undefined; const dd = argv.indexOf('--'); // `--stdin` counts only before `--`: after it, it is an argument word. const options = dd >= 0 ? argv.slice(0, dd) : argv; let r; if (options.includes('--stdin')) { let text = ''; let readError = null; try { text = readFileSync(0, 'utf8'); } catch (e) { readError = e; } r = parseArgs(text.replace(/\r?\n$/, ''), command); if (readError) { r.errors.push({ code: 'ARG_STDIN_UNREADABLE', message: `could not read the arguments from stdin (${readError.code || readError.message})` }); } } else { const rest = dd >= 0 ? argv.slice(dd + 1) : []; r = parseArgs(rest.map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), command); } const req = REQUIRED[command]; if (req) { for (const [flag, metavar] of req.flags) { if (!(flag in r.flags)) { r.errors.push({ code: 'ARG_REQUIRED_MISSING', message: `${flag} ${metavar} is required.` }); } } } process.stdout.write(JSON.stringify(r) + '\n'); if (r.errors.length > 0) { for (const e of r.errors) process.stderr.write(`Error: ${e.message}\n`); if (req) process.stderr.write(`${req.usage}\n`); process.exit(1); } process.exit(0); }