|
| 1 | +import path from 'node:path'; |
| 2 | +import { |
| 3 | + loadConfig, |
| 4 | + loadEnvPathFiles, |
| 5 | + runBeforeSessionHook, |
| 6 | + runEnvFromEntries, |
| 7 | +} from '@agentv/core'; |
| 8 | +import { binary, run, subcommands } from 'cmd-ts'; |
| 9 | +import { findRepoRoot } from './commands/eval/shared.js'; |
| 10 | + |
| 11 | +import packageJson from '../package.json' with { type: 'json' }; |
| 12 | +import { compareCommand } from './commands/compare/index.js'; |
| 13 | +import { convertCommand } from './commands/convert/index.js'; |
| 14 | +import { createCommand } from './commands/create/index.js'; |
| 15 | +import { doctorCommand } from './commands/doctor/index.js'; |
| 16 | +import { evalCommand } from './commands/eval/index.js'; |
| 17 | +import { gradeCommand } from './commands/grade/index.js'; |
| 18 | +import { importCommand } from './commands/import/index.js'; |
| 19 | +import { initCmdTsCommand } from './commands/init/index.js'; |
| 20 | +import { inspectCommand } from './commands/inspect/index.js'; |
| 21 | +import { pipelineCommand } from './commands/pipeline/index.js'; |
| 22 | +import { prepareCommand } from './commands/prepare/index.js'; |
| 23 | +import { resultsCommand } from './commands/results/index.js'; |
| 24 | +import { resultsServeCommand } from './commands/results/serve.js'; |
| 25 | +import { runsCommand } from './commands/runs/index.js'; |
| 26 | +import { selfCommand } from './commands/self/index.js'; |
| 27 | +import { skillsCommand } from './commands/skills/index.js'; |
| 28 | +import { transpileCommand } from './commands/transpile/index.js'; |
| 29 | +import { trendCommand } from './commands/trend/index.js'; |
| 30 | +import { trimCommand } from './commands/trim/index.js'; |
| 31 | +import { validateCommand } from './commands/validate/index.js'; |
| 32 | +import { getUpdateNotice } from './update-check.js'; |
| 33 | + |
| 34 | +export const app = subcommands({ |
| 35 | + name: 'agentv', |
| 36 | + description: 'AgentV CLI', |
| 37 | + version: packageJson.version, |
| 38 | + cmds: { |
| 39 | + dashboard: resultsServeCommand, |
| 40 | + eval: evalCommand, |
| 41 | + grade: gradeCommand, |
| 42 | + import: importCommand, |
| 43 | + compare: compareCommand, |
| 44 | + convert: convertCommand, |
| 45 | + create: createCommand, |
| 46 | + doctor: doctorCommand, |
| 47 | + init: initCmdTsCommand, |
| 48 | + pipeline: pipelineCommand, |
| 49 | + prepare: prepareCommand, |
| 50 | + results: resultsCommand, |
| 51 | + runs: runsCommand, |
| 52 | + self: selfCommand, |
| 53 | + skills: skillsCommand, |
| 54 | + serve: resultsServeCommand, |
| 55 | + inspect: inspectCommand, |
| 56 | + trend: trendCommand, |
| 57 | + transpile: transpileCommand, |
| 58 | + trim: trimCommand, |
| 59 | + validate: validateCommand, |
| 60 | + }, |
| 61 | +}); |
| 62 | + |
| 63 | +/** |
| 64 | + * Known eval subcommand names — used to decide whether to inject the |
| 65 | + * implicit `run` subcommand for backward-compatible `agentv eval <paths>`. |
| 66 | + */ |
| 67 | +const EVAL_SUBCOMMANDS = new Set(['run', 'assert', 'aggregate', 'bundle', 'vitest']); |
| 68 | +const VITEST_VERIFIER_RE = /(?:^|[/\\])(?:EVAL|[^/\\]+[.-](?:test|spec))\.[cm]?[jt]sx?$/i; |
| 69 | + |
| 70 | +/** |
| 71 | + * Top-level CLI command names (excluding `eval` itself). |
| 72 | + * Used to ensure `eval` is the top-level subcommand, not nested. |
| 73 | + */ |
| 74 | +const TOP_LEVEL_COMMANDS = new Set([ |
| 75 | + 'import', |
| 76 | + 'inspect', |
| 77 | + 'compare', |
| 78 | + 'convert', |
| 79 | + 'create', |
| 80 | + 'dashboard', |
| 81 | + 'doctor', |
| 82 | + 'grade', |
| 83 | + 'init', |
| 84 | + 'pipeline', |
| 85 | + 'prepare', |
| 86 | + 'results', |
| 87 | + 'runs', |
| 88 | + 'self', |
| 89 | + 'skills', |
| 90 | + 'serve', |
| 91 | + 'studio', |
| 92 | + 'trend', |
| 93 | + 'transpile', |
| 94 | + 'trim', |
| 95 | + 'validate', |
| 96 | +]); |
| 97 | + |
| 98 | +export function usesDeprecatedStudioAlias(argv: string[]): boolean { |
| 99 | + return argv[2] === 'studio'; |
| 100 | +} |
| 101 | + |
| 102 | +export function shouldRunBeforeSessionHook(argv: string[]): boolean { |
| 103 | + return !(argv[2] === 'eval' && argv[3] === 'vitest'); |
| 104 | +} |
| 105 | + |
| 106 | +export function inferEvalSubcommand(arg: string | undefined): 'run' | 'vitest' { |
| 107 | + return arg && VITEST_VERIFIER_RE.test(arg) ? 'vitest' : 'run'; |
| 108 | +} |
| 109 | + |
| 110 | +/** |
| 111 | + * Preprocess argv for convenience aliases: |
| 112 | + * - `--eval-id` → `--test-id` |
| 113 | + * - `agentv eval <non-subcommand>` → `agentv eval run <non-subcommand>` |
| 114 | + * (backward compat: `eval` used to be a direct command, now it's a group) |
| 115 | + */ |
| 116 | +export function preprocessArgv(argv: string[]): string[] { |
| 117 | + const result = [...argv]; |
| 118 | + |
| 119 | + if (result[2] === 'studio') { |
| 120 | + result[2] = 'dashboard'; |
| 121 | + } |
| 122 | + |
| 123 | + // Rewrite --eval-id → --test-id (convenience alias) |
| 124 | + for (let i = 0; i < result.length; i++) { |
| 125 | + if (result[i] === '--eval-id') { |
| 126 | + result[i] = '--test-id'; |
| 127 | + } else if (result[i].startsWith('--eval-id=')) { |
| 128 | + result[i] = `--test-id=${result[i].slice('--eval-id='.length)}`; |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + // Implicit eval subcommand: `agentv eval [<arg>]` injects the inferred command |
| 133 | + // when the first arg after `eval` is absent or is not a known eval subcommand. |
| 134 | + // Backward-compat: `eval` used to be a direct command; now it is a subcommands group. |
| 135 | + // Bare `agentv eval` falls through to the run handler so its TTY check can launch |
| 136 | + // the interactive wizard. Vitest-looking verifier files use the protocol adapter |
| 137 | + // directly so deterministic workspace graders can stay short in eval YAML. |
| 138 | + // Only applies when `eval` is the top-level subcommand. |
| 139 | + // Exception: `--help` / `-h` should show the eval group help, not run's help. |
| 140 | + const evalIdx = result.indexOf('eval'); |
| 141 | + if (evalIdx !== -1) { |
| 142 | + // Ensure no top-level command appears before `eval` in the argv — |
| 143 | + // if one does, `eval` is a nested subcommand. |
| 144 | + const isTopLevel = !result.slice(0, evalIdx).some((arg) => TOP_LEVEL_COMMANDS.has(arg)); |
| 145 | + if (isTopLevel) { |
| 146 | + const nextArg = result[evalIdx + 1]; |
| 147 | + const isHelp = nextArg === '--help' || nextArg === '-h'; |
| 148 | + const isKnownSubcommand = nextArg !== undefined && EVAL_SUBCOMMANDS.has(nextArg); |
| 149 | + if (!isHelp && !isKnownSubcommand) { |
| 150 | + result.splice(evalIdx + 1, 0, inferEvalSubcommand(nextArg)); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + return result; |
| 156 | +} |
| 157 | + |
| 158 | +export async function runCli(argv: string[] = process.argv): Promise<void> { |
| 159 | + // Kick off update check: reads from local cache (fast), spawns a detached |
| 160 | + // child to refresh if stale. The notice is printed on process exit so it |
| 161 | + // appears after command output, even if the command calls process.exit(). |
| 162 | + let updateNotice: string | null = null; |
| 163 | + process.on('exit', () => { |
| 164 | + if (updateNotice) process.stderr.write(`\n${updateNotice}\n`); |
| 165 | + }); |
| 166 | + getUpdateNotice(packageJson.version).then((n) => { |
| 167 | + updateNotice = n; |
| 168 | + }); |
| 169 | + |
| 170 | + const processedArgv = preprocessArgv(argv); |
| 171 | + if (usesDeprecatedStudioAlias(argv)) { |
| 172 | + process.stderr.write( |
| 173 | + 'Warning: `agentv studio` is deprecated and will be removed in a future release. Use `agentv dashboard` instead.\n', |
| 174 | + ); |
| 175 | + } |
| 176 | + |
| 177 | + if (shouldRunBeforeSessionHook(processedArgv)) { |
| 178 | + // Load env_path/env_from and run the before_session hook once at startup, |
| 179 | + // before any command executes. Uses cwd as the search root for |
| 180 | + // .agentv/config.yaml so validate/eval commands see the injected vars. |
| 181 | + const cwd = process.cwd(); |
| 182 | + const repoRoot = await findRepoRoot(cwd); |
| 183 | + const sessionConfig = await loadConfig(path.join(cwd, '_'), repoRoot); |
| 184 | + const configDir = sessionConfig?.configDir ?? repoRoot; |
| 185 | + |
| 186 | + if (sessionConfig?.env_path) { |
| 187 | + await loadEnvPathFiles(sessionConfig.env_path, configDir); |
| 188 | + } |
| 189 | + if (sessionConfig?.env_from) { |
| 190 | + await runEnvFromEntries(sessionConfig.env_from, { cwd: configDir }); |
| 191 | + } |
| 192 | + |
| 193 | + const beforeSessionCommand = sessionConfig?.hooks?.before_session; |
| 194 | + if (beforeSessionCommand) { |
| 195 | + runBeforeSessionHook(beforeSessionCommand); |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + await run(binary(app), processedArgv); |
| 200 | +} |
0 commit comments