@vodmal/vdx-cli 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,7 @@ vdx doctor [--format=ansi|markdown|json]
49
49
 
50
50
  # Start your agent in a project per your profile (~/.vdx-environment.yaml or $VDX_ENVIRONMENT)
51
51
  vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
52
+ vdx ai --check [project-path] # this session against the profile — what the plugin's SessionStart hook tells the agent
52
53
 
53
54
  # What a command does and takes; nothing runs (ai: also where launch flags live)
54
55
  vdx <command> --help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
5
5
  "keywords": [
6
6
  "vdx",
package/src/ai.ts CHANGED
@@ -906,12 +906,21 @@ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version
906
906
  return res.status ?? 1;
907
907
  }
908
908
 
909
- export function runAi(opts: AiOptions, deps: AiDeps): number {
910
- const { log, tmux } = deps;
909
+ interface Prepared {
910
+ projectRoot: string;
911
+ environment: Environment;
912
+ profilePath: string | null;
913
+ profileDir: string;
914
+ plan: LaunchPlan;
915
+ }
916
+
917
+ /** The project, the profile and the launch plan for `pathArg`; an exit code when there is none. */
918
+ function prepare(pathArg: string, deps: AiDeps): Prepared | number {
919
+ const { log } = deps;
911
920
 
912
- const projectRoot = resolveProjectRoot(opts.path);
921
+ const projectRoot = resolveProjectRoot(pathArg);
913
922
  if (!projectRoot) {
914
- log(`vdx ai: ${path.resolve(opts.path)} is not a directory`);
923
+ log(`vdx ai: ${path.resolve(pathArg)} is not a directory`);
915
924
  return EXIT_USAGE;
916
925
  }
917
926
 
@@ -959,6 +968,70 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
959
968
  log(`vdx ai: ${profilePath ?? 'profile'}: ${e?.message ?? e}`);
960
969
  return EXIT_USAGE;
961
970
  }
971
+ return { projectRoot, environment, profilePath, profileDir, plan };
972
+ }
973
+
974
+ /** The nearest ancestor of `pid` that is the agent: the session a hook or a shell tool runs in. */
975
+ export function findOwnAgent(procs: ProcInfo[], pid: number, command: string): ProcInfo | null {
976
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
977
+ let cur = byPid.get(pid);
978
+ for (let hops = 0; cur && cur.ppid > 1 && hops < 64; hops++) {
979
+ cur = byPid.get(cur.ppid);
980
+ if (cur && isAgentProcess(cur, command)) return cur;
981
+ }
982
+ return null;
983
+ }
984
+
985
+ /**
986
+ * `vdx ai --check`: what an agent should know about how it is launched here,
987
+ * and whether the session it runs in carries the profile's flags. Prints
988
+ * nothing without a profile — on such a machine `vdx ai` decides nothing.
989
+ */
990
+ export function runAiCheck(pathArg: string, deps: AiDeps, selfPid: number = process.pid): number {
991
+ if (!resolveEnvironmentPath(deps.env, deps.home)) return EXIT_OK;
992
+ const prepared = prepare(pathArg, deps);
993
+ if (typeof prepared === 'number') return prepared;
994
+ const { projectRoot, profilePath, plan } = prepared;
995
+
996
+ const lines = [
997
+ `vdx ai: the agent in this project is started with \`vdx ai\`, and its flags come from the profile ` +
998
+ `${profilePath} — agent.args for every project, agent.when[] for a class of projects. ` +
999
+ `Hand the user \`vdx ai\`, not \`${plan.command} --<flag>\`; a flag a class of projects needs is a ` +
1000
+ `condition in the profile, not an instruction to type. More: \`vdx ai --help\`.`,
1001
+ ];
1002
+ const own = findOwnAgent(deps.processes(), selfPid, plan.command);
1003
+ if (!own) {
1004
+ lines.push(`· no ${plan.command} among the ancestors of this process — no session to compare with the profile.`);
1005
+ deps.out(lines.join('\n') + '\n');
1006
+ return EXIT_OK;
1007
+ }
1008
+ const missing = missingArgs(own.args, plan.args);
1009
+ const why = plan.matched.length ? ` (profile conditions met here: ${plan.matched.join(', ')})` : '';
1010
+ if (missing.length === 0) {
1011
+ lines.push(`✓ this session carries the profile's flags${why}.`);
1012
+ deps.out(lines.join('\n') + '\n');
1013
+ return EXIT_OK;
1014
+ }
1015
+ const root = shellQuote(projectRoot);
1016
+ lines.push(
1017
+ `✗ this session runs without ${missing.join(' ')}${why}. Hand the user the fix — ` +
1018
+ (deps.env['TMUX']
1019
+ ? `\`vdx ai --restart ${root}\`: it stops this session and starts it again with the profile's flags` +
1020
+ (plan.resumeArgs.length ? `, resuming the conversation (${plan.resumeArgs.join(' ')})` : '') +
1021
+ `. Do not run it yourself: it ends this session.`
1022
+ : `this session runs outside tmux, so vdx cannot restart it: the user exits it and runs \`vdx ai ${root}\`.`),
1023
+ );
1024
+ deps.out(lines.join('\n') + '\n');
1025
+ return EXIT_DRIFT;
1026
+ }
1027
+
1028
+ export function runAi(opts: AiOptions, deps: AiDeps): number {
1029
+ const { log, tmux } = deps;
1030
+
1031
+ const prepared = prepare(opts.path, deps);
1032
+ if (typeof prepared === 'number') return prepared;
1033
+ const { projectRoot, environment, profileDir } = prepared;
1034
+ let { plan } = prepared;
962
1035
  // A start continues the last conversation unless asked for a new one.
963
1036
  if (opts.fresh) plan = { ...plan, resumeArgs: [] };
964
1037
 
package/src/doctor.ts CHANGED
@@ -339,42 +339,45 @@ function checkClaudeCode(): CheckResult {
339
339
  };
340
340
  }
