@vodmal/vdx-cli 0.13.1 → 0.14.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
@@ -48,7 +48,7 @@ vdx publish <patch|minor|major> [--dry-run] [--force]
48
48
  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
- vdx ai [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd
51
+ vdx ai[@host] [project-path] [--new] [--restart] [--detach] [--dry-run] # default: cwd; @host: over ssh, in tmux there
52
52
 
53
53
  # Print the installed version
54
54
  vdx --version
@@ -73,11 +73,19 @@ vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no
73
73
  # 4. Start the agent here, continuing its last conversation (--new: a new one);
74
74
  # re-run to attach, --restart to apply a changed profile
75
75
  vdx ai
76
+
77
+ # 5. The same on another machine (an ssh host with vdx), attaching from here
78
+ vdx ai@m3
76
79
  ```
77
80
 
78
81
  `vdx ai` reads a personal profile, never a bundled one: `$VDX_ENVIRONMENT`, else
79
82
  `~/.vdx-environment.yaml`, else a plain `claude`/`codex` with no flags. Format:
80
83
  [docs/specs/environment-format.md](../docs/specs/environment-format.md).
84
+ The session's `{project}` is `[vdx] name` from the project's `mise.toml`, else the
85
+ first file in the profile's `session.project_names` that has one, else the repo
86
+ name. `vdx ai@<host>` runs the same command on `<host>` over ssh — that machine's vdx,
87
+ profile and tmux, the path taken under its home directory; `--detach` only
88
+ starts it. The machine's own label (`$VDX_HOST`) runs here.
81
89
 
82
90
  By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
83
91
  [vdx-rubric-vodmal](https://github.com/VoDmAl/vdx-rubric-vodmal) at the time of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.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",
@@ -11,7 +11,7 @@ schema_version: "0.2"
11
11
 
12
12
  metadata:
13
13
  name: vdx-rubric-vodmal
14
- version: "0.4.0"
14
+ version: "0.5.0"
15
15
  description: "Personal maturity rubric for multi-stack projects (vodmal)"
16
16
  owner: vodmal
17
17
  homepage: "https://github.com/vodmal/vdx-rubric"
package/src/ai.ts CHANGED
@@ -4,7 +4,8 @@ import * as path from 'node:path';
4
4
  import { execFileSync, spawnSync } from 'node:child_process';
5
5
  import YAML from 'js-yaml';
6
6
  import { evalPredicate } from './evaluator.ts';
7
- import { autoDetectStack, type Ctx } from './facts.ts';
7
+ import { autoDetectStack, readStructured, resolveConfigPath, type Ctx } from './facts.ts';
8
+ import { loadManifest } from './manifest.ts';
8
9
  import type { Predicate } from './rubric.ts';
9
10
 
10
11
  /**
@@ -46,9 +47,18 @@ export interface AgentProfile {
46
47
 
47
48
  export type Multiplexer = 'tmux' | 'none';
48
49
 
50
+ /** A place a project's short name may come from: a structured file and a dot path into it. */
51
+ export interface NameSource {
52
+ /** `{repo}` is the repository name; `~` the home directory; relative to the profile's directory. */
53
+ file: string;
54
+ jsonpath: string;
55
+ }
56
+
49
57
  export interface SessionProfile {
50
58
  multiplexer?: Multiplexer;
51
59
  name?: string;
60
+ /** Tried after `[vdx] name` in the project's mise.toml; the first non-empty string wins. */
61
+ project_names?: NameSource[];
52
62
  }
53
63
 
54
64
  export interface Environment {
@@ -137,6 +147,19 @@ export function parseEnvironment(text: string, source: string): Environment {
137
147
  if (session.name !== undefined && typeof session.name !== 'string') {
138
148
  fail('session.name', 'must be a string');
139
149
  }
150
+ if (session.project_names !== undefined) {
151
+ if (!Array.isArray(session.project_names)) fail('session.project_names', 'must be a list');
152
+ session.project_names.forEach((src, i) => {
153
+ const at = `session.project_names[${i}]`;
154
+ if (typeof src !== 'object' || src === null) fail(at, 'must be a mapping');
155
+ if (typeof src.file !== 'string' || src.file === '') fail(`${at}.file`, 'must be a non-empty string');
156
+ const unknown = src.file.match(/\{(?!repo\})\w+\}/);
157
+ if (unknown) fail(`${at}.file`, `has an unknown placeholder ${unknown[0]} (only {repo})`);
158
+ if (typeof src.jsonpath !== 'string' || src.jsonpath === '') {
159
+ fail(`${at}.jsonpath`, 'must be a non-empty string (a dot path, e.g. names.0)');
160
+ }
161
+ });
162
+ }
140
163
  }
141
164
  return envDoc;
142
165
  }
@@ -172,6 +195,8 @@ export function renderSessionName(template: string, vars: Record<string, string>
172
195
  export interface LaunchPlan {
173
196
  projectRoot: string;
174
197
  project: string;
198
+ /** Where `project` came from: mise.toml, a profile source, or the repository name. */
199
+ projectSource: string;
175
200
  host: string;
176
201
  profilePath: string | null;
177
202
  command: string;
@@ -190,6 +215,7 @@ export function planLaunch(input: {
190
215
  agent: AgentProfile;
191
216
  projectRoot: string;
192
217
  project: string;
218
+ projectSource?: string;
193
219
  host: string;
194
220
  }): LaunchPlan {
195
221
  const { environment, agent, projectRoot } = input;
@@ -206,6 +232,7 @@ export function planLaunch(input: {
206
232
  return {
207
233
  projectRoot,
208
234
  project: input.project,
235
+ projectSource: input.projectSource ?? 'repository name',
209
236
  host: input.host,
210
237
  profilePath: input.profilePath,
211
238
  command: agent.command,
@@ -384,6 +411,40 @@ export function projectIdentity(projectRoot: string): string {
384
411
  return path.basename(projectRoot);
385
412
  }
386
413
 
414
+ export interface ProjectName {
415
+ name: string;
416
+ source: string;
417
+ }
418
+
419
+ /**
420
+ * `{project}`: `[vdx] name` in the project's mise.toml, else the first profile
421
+ * source holding a non-empty string, else the repository name. A source that is
422
+ * missing or unreadable is skipped — a name is a label, not a reason to fail.
423
+ */
424
+ export function resolveProjectName(input: {
425
+ projectRoot: string;
426
+ repo: string;
427
+ sources: NameSource[];
428
+ home: string;
429
+ profileDir: string;
430
+ }): ProjectName {
431
+ const own = loadManifest(input.projectRoot)?.name;
432
+ if (typeof own === 'string' && own.trim()) return { name: own.trim(), source: 'mise.toml [vdx] name' };
433
+ for (const src of input.sources) {
434
+ const file = path.resolve(
435
+ input.profileDir,
436
+ src.file.replace(/^~(?=$|\/)/, input.home).replace(/\{repo\}/g, input.repo),
437
+ );
438
+ // Readers take paths relative to a project root; from "/" an absolute path stays itself.
439
+ const value = resolveConfigPath(readStructured({ projectRoot: '/', stack: '', cache: new Map() }, file), src.jsonpath);
440
+ if (typeof value === 'string' && value.trim()) {
441
+ const shown = file.startsWith(input.home + path.sep) ? `~${file.slice(input.home.length)}` : file;
442
+ return { name: value.trim(), source: `${shown} ${src.jsonpath}` };
443
+ }
444
+ }
445
+ return { name: input.repo, source: 'repository name' };
446
+ }
447
+
387
448
  /** `{host}`: `$VDX_HOST` when set (a machine label such as `lft`), else the short hostname. */
388
449
  export function hostLabel(env: NodeJS.ProcessEnv = process.env): string {
389
450
  const label = env[HOST_ENV_VAR]?.trim();
@@ -559,6 +620,7 @@ export const NOTHING_TO_RESUME_MS = 30_000;
559
620
  export function renderPlan(plan: LaunchPlan, multiplexer: Multiplexer, running: AgentPane[]): string {
560
621
  const lines = [
561
622
  `vdx ai — ${plan.projectRoot}`,
623
+ ` name: ${plan.project} (${plan.projectSource})`,
562
624
  ` profile: ${plan.profilePath ?? `none — built-in default (${FALLBACK_AGENTS.join(' or ')}, no flags, no tmux)`}`,
563
625
  ` agent: ${commandLine(plan.command, plan.args)}`,
564
626
  ` resume: ${plan.resumeArgs.length ? plan.resumeArgs.join(' ') : '—'}`,
@@ -693,6 +755,61 @@ function finish(ok: boolean, session: string, paneId: string, opts: AiOptions, d
693
755
  return ok ? EXIT_OK : EXIT_LAUNCH_FAILED;
694
756
  }
695
757
 
758
+ /** `vdx ai@<host>`: an ssh destination such as `m3` — never an option to ssh. */
759
+ export const REMOTE_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
760
+
761
+ /** A path under the local home as a shell word under the remote home; any other path as is. */
762
+ export function remotePathWord(abs: string, home: string): string {
763
+ const rel = path.relative(home, abs);
764
+ if (rel === '') return '"$HOME"';
765
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return shellQuote(abs);
766
+ return `"$HOME"/${shellQuote(rel)}`;
767
+ }
768
+
769
+ /**
770
+ * The ssh argv for `vdx ai@<host>`: the same `vdx ai` on the host, in its own
771
+ * tmux there. The path goes first: an older vdx hands the token after a flag it
772
+ * does not know to that flag. A different vdx version there is named, not fatal.
773
+ */
774
+ export function remoteAiArgs(input: {
775
+ host: string;
776
+ projectPath: string;
777
+ home: string;
778
+ version: string;
779
+ opts: AiOptions;
780
+ tty: boolean;
781
+ }): string[] {
782
+ const { host, opts, version } = input;
783
+ const flags = [
784
+ opts.fresh && '--new',
785
+ opts.restart && '--restart',
786
+ opts.detach && '--detach',
787
+ opts.dryRun && '--dry-run',
788
+ ].filter((f): f is string => Boolean(f));
789
+ const v = shellQuote(version);
790
+ const script =
791
+ `v=$(vdx --version 2>/dev/null) || v='older than 0.13.1'; ` +
792
+ `[ "$v" = ${v} ] || echo "note: vdx on ${host} is $v, here "${v} >&2; ` +
793
+ `exec vdx ai ${[remotePathWord(input.projectPath, input.home), ...flags].join(' ')}`;
794
+ return [input.tty ? '-t' : '-T', host, script];
795
+ }
796
+
797
+ export function runAiRemote(opts: AiOptions, host: string, deps: AiDeps, version: string): number {
798
+ const projectPath = resolveProjectRoot(opts.path) ?? path.resolve(opts.path);
799
+ const tty = deps.interactive && !opts.detach && !opts.dryRun;
800
+ const args = remoteAiArgs({ host, projectPath, home: deps.home, version, opts, tty });
801
+ deps.log(`→ ${host}: vdx ai ${shellQuote(projectPath)}`);
802
+ const res = spawnSync('ssh', args, { stdio: 'inherit' });
803
+ if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
804
+ deps.log('vdx ai: `ssh` not found on PATH');
805
+ return 127;
806
+ }
807
+ if (opts.detach && res.status === EXIT_OK) {
808
+ deps.log(`attach from here: vdx ai@${host} ${shellQuote(projectPath)}`);
809
+ }
810
+ return res.status ?? 1;
811
+ }
812
+
696
813
  export function runAi(opts: AiOptions, deps: AiDeps): number {
697
814
  const { log, tmux } = deps;
698
815
 
@@ -722,6 +839,14 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
722
839
  return EXIT_USAGE;
723
840
  }
724
841
 
842
+ const named = resolveProjectName({
843
+ projectRoot,
844
+ repo: projectIdentity(projectRoot),
845
+ sources: environment.session?.project_names ?? [],
846
+ home: deps.home,
847
+ profileDir: profilePath ? path.dirname(profilePath) : deps.home,
848
+ });
849
+
725
850
  let plan: LaunchPlan;
726
851
  try {
727
852
  plan = planLaunch({
@@ -729,7 +854,8 @@ export function runAi(opts: AiOptions, deps: AiDeps): number {
729
854
  profilePath,
730
855
  agent,
731
856
  projectRoot,
732
- project: projectIdentity(projectRoot),
857
+ project: named.name,
858
+ projectSource: named.source,
733
859
  host: hostLabel(deps.env),
734
860
  });
735
861
  } catch (e: any) {
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, defaultDeps } from './ai.ts';
32
+ import { runAi, runAiRemote, defaultDeps, hostLabel, REMOTE_HOST_RE } from './ai.ts';
33
33
 
34
34
  const DEFAULT_RUBRIC = resolveDefaultRubric();
35
35
 
@@ -41,7 +41,7 @@ function usage(): never {
41
41
  vdx init [project_path] [--stack <id>] [--baseline <ref>] [--dry-run] [--force] (default: cwd)
42
42
  vdx publish <patch|minor|major> [--dry-run] [--force]
43
43
  vdx doctor [--format=ansi|markdown|json] [--json]
44
- vdx ai [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd)
44
+ vdx ai[@host] [project_path] [--new] [--restart] [--detach] [--dry-run] (default: cwd; @host: over ssh, in tmux there)
45
45
  vdx --version
46
46
  `,
47
47
  );
