@vodmal/vdx-cli 0.23.0 → 0.25.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 +6 -4
- package/package.json +5 -1
- package/rubric/vdx-rubric.yaml +32 -24
- package/src/ai.ts +268 -60
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +13 -8
- package/src/conversations.ts +23 -4
- package/src/doctor.ts +9 -11
- package/src/evaluator.ts +1 -1
- package/src/facts.ts +51 -11
- package/src/gha.ts +4 -1
- package/src/hooks.ts +47 -12
- package/src/index.ts +41 -31
- package/src/init.ts +14 -50
- package/src/mcp-server.ts +2 -5
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +2 -5
- package/src/publish.ts +24 -11
- package/src/report.ts +1 -3
- package/src/run.ts +2 -6
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +7 -2
package/src/author.ts
CHANGED
|
@@ -61,8 +61,24 @@ export function remoteParts(url: string): { host: string; group: string; path: s
|
|
|
61
61
|
|
|
62
62
|
/** Words that say nothing about whose repo it is. */
|
|
63
63
|
const GENERIC = new Set([
|
|
64
|
-
'git',
|
|
65
|
-
'
|
|
64
|
+
'git',
|
|
65
|
+
'com',
|
|
66
|
+
'org',
|
|
67
|
+
'name',
|
|
68
|
+
'www',
|
|
69
|
+
'ssh',
|
|
70
|
+
'http',
|
|
71
|
+
'https',
|
|
72
|
+
'the',
|
|
73
|
+
'app',
|
|
74
|
+
'mcp',
|
|
75
|
+
'github',
|
|
76
|
+
'gitlab',
|
|
77
|
+
'dev',
|
|
78
|
+
'net',
|
|
79
|
+
'vorobyev',
|
|
80
|
+
'vodmal',
|
|
81
|
+
'dmitry',
|
|
66
82
|
]);
|
|
67
83
|
|
|
68
84
|
export function nameTokens(dir: string, remotePath: string): Set<string> {
|
|
@@ -77,7 +93,11 @@ export function nameTokens(dir: string, remotePath: string): Set<string> {
|
|
|
77
93
|
function describe(dir: string): PoolRepo {
|
|
78
94
|
const cfg = git(dir, ['config', '--local', '--get-regexp', '^(user\\.(name|email)|remote\\.origin\\.url)$']) ?? '';
|
|
79
95
|
const val = (key: string) =>
|
|
80
|
-
cfg
|
|
96
|
+
cfg
|
|
97
|
+
.split('\n')
|
|
98
|
+
.find((l) => l.toLowerCase().startsWith(`${key} `))
|
|
99
|
+
?.slice(key.length + 1)
|
|
100
|
+
.trim() ?? '';
|
|
81
101
|
const email = val('user.email');
|
|
82
102
|
const remote = remoteParts(val('remote.origin.url'));
|
|
83
103
|
return {
|
|
@@ -177,11 +197,10 @@ export function rankAuthors(dir: string, pool: PoolRepo[]): AuthorCandidate[] {
|
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
const fallbackName = [...names.values()].flatMap((m) => [...m.entries()]).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
180
|
-
return ranked
|
|
181
|
-
.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
});
|
|
200
|
+
return ranked.map(([email, s]) => {
|
|
201
|
+
const byName = [...(names.get(email)?.entries() ?? [])].sort((a, b) => b[1] - a[1]);
|
|
202
|
+
return { email, name: byName[0]?.[0] ?? fallbackName, score: s, why: why.get(email) ?? [] };
|
|
203
|
+
});
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
export function writeAuthor(dir: string, author: Author): boolean {
|
package/src/clone-checks.ts
CHANGED
|
@@ -95,12 +95,14 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
|
|
|
95
95
|
remedy: `chmod +x ${hooksPath}/*`,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
-
return
|
|
99
|
-
id,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
return (
|
|
99
|
+
bodyProblems(id, label, [...hooks.values()]) ?? {
|
|
100
|
+
id,
|
|
101
|
+
label,
|
|
102
|
+
status: 'ok',
|
|
103
|
+
message: `${hooks.size} hook(s) active via ${hooksPath}`,
|
|
104
|
+
}
|
|
105
|
+
);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
const states = cloneHookStates(root, ctx, frameworks);
|
|
@@ -110,7 +112,9 @@ export function checkRepoHooks(root: string | null): CheckResult | null {
|
|
|
110
112
|
id,
|
|
111
113
|
label,
|
|
112
114
|
status: 'warning',
|
|
113
|
-
message: off
|
|
115
|
+
message: off
|
|
116
|
+
.map((s) => `${s.framework.id} (${s.framework.declaredIn}): ${s.problem} — those hooks never run`)
|
|
117
|
+
.join('; '),
|
|
114
118
|
...(off[0]!.remedy ? { remedy: off[0]!.remedy } : {}),
|
|
115
119
|
};
|
|
116
120
|
}
|
|
@@ -149,7 +153,8 @@ function bodyProblems(id: string, label: string, bodies: string[]): CheckResult
|
|
|
149
153
|
label,
|
|
150
154
|
status: 'warning',
|
|
151
155
|
message: `hooks call ${missing.join(', ')} — not on this machine, the commit fails or the gate is skipped`,
|
|
152
|
-
remedy:
|
|
156
|
+
remedy:
|
|
157
|
+
'install what the hook calls here, or move the hook out of the repository (a personal hook in git config)',
|
|
153
158
|
};
|
|
154
159
|
}
|
|
155
160
|
return null;
|
package/src/conversations.ts
CHANGED
|
@@ -179,7 +179,10 @@ export function machineOf(c: Conversation, host: string): string | null {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** The newest conversation of each machine (unknown counts as one), newest first. */
|
|
182
|
-
export function latestPerMachine(
|
|
182
|
+
export function latestPerMachine(
|
|
183
|
+
conversations: Conversation[],
|
|
184
|
+
machine: (c: Conversation) => string | null,
|
|
185
|
+
): Conversation[] {
|
|
183
186
|
const out: Conversation[] = [];
|
|
184
187
|
for (const c of [...conversations].sort((a, b) => b.updated.getTime() - a.updated.getTime())) {
|
|
185
188
|
if (!out.some((o) => sameMachine(machine(o), machine(c)))) out.push(c);
|
|
@@ -187,7 +190,6 @@ export function latestPerMachine(conversations: Conversation[], machine: (c: Con
|
|
|
187
190
|
return out;
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
|
|
191
193
|
/**
|
|
192
194
|
* The conversation a running Claude Code process is in, from its session file
|
|
193
195
|
* `<config>/sessions/<pid>.json` — it follows /clear. That folder is per
|
|
@@ -203,6 +205,18 @@ export function liveConversationId(env: NodeJS.ProcessEnv, home: string, pid: nu
|
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
/** When a running Claude Code process started (ms), from the same session file; null — not known. */
|
|
209
|
+
export function liveStartedAt(env: NodeJS.ProcessEnv, home: string, pid: number): number | null {
|
|
210
|
+
try {
|
|
211
|
+
const file = path.join(claudeConfigDir(env, home), 'sessions', `${pid}.json`);
|
|
212
|
+
const data: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
213
|
+
const at = typeof data === 'object' && data !== null ? (data as { startedAt?: unknown }).startedAt : undefined;
|
|
214
|
+
return typeof at === 'number' ? at : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
206
220
|
/** A Claude Code process running right now, and the conversation it is in. */
|
|
207
221
|
export interface LiveSession {
|
|
208
222
|
pid: number;
|
|
@@ -226,7 +240,8 @@ export function parseLiveSessions(text: string): LiveSession[] {
|
|
|
226
240
|
} catch {
|
|
227
241
|
continue;
|
|
228
242
|
}
|
|
229
|
-
if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId))
|
|
243
|
+
if (!Number.isInteger(data?.pid) || typeof data?.sessionId !== 'string' || !isConversationId(data.sessionId))
|
|
244
|
+
continue;
|
|
230
245
|
out.push({
|
|
231
246
|
pid: data.pid,
|
|
232
247
|
conversation: data.sessionId,
|
|
@@ -282,7 +297,11 @@ export const LIVE_SESSIONS_SCRIPT =
|
|
|
282
297
|
'kill -0 "$p" 2>/dev/null && tr -d "\\n" < "$f" && echo; done; exit 0';
|
|
283
298
|
|
|
284
299
|
/** Claude Code processes alive on this machine. */
|
|
285
|
-
export function liveSessionsHere(
|
|
300
|
+
export function liveSessionsHere(
|
|
301
|
+
env: NodeJS.ProcessEnv,
|
|
302
|
+
home: string,
|
|
303
|
+
alive: (pid: number) => boolean = pidAlive,
|
|
304
|
+
): LiveSession[] {
|
|
286
305
|
const dir = path.join(claudeConfigDir(env, home), 'sessions');
|
|
287
306
|
let names: string[];
|
|
288
307
|
try {
|
package/src/doctor.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { execFileSync } from 'node:child_process';
|
|
|
6
6
|
import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
|
|
7
7
|
import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
|
|
8
8
|
import { checkRepoHooks } from './clone-checks.ts';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
checkPersonalHooks,
|
|
11
|
+
defaultPersonalHookDeps,
|
|
12
|
+
fixPersonalHooks,
|
|
13
|
+
type PersonalHookDeps,
|
|
14
|
+
} from './personal-hooks.ts';
|
|
10
15
|
|
|
11
16
|
export type CheckStatus = 'ok' | 'warning' | 'missing';
|
|
12
17
|
|
|
@@ -39,11 +44,7 @@ export interface DoctorReport {
|
|
|
39
44
|
|
|
40
45
|
export function readCliVersion(): string {
|
|
41
46
|
try {
|
|
42
|
-
const pkgPath = path.resolve(
|
|
43
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
44
|
-
'..',
|
|
45
|
-
'package.json',
|
|
46
|
-
);
|
|
47
|
+
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
47
48
|
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
|
|
48
49
|
return raw.version ?? 'unknown';
|
|
49
50
|
} catch {
|
|
@@ -315,8 +316,7 @@ function checkVdx(): CheckResult {
|
|
|
315
316
|
status: 'ok',
|
|
316
317
|
level,
|
|
317
318
|
message: `${tag} — works inside this project only (${found})`,
|
|
318
|
-
remedy:
|
|
319
|
-
'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
|
|
319
|
+
remedy: 'npm i -g @vodmal/vdx-cli OR add `alias vdx="npx -y -p @vodmal/vdx-cli vdx"` to your shell rc',
|
|
320
320
|
};
|
|
321
321
|
}
|
|
322
322
|
|
|
@@ -458,9 +458,7 @@ export function runDoctorCheck(
|
|
|
458
458
|
);
|
|
459
459
|
return rows
|
|
460
460
|
.map(
|
|
461
|
-
(c) =>
|
|
462
|
-
`vdx doctor: ${c.label} — ${c.message}.` +
|
|
463
|
-
(c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
|
|
461
|
+
(c) => `vdx doctor: ${c.label} — ${c.message}.` + (c.remedy ? ` Fix, on the user's word: \`${c.remedy}\`.` : ''),
|
|
464
462
|
)
|
|
465
463
|
.join('\n');
|
|
466
464
|
}
|
package/src/evaluator.ts
CHANGED
|
@@ -108,7 +108,7 @@ export function predicateNames(p: Predicate): string[] {
|
|
|
108
108
|
const obj = q as Record<string, any>;
|
|
109
109
|
if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
|
|
110
110
|
if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
|
|
111
|
-
if ('at_least_n_of' in obj) return (obj.at_least_n_of?.predicates as Predicate[] ?? []).forEach(walk);
|
|
111
|
+
if ('at_least_n_of' in obj) return ((obj.at_least_n_of?.predicates as Predicate[]) ?? []).forEach(walk);
|
|
112
112
|
if ('not' in obj) return walk(obj.not);
|
|
113
113
|
if ('fn' in obj) {
|
|
114
114
|
out.push(String(obj.fn));
|
package/src/facts.ts
CHANGED
|
@@ -7,16 +7,41 @@ export interface Ctx {
|
|
|
7
7
|
projectRoot: string;
|
|
8
8
|
stack: string;
|
|
9
9
|
cache: Map<string, unknown>;
|
|
10
|
+
/**
|
|
11
|
+
* Meta repo: the other half, where a file missing here is looked for — the repo root
|
|
12
|
+
* for an axis evaluated in the primary subpackage (`.github/` lives there), the
|
|
13
|
+
* subpackage for an axis evaluated in a root without a stack manifest of its own.
|
|
14
|
+
*/
|
|
15
|
+
fallbackRoot?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
|
|
19
|
+
function locate(ctx: Ctx, rel: string): string {
|
|
20
|
+
const here = path.join(ctx.projectRoot, rel);
|
|
21
|
+
if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
|
|
22
|
+
const there = path.join(ctx.fallbackRoot, rel);
|
|
23
|
+
return fs.existsSync(there) ? there : here;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
|
|
27
|
+
function ownCtx(ctx: Ctx): Ctx {
|
|
28
|
+
if (ctx.fallbackRoot === undefined) return ctx;
|
|
29
|
+
let own = ctx.cache.get('ctx:own') as Ctx | undefined;
|
|
30
|
+
if (!own) {
|
|
31
|
+
own = { projectRoot: ctx.projectRoot, stack: ctx.stack, cache: new Map() };
|
|
32
|
+
ctx.cache.set('ctx:own', own);
|
|
33
|
+
}
|
|
34
|
+
return own;
|
|
10
35
|
}
|
|
11
36
|
|
|
12
37
|
export function fileExists(ctx: Ctx, rel: string): boolean {
|
|
13
|
-
return fs.existsSync(
|
|
38
|
+
return fs.existsSync(locate(ctx, rel));
|
|
14
39
|
}
|
|
15
40
|
|
|
16
41
|
export function readText(ctx: Ctx, rel: string): string | null {
|
|
17
42
|
const key = `file:${rel}`;
|
|
18
43
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
|
|
19
|
-
const abs =
|
|
44
|
+
const abs = locate(ctx, rel);
|
|
20
45
|
try {
|
|
21
46
|
const t = fs.readFileSync(abs, 'utf8');
|
|
22
47
|
ctx.cache.set(key, t);
|
|
@@ -107,34 +132,38 @@ export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
|
|
|
107
132
|
return cur;
|
|
108
133
|
}
|
|
109
134
|
|
|
110
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile.
|
|
137
|
+
* Without the fallback: `vdx test` here runs this directory's tasks, not the other half's.
|
|
138
|
+
*/
|
|
111
139
|
export function listAllTasks(ctx: Ctx): Set<string> {
|
|
112
140
|
const key = 'tasks:all';
|
|
113
141
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as Set<string>;
|
|
114
142
|
const tasks = new Set<string>();
|
|
143
|
+
const own = ownCtx(ctx);
|
|
115
144
|
|
|
116
|
-
const miseT = readToml(
|
|
145
|
+
const miseT = readToml(own, 'mise.toml');
|
|
117
146
|
if (miseT?.tasks && typeof miseT.tasks === 'object') {
|
|
118
147
|
for (const k of Object.keys(miseT.tasks as object)) tasks.add(k);
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
const composer = readJson(
|
|
150
|
+
const composer = readJson(own, 'composer.json');
|
|
122
151
|
if (composer?.scripts && typeof composer.scripts === 'object') {
|
|
123
152
|
for (const k of Object.keys(composer.scripts)) tasks.add(k);
|
|
124
153
|
}
|
|
125
154
|
|
|
126
|
-
const pkg = readJson(
|
|
155
|
+
const pkg = readJson(own, 'package.json');
|
|
127
156
|
if (pkg?.scripts && typeof pkg.scripts === 'object') {
|
|
128
157
|
for (const k of Object.keys(pkg.scripts)) tasks.add(k);
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
// monorepo-ish fallback: server/package.json (см. bookmap)
|
|
132
|
-
const serverPkg = readJson(
|
|
161
|
+
const serverPkg = readJson(own, 'server/package.json');
|
|
133
162
|
if (serverPkg?.scripts && typeof serverPkg.scripts === 'object') {
|
|
134
163
|
for (const k of Object.keys(serverPkg.scripts)) tasks.add(`server:${k}`);
|
|
135
164
|
}
|
|
136
165
|
|
|
137
|
-
const make = readText(
|
|
166
|
+
const make = readText(own, 'Makefile');
|
|
138
167
|
if (make) {
|
|
139
168
|
const lines = make.split('\n');
|
|
140
169
|
for (const line of lines) {
|
|
@@ -176,7 +205,7 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
176
205
|
if (m && m[1] && m[2]) {
|
|
177
206
|
const dir = m[1];
|
|
178
207
|
const ext = m[2];
|
|
179
|
-
const abs =
|
|
208
|
+
const abs = locate(ctx, dir);
|
|
180
209
|
try {
|
|
181
210
|
const files = fs.readdirSync(abs);
|
|
182
211
|
for (const f of files) {
|
|
@@ -224,8 +253,19 @@ export function stackForDir(dir: string): string | null {
|
|
|
224
253
|
}
|
|
225
254
|
|
|
226
255
|
const SUBPACKAGE_SCAN_IGNORE = new Set([
|
|
227
|
-
'node_modules',
|
|
228
|
-
'
|
|
256
|
+
'node_modules',
|
|
257
|
+
'vendor',
|
|
258
|
+
'dist',
|
|
259
|
+
'build',
|
|
260
|
+
'out',
|
|
261
|
+
'target',
|
|
262
|
+
'.git',
|
|
263
|
+
'.next',
|
|
264
|
+
'__pycache__',
|
|
265
|
+
'.venv',
|
|
266
|
+
'venv',
|
|
267
|
+
'.cache',
|
|
268
|
+
'coverage',
|
|
229
269
|
]);
|
|
230
270
|
|
|
231
271
|
export interface SubPackage {
|
package/src/gha.ts
CHANGED
|
@@ -120,7 +120,10 @@ export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
|
|
|
120
120
|
const out: GhaWorkflow[] = [];
|
|
121
121
|
let files: string[] = [];
|
|
122
122
|
try {
|
|
123
|
-
files = fs
|
|
123
|
+
files = fs
|
|
124
|
+
.readdirSync(dir)
|
|
125
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
126
|
+
.sort();
|
|
124
127
|
} catch {
|
|
125
128
|
files = [];
|
|
126
129
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -36,11 +36,29 @@ export interface HookFramework {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export const GIT_HOOK_EVENTS = new Set([
|
|
39
|
-
'applypatch-msg',
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
39
|
+
'applypatch-msg',
|
|
40
|
+
'pre-applypatch',
|
|
41
|
+
'post-applypatch',
|
|
42
|
+
'pre-commit',
|
|
43
|
+
'pre-merge-commit',
|
|
44
|
+
'prepare-commit-msg',
|
|
45
|
+
'commit-msg',
|
|
46
|
+
'post-commit',
|
|
47
|
+
'pre-rebase',
|
|
48
|
+
'post-checkout',
|
|
49
|
+
'post-merge',
|
|
50
|
+
'pre-push',
|
|
51
|
+
'pre-auto-gc',
|
|
52
|
+
'post-rewrite',
|
|
53
|
+
'reference-transaction',
|
|
54
|
+
'push-to-checkout',
|
|
55
|
+
'post-index-change',
|
|
56
|
+
'sendemail-validate',
|
|
57
|
+
'fsmonitor-watchman',
|
|
58
|
+
'p4-changelist',
|
|
59
|
+
'p4-prepare-changelist',
|
|
60
|
+
'p4-post-changelist',
|
|
61
|
+
'p4-pre-submit',
|
|
44
62
|
]);
|
|
45
63
|
|
|
46
64
|
/** Install steps a clone runs anyway: npm-family lifecycle scripts, composer's, the vdx `up` task. */
|
|
@@ -173,7 +191,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
173
191
|
id: 'husky',
|
|
174
192
|
declaredIn: '.husky/',
|
|
175
193
|
hooks: husky,
|
|
176
|
-
installedBy: findStep(
|
|
194
|
+
installedBy: findStep(
|
|
195
|
+
steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'),
|
|
196
|
+
/(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m,
|
|
197
|
+
),
|
|
177
198
|
manualInstall: 'npx husky',
|
|
178
199
|
hooksPath: '.husky/_',
|
|
179
200
|
copies: false,
|
|
@@ -247,7 +268,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
247
268
|
id: 'cghooks',
|
|
248
269
|
declaredIn: 'composer.json extra.hooks',
|
|
249
270
|
hooks,
|
|
250
|
-
installedBy: findStep(
|
|
271
|
+
installedBy: findStep(
|
|
272
|
+
steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'),
|
|
273
|
+
/\bcghooks\s+(add|update)\b/,
|
|
274
|
+
),
|
|
251
275
|
manualInstall: 'vendor/bin/cghooks update',
|
|
252
276
|
hooksPath: null,
|
|
253
277
|
copies: true,
|
|
@@ -353,7 +377,13 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
|
|
|
353
377
|
}
|
|
354
378
|
if (fw.id === 'lefthook') {
|
|
355
379
|
const foreign = events.filter((e) => !/lefthook/.test(readFile(path.join(dir, e))));
|
|
356
|
-
if (foreign.length > 0)
|
|
380
|
+
if (foreign.length > 0)
|
|
381
|
+
return {
|
|
382
|
+
framework: fw,
|
|
383
|
+
enabled: false,
|
|
384
|
+
problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`,
|
|
385
|
+
remedy,
|
|
386
|
+
};
|
|
357
387
|
}
|
|
358
388
|
if (fw.copies) {
|
|
359
389
|
const stale = events.filter((e) => {
|
|
@@ -361,7 +391,12 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
|
|
|
361
391
|
return fw.hooks.get(e)!.some((cmd) => !body.includes(cmd.trim()));
|
|
362
392
|
});
|
|
363
393
|
if (stale.length > 0) {
|
|
364
|
-
return {
|
|
394
|
+
return {
|
|
395
|
+
framework: fw,
|
|
396
|
+
enabled: false,
|
|
397
|
+
problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`,
|
|
398
|
+
remedy,
|
|
399
|
+
};
|
|
365
400
|
}
|
|
366
401
|
}
|
|
367
402
|
return { framework: fw, enabled: true };
|
|
@@ -386,8 +421,7 @@ function readFile(file: string): string {
|
|
|
386
421
|
* anywhere in a script says nothing about whether the hook still does its job,
|
|
387
422
|
* while an unset variable inside the guard means the guarded block never runs.
|
|
388
423
|
*/
|
|
389
|
-
const GATING_VAR_RE =
|
|
390
|
-
/\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
|
|
424
|
+
const GATING_VAR_RE = /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
|
|
391
425
|
|
|
392
426
|
export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = process.env): string[] {
|
|
393
427
|
const out = new Set<string>();
|
|
@@ -406,7 +440,8 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
|
|
|
406
440
|
* Only what is run: a path in an argument or an `echo` says nothing about
|
|
407
441
|
* whether the hook works here.
|
|
408
442
|
*/
|
|
409
|
-
const MACHINE_PROGRAM_RE =
|
|
443
|
+
const MACHINE_PROGRAM_RE =
|
|
444
|
+
/^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
|
|
410
445
|
|
|
411
446
|
export function missingMachinePaths(commands: string[], home: string = os.homedir()): string[] {
|
|
412
447
|
const out = new Set<string>();
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
4
4
|
import { loadRubric } from './rubric.ts';
|
|
5
5
|
import { loadManifest, loadOverrides } from './manifest.ts';
|
|
6
6
|
import { autoDetectStack, type Ctx } from './facts.ts';
|
|
7
|
-
import {
|
|
8
|
-
LIFECYCLE_VERBS,
|
|
9
|
-
type LifecycleVerb,
|
|
10
|
-
resolveLifecycleVerb,
|
|
11
|
-
renderResolveError,
|
|
12
|
-
} from './run.ts';
|
|
7
|
+
import { LIFECYCLE_VERBS, type LifecycleVerb, resolveLifecycleVerb, renderResolveError } from './run.ts';
|
|
13
8
|
import { audit } from './audit.ts';
|
|
14
9
|
import {
|
|
15
10
|
reportMarkdown,
|
|
@@ -19,7 +14,14 @@ import {
|
|
|
19
14
|
reportDoctorJson,
|
|
20
15
|
reportDoctorAnsi,
|
|
21
16
|
} from './report.ts';
|
|
22
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
runDoctor,
|
|
19
|
+
runDoctorCheck,
|
|
20
|
+
runDoctorFix,
|
|
21
|
+
resolveDoctorCtx,
|
|
22
|
+
looksLikeProject,
|
|
23
|
+
readCliVersion,
|
|
24
|
+
} from './doctor.ts';
|
|
23
25
|
import { planInit, writeInit, renderPlanSummary } from './init.ts';
|
|
24
26
|
import {
|
|
25
27
|
planPublish,
|
|
@@ -66,6 +68,7 @@ interface CommandSpec {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
const AI_HELP = `Starts your agent in the project, or attaches to the one already running there.
|
|
71
|
+
Several running there: a list to pick from — Enter takes the newest.
|
|
69
72
|
Which agent, its flags and whether it runs in tmux come from your profile:
|
|
70
73
|
$VDX_ENVIRONMENT, else ~/.vdx-environment.yaml, else a plain claude/codex.
|
|
71
74
|
|
|
@@ -75,15 +78,23 @@ the project's files). Hand a person \`vdx ai\`, not \`<agent> --<flag>\`.
|
|
|
75
78
|
|
|
76
79
|
project_path the project (default: cwd); its git toplevel is used
|
|
77
80
|
@host the same command on <host> over ssh: its vdx, profile and tmux
|
|
78
|
-
--new a new conversation instead of continuing the last one
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
--new a new conversation instead of continuing the last one; an agent
|
|
82
|
+
already running here keeps running, the new one starts next to
|
|
83
|
+
it (a new tmux window)
|
|
84
|
+
--focused a new conversation, as --new, in a session nothing wakes: the
|
|
85
|
+
agent, its hooks and MCP servers get VDX_FOCUSED=1 — for the
|
|
86
|
+
plugins that wake sessions to leave it alone — and the profile's
|
|
87
|
+
rules marked \`wakes: true\` (a channel flag) are left out. Back
|
|
88
|
+
to it: --focused --conversation <id>. A running agent keeps its
|
|
89
|
+
mode: plain vdx ai attaches to it as it is
|
|
84
90
|
--conversation <id>
|
|
85
|
-
continue this Claude Code conversation
|
|
86
|
-
|
|
91
|
+
continue this Claude Code conversation: attach to the agent in
|
|
92
|
+
it, or start one next to the agents running here
|
|
93
|
+
--restart restart the running agent in its pane — applies a changed profile;
|
|
94
|
+
with --new, --focused or --conversation, it is replaced by that.
|
|
95
|
+
Without it, an agent running without the profile's flags is
|
|
96
|
+
a question in a terminal: Enter attaches to it as is, r
|
|
97
|
+
restarts it, q leaves it
|
|
87
98
|
--detach start without attaching; print the command that attaches
|
|
88
99
|
--dry-run print the plan and the project's running agents; start nothing
|
|
89
100
|
--check for the agent's own session: how the agent here is launched, and
|
|
@@ -102,8 +113,8 @@ machine's. A new conversation (--new, or none to continue) while an agent of
|
|
|
102
113
|
the project runs on another machine starts only after a yes.
|
|
103
114
|
|
|
104
115
|
Exit codes: 0 the agent runs per the profile; 2 profile or usage error;
|
|
105
|
-
3 the running agent lacks profile flags, or is not focused under --focused
|
|
106
|
-
(rerun with --restart); 4 the start
|
|
116
|
+
3 the running agent lacks profile flags, or is not focused under --focused,
|
|
117
|
+
and was left as is — no terminal, or q (rerun with --restart); 4 the start
|
|
107
118
|
was not confirmed.
|
|
108
119
|
Profile format: https://github.com/VoDmAl/vdx/blob/main/docs/specs/environment-format.md
|
|
109
120
|
`;
|
|
@@ -134,7 +145,8 @@ mise.toml is overwritten only with --force.
|
|
|
134
145
|
pre-flight checks — at a terminal an expired npm login is renewed (\`npm login\`)
|
|
135
146
|
and the checks run again;
|
|
136
147
|
the version bump in package.json and package-lock.json;
|
|
137
|
-
npm publish (npm asks for an OTP
|
|
148
|
+
npm publish (npm asks for an OTP or a confirmation in the browser; Ctrl-C
|
|
149
|
+
there puts the version back);
|
|
138
150
|
git commit and tag, then \`git push --follow-tags\` to the branch's upstream;
|
|
139
151
|
a wait until the registry shows the new version (up to 3 min).
|
|
140
152
|
--dry-run runs the pre-flight and prints the plan; --force goes on past a failed
|
|
@@ -247,7 +259,9 @@ function checkArgs(name: string, opts: ParsedArgs): ParsedArgs {
|
|
|
247
259
|
process.exit(2);
|
|
248
260
|
};
|
|
249
261
|
const unknown = [
|
|
250
|
-
...Object.keys(flags)
|
|
262
|
+
...Object.keys(flags)
|
|
263
|
+
.filter((f) => !bools.includes(f) && !values.includes(f))
|
|
264
|
+
.map((f) => `--${f}`),
|
|
251
265
|
...positionals.filter((p) => p.startsWith('-')),
|
|
252
266
|
];
|
|
253
267
|
if (unknown.length > 0) fail(`unknown option ${unknown.join(', ')}`);
|
|
@@ -277,8 +291,7 @@ function cmdAudit(opts: ParsedArgs): void {
|
|
|
277
291
|
const rubricPath = (opts.flags.rubric as string) || DEFAULT_RUBRIC;
|
|
278
292
|
const rubric = loadRubric(rubricPath);
|
|
279
293
|
|
|
280
|
-
const stack =
|
|
281
|
-
(opts.flags.stack as string) || manifest?.stack || autoDetectStack(projectRoot);
|
|
294
|
+
const stack = (opts.flags.stack as string) || manifest?.stack || autoDetectStack(projectRoot);
|
|
282
295
|
|
|
283
296
|
const ctx: Ctx = {
|
|
284
297
|
projectRoot,
|
|
@@ -289,8 +302,7 @@ function cmdAudit(opts: ParsedArgs): void {
|
|
|
289
302
|
const baselineRef = manifest?.baseline ?? `file://${rubricPath}`;
|
|
290
303
|
const result = audit(rubric, ctx, overrides, baselineRef, manifest);
|
|
291
304
|
|
|
292
|
-
const formatFlag =
|
|
293
|
-
typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
|
|
305
|
+
const formatFlag = typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
|
|
294
306
|
const wantJson = opts.flags.json === true || formatFlag === 'json';
|
|
295
307
|
const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
|
|
296
308
|
const wantAnsi = formatFlag === 'ansi';
|
|
@@ -343,9 +355,7 @@ function cmdInit(opts: ParsedArgs): void {
|
|
|
343
355
|
function cmdPublish(opts: ParsedArgs): void {
|
|
344
356
|
const bumpArg = opts.positionals[0];
|
|
345
357
|
if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
|
|
346
|
-
process.stderr.write(
|
|
347
|
-
'Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n',
|
|
348
|
-
);
|
|
358
|
+
process.stderr.write('Usage: vdx publish <patch|minor|major> [--dry-run] [--force] [--no-push]\n');
|
|
349
359
|
process.exit(1);
|
|
350
360
|
}
|
|
351
361
|
const bump = bumpArg as BumpKind;
|
|
@@ -427,8 +437,7 @@ function cmdRun(verb: LifecycleVerb): void {
|
|
|
427
437
|
} catch (e: any) {
|
|
428
438
|
if (e?.code === 'ENOENT') {
|
|
429
439
|
process.stderr.write(
|
|
430
|
-
`vdx: \`mise\` binary not found on PATH\n` +
|
|
431
|
-
`hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
|
|
440
|
+
`vdx: \`mise\` binary not found on PATH\n` + `hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
|
|
432
441
|
);
|
|
433
442
|
process.exit(127);
|
|
434
443
|
}
|
|
@@ -450,7 +459,9 @@ function cmdDoctor(opts: ParsedArgs): void {
|
|
|
450
459
|
if (opts.flags.fix === true) {
|
|
451
460
|
try {
|
|
452
461
|
const done = runDoctorFix();
|
|
453
|
-
process.stderr.write(
|
|
462
|
+
process.stderr.write(
|
|
463
|
+
done.length > 0 ? done.map((d) => `✓ ${d}`).join('\n') + '\n' : 'nothing to fix in ~/.gitconfig\n',
|
|
464
|
+
);
|
|
454
465
|
} catch (e: any) {
|
|
455
466
|
process.stderr.write(`vdx doctor --fix: ${e?.message ?? String(e)}\n`);
|
|
456
467
|
process.exit(2);
|
|
@@ -458,8 +469,7 @@ function cmdDoctor(opts: ParsedArgs): void {
|
|
|
458
469
|
}
|
|
459
470
|
const report = runDoctor(ctx);
|
|
460
471
|
|
|
461
|
-
const formatFlag =
|
|
462
|
-
typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
|
|
472
|
+
const formatFlag = typeof opts.flags.format === 'string' ? opts.flags.format : undefined;
|
|
463
473
|
const wantJson = opts.flags.json === true || formatFlag === 'json';
|
|
464
474
|
const wantMarkdown = formatFlag === 'markdown' || formatFlag === 'md';
|
|
465
475
|
const wantAnsi = formatFlag === 'ansi';
|