341
341
 
342
- function checkClaudeCodePlugin(): CheckResult {
343
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
344
- if (!fs.existsSync(settingsPath)) {
345
- return {
346
- id: 'claude-plugin',
347
- label: 'Claude Code vdx plugin',
348
- status: 'warning',
349
- message: '~/.claude/settings.json not found',
350
- remedy: 'Install Claude Code, then add vdx marketplace (see README)',
351
- };
352
- }
353
- try {
354
- const raw = fs.readFileSync(settingsPath, 'utf8');
355
- if (raw.includes('VoDmAl/vdx') || raw.includes('vdx/marketplace')) {
356
- return {
357
- id: 'claude-plugin',
358
- label: 'Claude Code vdx plugin',
359
- status: 'ok',
360
- message: 'vdx marketplace registered',
361
- };
342
+ const PLUGIN_INSTALL_HINT =
343
+ 'in Claude Code: /plugin marketplace add VoDmAl/ai-dev-plugins, then /plugin install vdx from it';
344
+
345
+ /**
346
+ * The vdx plugin as Claude Code records it: `vdx@<marketplace>` in
347
+ * plugins/installed_plugins.json, and not switched off in settings.json
348
+ * enabledPlugins. Whichever marketplace brought it — the plugin ships through
349
+ * the owner's marketplace (VoDmAl/ai-dev-plugins), not a vdx one of its own.
350
+ */
351
+ export function checkClaudeCodePlugin(
352
+ claudeDir: string = process.env['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude'),
353
+ ): CheckResult {
354
+ const base = { id: 'claude-plugin', label: 'Claude Code vdx plugin' };
355
+ const readJson = (file: string): any => {
356
+ try {
357
+ return JSON.parse(fs.readFileSync(path.join(claudeDir, file), 'utf8'));
358
+ } catch {
359
+ return null;
362
360
  }
361
+ };
362
+ const installed = readJson(path.join('plugins', 'installed_plugins.json'));
363
+ if (!installed) {
363
364
  return {
364
- id: 'claude-plugin',
365
- label: 'Claude Code vdx plugin',
366
- status: 'warning',
367
- message: 'vdx marketplace not registered',
368
- remedy: 'Add github.com/VoDmAl/vdx/marketplace to extraKnownMarketplaces',
369
- };
370
- } catch (e: any) {
371
- return {
372
- id: 'claude-plugin',
373
- label: 'Claude Code vdx plugin',
365
+ ...base,
374
366
  status: 'warning',
375
- message: `cannot read settings.json: ${e?.message ?? e}`,
367
+ message: `no plugin records in ${claudeDir}/plugins/installed_plugins.json`,
368
+ remedy: PLUGIN_INSTALL_HINT,
376
369
  };
377
370
  }
371
+ const key = Object.keys(installed.plugins ?? {}).find((k) => k.startsWith('vdx@'));
372
+ if (!key) {
373
+ return { ...base, status: 'warning', message: 'not installed', remedy: PLUGIN_INSTALL_HINT };
374
+ }
375
+ const version = installed.plugins[key]?.[0]?.version;
376
+ const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
377
+ if (readJson('settings.json')?.enabledPlugins?.[key] === false) {
378
+ return { ...base, status: 'warning', message: `${label} installed but disabled`, remedy: `/plugin enable ${key}` };
379
+ }
380
+ return { ...base, status: 'ok', message: label };
378
381
  }
379
382
 
380
383
  /**
@@ -543,7 +546,7 @@ const CHECKS: Array<(ctx: DoctorCtx) => CheckResult | null> = [
543
546
  checkVdxVersion,
544
547
  checkVdx,
545
548
  checkClaudeCode,
546
- checkClaudeCodePlugin,
549
+ () => checkClaudeCodePlugin(),
547
550
  checkNode,
548
551
  checkGit,
549
552
  checkMise,
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  type PublishOptions,
30
30
  } from './publish.ts';
31
31
  import { resolveDefaultRubric } from './defaults.ts';
32
- import { runAi, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
32
+ import { runAi, runAiCheck, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
33
33
 
34
34
  const DEFAULT_RUBRIC = resolveDefaultRubric();
35
35
 
@@ -76,6 +76,9 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
76
76
  --restart restart the running agent in its pane — applies a changed profile
77
77
  --detach start without attaching; print the command that attaches
78
78
  --dry-run print the plan and the project's running agents; start nothing
79
+ --check for the agent's own session: how the agent here is launched, and
80
+ whether this session carries the profile's flags (the vdx plugin's
81
+ SessionStart hook); prints nothing without a profile; exit 3 on drift
79
82
  -h, --help this help
80
83
 
81
84
  Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
@@ -127,7 +130,7 @@ Exit 2 when something is missing.
127
130
  usage: 'vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run]',
128
131
  help: AI_HELP,
129
132
  // `--resume` asked to continue in 0.12; continuing is the default now.
130
- bools: ['new', 'restart', 'resume', 'detach', 'dry-run'],
133
+ bools: ['new', 'restart', 'resume', 'detach', 'dry-run', 'check'],
131
134
  positionals: 1,
132
135
  },
133
136
  ...Object.fromEntries(
@@ -401,6 +404,17 @@ function cmdDoctor(opts: ParsedArgs): void {
401
404
  }
402
405
 
403
406
  function cmdAi(opts: ParsedArgs): void {
407
+ if (opts.flags.check === true) {
408
+ // About the session this runs in — there is nothing to start, and no other machine.
409
+ const others = Object.keys(opts.flags).filter((f) => f !== 'check');
410
+ if (others.length > 0 || opts.cmd !== 'ai') {
411
+ process.stderr.write(
412
+ `vdx ai: --check takes only the project path (not ${opts.cmd !== 'ai' ? opts.cmd : `--${others.join(', --')}`})\n`,
413
+ );
414
+ process.exit(2);
415
+ }
416
+ process.exit(runAiCheck(opts.positionals[0] ?? '.', defaultDeps()));
417
+ }
404
418
  const options = {
405
419
  path: opts.positionals[0] ?? '.',
406
420
  restart: opts.flags.restart === true,