@@ -278,9 +278,19 @@ function cmdAi(opts: ParsedArgs): void {
278
278
  bool('resume');
279
279
  const detach = bool('detach');
280
280
  const dryRun = bool('dry-run');
281
- process.exit(
282
- runAi({ path: positionals[0] ?? '.', restart, fresh, detach, dryRun }, defaultDeps()),
283
- );
281
+ const options = { path: positionals[0] ?? '.', restart, fresh, detach, dryRun };
282
+ // `ai@m3`: the same command on another machine. Its own label runs here.
283
+ const host = opts.cmd.startsWith('ai@') ? opts.cmd.slice(3) : null;
284
+ if (host !== null) {
285
+ if (!REMOTE_HOST_RE.test(host)) {
286
+ process.stderr.write(`vdx ai@host: "${host}" is not a host name (an ssh destination such as m3)\n`);
287
+ process.exit(2);
288
+ }
289
+ if (host.toLowerCase() !== hostLabel().toLowerCase()) {
290
+ process.exit(runAiRemote(options, host, defaultDeps(), readCliVersion()));
291
+ }
292
+ }
293
+ process.exit(runAi(options, defaultDeps()));
284
294
  }
285
295
 
286
296
  const parsed = parseArgs(process.argv);
@@ -289,7 +299,7 @@ else if (parsed.cmd === 'audit') cmdAudit(parsed);
289
299
  else if (parsed.cmd === 'init') cmdInit(parsed);
290
300
  else if (parsed.cmd === 'publish') cmdPublish(parsed);
291
301
  else if (parsed.cmd === 'doctor') cmdDoctor(parsed);
292
- else if (parsed.cmd === 'ai') cmdAi(parsed);
302
+ else if (parsed.cmd === 'ai' || parsed.cmd.startsWith('ai@')) cmdAi(parsed);
293
303
  else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
294
304
  cmdRun(parsed.cmd as LifecycleVerb);
295
305
  else usage();
package/src/init.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
13
13
  export type Verb = (typeof STANDARD_VERBS)[number];
14
14
 
15
- export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.4.0';
15
+ export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.5.0';
16
16
 
17
17
  const VERB_ALIASES: Record<Verb, string[]> = {
18
18
  up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
package/src/manifest.ts CHANGED
@@ -5,6 +5,8 @@ import YAML from 'js-yaml';
5
5
 
6
6
  export interface VdxManifest {
7
7
  schema_version?: string;
8
+ /** Short name people use for the project (a session name, a sidebar); defaults to the repository name. */
9
+ name?: string;
8
10
  baseline?: string;
9
11
  stack?: string;
10
12
  primary_language?: string;