@grvmithas/create-aies-kit 1.3.0 → 2.3.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.
@@ -1,114 +1,164 @@
1
1
  /**
2
2
  * Client-safe kit helpers — no node:fs, safe for Next.js client components.
3
3
  *
4
- * Layout contract:
5
- * - IDE root (.cursor / .github / .claude / …) = prompts + skills + agents (+ IDE rules/MCP config)
6
- * - Repo root shared dirs = cookbook, guides, mcps, templates, policy (never nest these under .github)
4
+ * Layout contract (kit 2.x):
5
+ * - Cross-tool canon, always written: AGENTS.md, .agents/skills/<name>/SKILL.md, plans/, reviews/, hooks/
6
+ * - Primary-IDE native files: subagents, commands, hook wiring, MCP config — each in the exact path
7
+ * and format that IDE loads. Nothing is written to a path the target does not read.
8
+ * - Repo-root shared dirs: cookbook, guides, mcps, templates, policy (never nested under an IDE folder)
7
9
  */
8
- import { SITE_URL } from './options.js';
10
+ import { SITE_URL, normalizeIde } from './options.js';
9
11
 
10
- /** Shared repo-root paths — identical for every IDE. */
11
12
  const SHARED = {
12
13
  cookbookBase: 'cookbook',
13
14
  guidesDir: 'guides',
14
15
  mcpsDir: 'mcps',
15
16
  templatesDir: 'templates',
16
17
  policyFile: 'MODEL_POLICY.md',
17
- hooksFile: 'aies-hooks.json',
18
+ skillsDir: '.agents/skills',
18
19
  };
19
20
 
21
+ /**
22
+ * Per-target emitter table. Formats are interpreted by generate.js renderers.
23
+ * @type {Record<string, object>}
24
+ */
25
+ const TARGETS = {
26
+ cursor: {
27
+ root: '.cursor',
28
+ rulesFile: '.cursor/rules/aies.mdc',
29
+ commandsDir: '.cursor/commands',
30
+ commandFormat: 'plain-md',
31
+ agentsDir: '.cursor/agents',
32
+ agentFormat: 'cursor',
33
+ skillMirrorDir: null,
34
+ hooksFile: '.cursor/hooks.json',
35
+ hookFormat: 'cursor',
36
+ mcpFile: '.cursor/mcp.json',
37
+ mcpFormat: 'cursor',
38
+ },
39
+ 'claude-code': {
40
+ root: '.claude',
41
+ rulesFile: 'CLAUDE.md',
42
+ commandsDir: '.claude/commands',
43
+ commandFormat: 'described-md',
44
+ agentsDir: '.claude/agents',
45
+ agentFormat: 'claude',
46
+ skillMirrorDir: '.claude/skills',
47
+ hooksFile: '.claude/settings.json',
48
+ hookFormat: 'claude',
49
+ mcpFile: '.mcp.json',
50
+ mcpFormat: 'portable',
51
+ },
52
+ copilot: {
53
+ root: '.github',
54
+ rulesFile: '.github/copilot-instructions.md',
55
+ commandsDir: '.github/prompts',
56
+ commandFormat: 'copilot-prompt',
57
+ agentsDir: '.github/agents',
58
+ agentFormat: 'copilot',
59
+ skillMirrorDir: null,
60
+ hooksFile: '.github/hooks/aies-guard.json',
61
+ hookFormat: 'copilot',
62
+ mcpFile: '.mcp.json',
63
+ mcpFormat: 'portable',
64
+ },
65
+ codex: {
66
+ root: '.codex',
67
+ rulesFile: null,
68
+ commandsDir: 'prompts',
69
+ commandFormat: 'plain-md',
70
+ agentsDir: '.codex/agents',
71
+ agentFormat: 'codex',
72
+ skillMirrorDir: null,
73
+ hooksFile: '.codex/hooks.json',
74
+ hookFormat: 'codex',
75
+ mcpFile: '.codex/config.toml',
76
+ mcpFormat: 'codex',
77
+ },
78
+ 'gemini-cli': {
79
+ root: '.gemini',
80
+ rulesFile: null,
81
+ commandsDir: '.gemini/commands/aies',
82
+ commandFormat: 'gemini-toml',
83
+ agentsDir: '.gemini/agents',
84
+ agentFormat: 'gemini',
85
+ skillMirrorDir: null,
86
+ hooksFile: '.gemini/settings.json',
87
+ hookFormat: 'gemini',
88
+ mcpFile: '.gemini/settings.json',
89
+ mcpFormat: 'gemini',
90
+ },
91
+ kiro: {
92
+ root: '.kiro',
93
+ rulesFile: '.kiro/steering/aies.md',
94
+ commandsDir: '.kiro/steering',
95
+ commandFormat: 'kiro-steering',
96
+ agentsDir: null,
97
+ agentFormat: null,
98
+ skillMirrorDir: '.kiro/skills',
99
+ hooksFile: '.kiro/hooks/aies-guard.json',
100
+ hookFormat: 'kiro',
101
+ mcpFile: '.kiro/settings/mcp.json',
102
+ mcpFormat: 'cursor',
103
+ },
104
+ windsurf: {
105
+ root: '.devin',
106
+ rulesFile: '.devin/rules/aies.md',
107
+ commandsDir: '.devin/skills',
108
+ commandFormat: 'skill',
109
+ agentsDir: null,
110
+ agentFormat: null,
111
+ skillMirrorDir: null,
112
+ hooksFile: '.devin/hooks.v1.json',
113
+ hookFormat: 'devin',
114
+ mcpFile: '.devin/mcp_config.json',
115
+ mcpFormat: 'portable',
116
+ },
117
+ opencode: {
118
+ root: '.opencode',
119
+ rulesFile: null,
120
+ commandsDir: '.opencode/commands',
121
+ commandFormat: 'described-md',
122
+ agentsDir: '.opencode/agents',
123
+ agentFormat: 'opencode',
124
+ skillMirrorDir: null,
125
+ hooksFile: '.opencode/plugins/aies-guard.js',
126
+ hookFormat: 'opencode',
127
+ mcpFile: 'opencode.json',
128
+ mcpFormat: 'opencode',
129
+ },
130
+ continue: {
131
+ root: '.continue',
132
+ rulesFile: '.continue/rules/aies.md',
133
+ commandsDir: '.continue/prompts',
134
+ commandFormat: 'continue-prompt',
135
+ agentsDir: null,
136
+ agentFormat: null,
137
+ skillMirrorDir: null,
138
+ hooksFile: null,
139
+ hookFormat: null,
140
+ mcpFile: '.continue/mcpServers/aies.json',
141
+ mcpFormat: 'portable',
142
+ },
143
+ };
144
+
145
+ export const SUPPORTED_TARGETS = Object.keys(TARGETS);
146
+
20
147
  /**
21
148
  * @param {string} ide
22
149
  * @param {{ cookbookId?: string | null }} [opts]
23
150
  */
24
151
  export function layoutFor(ide, opts = {}) {
152
+ const id = normalizeIde(ide);
153
+ const target = TARGETS[id] ?? TARGETS.copilot;
25
154
  const cookbookId = opts.cookbookId ?? null;
26
- const cookbookDir = cookbookId ? `${SHARED.cookbookBase}/${cookbookId}` : SHARED.cookbookBase;
27
-
28
- switch (ide) {
29
- case 'cursor':
30
- return {
31
- ide,
32
- ...SHARED,
33
- cookbookDir,
34
- root: '.cursor',
35
- rulesFile: '.cursor/rules/aies.mdc',
36
- skillsDir: '.cursor/skills',
37
- skillStyle: 'cursor-skill',
38
- promptsDir: '.cursor/prompts',
39
- agentsDir: '.cursor/agents',
40
- mcpFile: '.cursor/mcp.json',
41
- hooksFile: '.cursor/aies-hooks.json',
42
- };
43
- case 'copilot':
44
- return {
45
- ide,
46
- ...SHARED,
47
- cookbookDir,
48
- root: '.github',
49
- rulesFile: '.github/copilot-instructions.md',
50
- skillsDir: '.github/skills',
51
- skillStyle: 'flat',
52
- promptsDir: '.github/prompts',
53
- agentsDir: '.github/agents',
54
- // Copilot discovery only under .github — MCP config at repo root
55
- mcpFile: '.mcp.json',
56
- };
57
- case 'claude-code':
58
- return {
59
- ide,
60
- ...SHARED,
61
- cookbookDir,
62
- root: '.claude',
63
- rulesFile: 'CLAUDE.md',
64
- skillsDir: '.claude/skills',
65
- skillStyle: 'cursor-skill',
66
- promptsDir: '.claude/prompts',
67
- agentsDir: '.claude/agents',
68
- mcpFile: '.mcp.json',
69
- };
70
- case 'vscode':
71
- return {
72
- ide,
73
- ...SHARED,
74
- cookbookDir,
75
- root: '.vscode',
76
- rulesFile: '.vscode/aies-instructions.md',
77
- skillsDir: '.vscode/skills',
78
- skillStyle: 'flat',
79
- promptsDir: '.vscode/prompts',
80
- agentsDir: '.vscode/agents',
81
- mcpFile: '.vscode/mcp.json',
82
- };
83
- case 'windsurf':
84
- return {
85
- ide,
86
- ...SHARED,
87
- cookbookDir,
88
- root: '.windsurf',
89
- rulesFile: '.windsurfrules',
90
- skillsDir: '.windsurf/skills',
91
- skillStyle: 'flat',
92
- promptsDir: '.windsurf/prompts',
93
- agentsDir: '.windsurf/agents',
94
- mcpFile: '.windsurf/mcp.json',
95
- };
96
- case 'continue':
97
- return {
98
- ide,
99
- ...SHARED,
100
- cookbookDir,
101
- root: '.continue',
102
- rulesFile: '.continue/aies-context.md',
103
- skillsDir: '.continue/skills',
104
- skillStyle: 'flat',
105
- promptsDir: '.continue/prompts',
106
- agentsDir: '.continue/agents',
107
- mcpFile: '.continue/mcp.json',
108
- };
109
- default:
110
- return layoutFor('vscode', opts);
111
- }
155
+ return {
156
+ ide: TARGETS[id] ? id : 'copilot',
157
+ ...SHARED,
158
+ ...target,
159
+ promptsDir: target.commandsDir,
160
+ cookbookDir: cookbookId ? `${SHARED.cookbookBase}/${cookbookId}` : SHARED.cookbookBase,
161
+ };
112
162
  }
113
163
 
114
164
  export function describeInstallLayout(ide, opts = {}) {
@@ -116,34 +166,32 @@ export function describeInstallLayout(ide, opts = {}) {
116
166
  return {
117
167
  rootFiles: [
118
168
  'AIES.md',
119
- 'implementation_plan.md',
120
- 'CLAUDE.md',
121
169
  'AGENTS.md',
122
- 'copilot-instructions.md',
170
+ 'implementation_plan.md',
123
171
  'MODEL_POLICY.md',
124
- 'aies-hooks.json',
172
+ ...(L.ide === 'claude-code' ? ['CLAUDE.md'] : []),
125
173
  ],
126
174
  ideRoot: L.root,
127
- ideOnly: [L.promptsDir, L.skillsDir, L.agentsDir],
128
- sharedRoot: [L.cookbookDir, L.guidesDir, L.mcpsDir, L.templatesDir],
129
- rulesFile: L.rulesFile,
175
+ ideOnly: [L.commandsDir, L.agentsDir, L.skillMirrorDir].filter(Boolean),
176
+ sharedRoot: [L.skillsDir, L.cookbookDir, L.guidesDir, L.mcpsDir, L.templatesDir, 'plans', 'reviews', 'hooks'],
177
+ rulesFile: L.rulesFile ?? 'AGENTS.md',
130
178
  skillsDir: L.skillsDir,
131
- promptsDir: L.promptsDir,
179
+ promptsDir: L.commandsDir,
132
180
  cookbookDir: L.cookbookDir,
133
181
  guidesDir: L.guidesDir,
134
- agentsDir: L.agentsDir,
182
+ agentsDir: L.agentsDir ?? '(AGENTS.md roles — target has no project subagents)',
135
183
  mcpsDir: L.mcpsDir,
136
184
  templatesDir: L.templatesDir,
137
185
  mcpFile: L.mcpFile,
138
186
  policyFile: L.policyFile,
139
- hooksFile: L.hooksFile,
187
+ hooksFile: L.hooksFile ?? 'hooks/ (CI evidence gates — target has no native hooks)',
140
188
  };
141
189
  }
142
190
 
143
191
  export function buildInstallQuery(profile) {
144
192
  return new URLSearchParams({
145
193
  stack: profile.stack,
146
- ide: profile.ide,
194
+ ide: normalizeIde(profile.ide),
147
195
  role: profile.role,
148
196
  maturity: profile.maturity,
149
197
  goal: profile.goal,
@@ -156,7 +204,7 @@ export function getInstallCommands(profile) {
156
204
  const q = buildInstallQuery(profile);
157
205
  const flags = [
158
206
  `--stack ${profile.stack}`,
159
- `--ide ${profile.ide}`,
207
+ `--ide ${normalizeIde(profile.ide)}`,
160
208
  `--role ${profile.role}`,
161
209
  `--maturity ${profile.maturity}`,
162
210
  `--goal ${profile.goal}`,
@@ -165,8 +213,8 @@ export function getInstallCommands(profile) {
165
213
  '--yes',
166
214
  ].join(' ');
167
215
  return {
168
- npx: `npx @grvmithas/create-aies-kit ${flags}`,
169
- enhance: 'npx @grvmithas/create-aies-kit enhance',
216
+ npx: `npx @grvmithas/create-aies-kit@latest ${flags}`,
217
+ enhance: 'npx @grvmithas/create-aies-kit@latest enhance',
170
218
  powershell: `irm "${SITE_URL}/api/kit?${q}&shell=ps1" | iex`,
171
219
  bash: `curl -fsSL "${SITE_URL}/api/kit?${q}&shell=bash" | bash`,
172
220
  };
@@ -31,54 +31,107 @@ export const MCP_SPECS = [
31
31
  { id: 'discord', category: 'communication', file: 'communication/discord.mcp.md', label: 'Discord' },
32
32
  ];
33
33
 
34
- /** Runnable stubs — copy into mcpServers only after human approval + secrets. */
35
- export const MCP_SERVER_STUBS = {
34
+ /**
35
+ * Enable-ready server definitions (MCP 2026-07-28 era).
36
+ * Prefer the vendor-hosted Streamable HTTP endpoint with OAuth 2.1 — no long-lived token in the repo.
37
+ * stdio entries remain only where no vendor remote exists. Reference servers archived by the MCP
38
+ * project (server-github, server-postgres, server-redis, server-slack) are intentionally absent.
39
+ * Endpoints verified 2026-11-06; re-verify in the quarterly vendor review.
40
+ */
41
+ export const MCP_SERVERS = {
36
42
  github: {
37
- command: 'npx',
38
- args: ['-y', '@modelcontextprotocol/server-github'],
39
- env: { GITHUB_PERSONAL_ACCESS_TOKEN: '${GITHUB_TOKEN}' },
40
- },
41
- filesystem: {
42
- command: 'npx',
43
- args: ['-y', '@modelcontextprotocol/server-filesystem', '${WORKSPACE_ROOT}'],
44
- },
45
- docker: {
46
- command: 'npx',
47
- args: ['-y', '@modelcontextprotocol/server-docker'],
48
- },
49
- postgresql: {
50
- command: 'npx',
51
- args: ['-y', '@modelcontextprotocol/server-postgres'],
52
- env: { POSTGRES_CONNECTION_STRING: '${DATABASE_URL}' },
53
- },
54
- redis: {
55
- command: 'npx',
56
- args: ['-y', '@modelcontextprotocol/server-redis'],
57
- env: { REDIS_URL: '${REDIS_URL}' },
58
- },
59
- playwright: {
60
- command: 'npx',
61
- args: ['-y', '@playwright/mcp@latest'],
43
+ transport: 'http',
44
+ url: 'https://api.githubcopilot.com/mcp/readonly',
45
+ auth: 'oauth',
46
+ note: 'Read-only endpoint. Drop /readonly only after approval; narrow toolsets with the X-MCP-Toolsets header.',
62
47
  },
48
+ gitlab: { transport: 'http', url: 'https://gitlab.com/api/v4/mcp', auth: 'oauth' },
63
49
  sentry: {
64
- command: 'npx',
65
- args: ['-y', '@sentry/mcp-server'],
66
- env: { SENTRY_AUTH_TOKEN: '${SENTRY_AUTH_TOKEN}' },
50
+ transport: 'http',
51
+ url: 'https://mcp.sentry.dev/mcp',
52
+ auth: 'oauth',
53
+ note: 'Scope by URL: /mcp/{org}/{project}.',
67
54
  },
68
- linear: {
69
- command: 'npx',
70
- args: ['-y', 'mcp-linear'],
71
- env: { LINEAR_API_KEY: '${LINEAR_API_KEY}' },
55
+ linear: { transport: 'http', url: 'https://mcp.linear.app/mcp/readonly', auth: 'oauth' },
56
+ notion: { transport: 'http', url: 'https://mcp.notion.com/mcp', auth: 'oauth' },
57
+ jira: {
58
+ transport: 'http',
59
+ url: 'https://mcp.atlassian.com/v2/mcp',
60
+ auth: 'oauth',
61
+ note: 'Atlassian Rovo MCP (Jira + Confluence); honors the signed-in user\'s permissions.',
72
62
  },
73
- slack: {
63
+ asana: { transport: 'http', url: 'https://mcp.asana.com/v2/mcp', auth: 'oauth' },
64
+ supabase: {
65
+ transport: 'http',
66
+ url: 'https://mcp.supabase.com/mcp?read_only=true',
67
+ auth: 'oauth',
68
+ note: 'Add &project_ref=<ref> to pin one project.',
69
+ },
70
+ playwright: { transport: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp@latest'] },
71
+ 'chrome-devtools': { transport: 'stdio', command: 'npx', args: ['-y', 'chrome-devtools-mcp@latest'] },
72
+ filesystem: {
73
+ transport: 'stdio',
74
74
  command: 'npx',
75
- args: ['-y', '@modelcontextprotocol/server-slack'],
76
- env: { SLACK_BOT_TOKEN: '${SLACK_BOT_TOKEN}' },
75
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '.'],
76
+ note: 'Usually redundant: coding agents already have scoped file tools.',
77
77
  },
78
78
  };
79
79
 
80
+ /** @deprecated kept for 1.x consumers; use MCP_SERVERS. */
81
+ export const MCP_SERVER_STUBS = MCP_SERVERS;
82
+
83
+ /**
84
+ * Render enabled servers for one client config format.
85
+ * @param {'portable'|'cursor'|'gemini'|'opencode'|'vscode'} format
86
+ * @param {string[]} ids
87
+ */
88
+ export function renderMcpServers(format, ids) {
89
+ /** @type {Record<string, object>} */
90
+ const out = {};
91
+ for (const id of ids) {
92
+ const s = MCP_SERVERS[id];
93
+ if (!s) continue;
94
+ if (s.transport === 'http') {
95
+ out[id] = {
96
+ portable: { type: 'http', url: s.url },
97
+ vscode: { type: 'http', url: s.url },
98
+ cursor: { url: s.url },
99
+ gemini: { httpUrl: s.url },
100
+ opencode: { type: 'remote', url: s.url, enabled: true },
101
+ }[format];
102
+ } else {
103
+ out[id] = {
104
+ portable: { type: 'stdio', command: s.command, args: s.args },
105
+ vscode: { type: 'stdio', command: s.command, args: s.args },
106
+ cursor: { command: s.command, args: s.args },
107
+ gemini: { command: s.command, args: s.args },
108
+ opencode: { type: 'local', command: [s.command, ...s.args], enabled: true },
109
+ }[format];
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** Codex `.codex/config.toml` — every server commented out until a human enables it. */
116
+ export function renderCodexMcpToml(ids) {
117
+ const lines = [];
118
+ for (const id of ids) {
119
+ const s = MCP_SERVERS[id];
120
+ if (!s) continue;
121
+ lines.push(`# [mcp_servers.${id.replace(/[^a-z0-9_]/gi, '_')}]`);
122
+ if (s.transport === 'http') {
123
+ lines.push(`# url = "${s.url}"`);
124
+ } else {
125
+ lines.push(`# command = "${s.command}"`);
126
+ lines.push(`# args = [${s.args.map(a => `"${a}"`).join(', ')}]`);
127
+ }
128
+ lines.push('');
129
+ }
130
+ return lines.join('\n');
131
+ }
132
+
80
133
  export function stackMcpIds(stackId) {
81
- const base = ['github', 'filesystem'];
134
+ const base = ['github'];
82
135
  switch (stackId) {
83
136
  case 'react':
84
137
  case 'nextjs':
@@ -147,7 +200,9 @@ export function detectRepoSignals(cwd, fs) {
147
200
  signals.postgres = signals.postgres || /psycopg|sqlalchemy|asyncpg/i.test(py);
148
201
  }
149
202
 
150
- const ids = new Set(['github', 'filesystem']);
203
+ const ids = new Set(['github']);
204
+ if (exists('.gitlab-ci.yml')) ids.add('gitlab');
205
+ if (exists('supabase')) ids.add('supabase');
151
206
  if (signals.dockerfile) ids.add('docker');
152
207
  if (signals.kubernetes) ids.add('kubernetes');
153
208
  if (signals.playwright || signals.next) ids.add('playwright');
@@ -164,7 +219,7 @@ export function buildRecommendedMcpConfig(ids) {
164
219
  /** @type {Record<string, object>} */
165
220
  const recommended = {};
166
221
  for (const id of ids) {
167
- if (MCP_SERVER_STUBS[id]) recommended[id] = MCP_SERVER_STUBS[id];
222
+ if (MCP_SERVERS[id]) recommended[id] = MCP_SERVERS[id];
168
223
  }
169
224
  return recommended;
170
225
  }
@@ -1,5 +1,7 @@
1
1
  export const SITE_URL = 'https://www.ai-standards.dev';
2
2
 
3
+ export const KIT_VERSION = '2.3.0';
4
+
3
5
  export const GOALS = [
4
6
  { id: 'new-product', label: 'Build a new product' },
5
7
  { id: 'existing-codebase', label: 'Join an existing codebase' },
@@ -30,13 +32,28 @@ export const MATURITIES = [
30
32
 
31
33
  export const IDES = [
32
34
  { id: 'cursor', label: 'Cursor' },
33
- { id: 'copilot', label: 'GitHub Copilot' },
34
35
  { id: 'claude-code', label: 'Claude Code' },
35
- { id: 'vscode', label: 'VS Code' },
36
- { id: 'windsurf', label: 'Windsurf' },
36
+ { id: 'copilot', label: 'GitHub Copilot (VS Code / CLI / cloud agent)' },
37
+ { id: 'codex', label: 'OpenAI Codex (CLI / app / cloud)' },
38
+ { id: 'gemini-cli', label: 'Gemini CLI' },
39
+ { id: 'kiro', label: 'Kiro' },
40
+ { id: 'windsurf', label: 'Devin Desktop (Windsurf)' },
41
+ { id: 'opencode', label: 'opencode' },
37
42
  { id: 'continue', label: 'Continue' },
38
43
  ];
39
44
 
45
+ /** Legacy ids accepted on input and normalized before generation. */
46
+ export const IDE_ALIASES = {
47
+ vscode: 'copilot',
48
+ devin: 'windsurf',
49
+ gemini: 'gemini-cli',
50
+ claude: 'claude-code',
51
+ };
52
+
53
+ export function normalizeIde(ide) {
54
+ return IDE_ALIASES[ide] ?? ide;
55
+ }
56
+
40
57
  export const MODELS = [
41
58
  { id: 'claude', label: 'Claude (Anthropic)' },
42
59
  { id: 'gpt', label: 'GPT (OpenAI)' },
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Detect a repository's own verification commands so AGENTS.md states real gates, not placeholders.
3
+ * Read-only: inspects manifests and lockfiles, never executes project code.
4
+ */
5
+ import path from 'node:path';
6
+
7
+ export const VERIFY_KEYS = ['lint', 'typecheck', 'test', 'build'];
8
+
9
+ const NPM_DEFAULT_TEST = /no test specified/i;
10
+
11
+ function nodeRunner(exists) {
12
+ if (exists('pnpm-lock.yaml')) return { name: 'pnpm', run: s => `pnpm ${s}`, test: 'pnpm test', exec: 'pnpm exec' };
13
+ if (exists('yarn.lock')) return { name: 'yarn', run: s => `yarn ${s}`, test: 'yarn test', exec: 'yarn' };
14
+ if (exists('bun.lock') || exists('bun.lockb')) return { name: 'bun', run: s => `bun run ${s}`, test: 'bun run test', exec: 'bunx' };
15
+ return { name: 'npm', run: s => `npm run ${s}`, test: 'npm test', exec: 'npx' };
16
+ }
17
+
18
+ function firstScript(scripts, names) {
19
+ return names.find(n => typeof scripts[n] === 'string' && scripts[n].trim());
20
+ }
21
+
22
+ function detectNode(exists, read) {
23
+ let pkg;
24
+ try {
25
+ pkg = JSON.parse(read('package.json'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ const scripts = pkg.scripts ?? {};
30
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
31
+ const r = nodeRunner(exists);
32
+ const out = {};
33
+
34
+ const lint = firstScript(scripts, ['lint', 'lint:check', 'eslint', 'biome:check', 'check:lint']);
35
+ if (lint) out.lint = r.run(lint);
36
+ else if (deps['@biomejs/biome']) out.lint = `${r.exec} biome check .`;
37
+
38
+ const typecheck = firstScript(scripts, ['typecheck', 'type-check', 'check-types', 'types:check', 'tsc']);
39
+ if (typecheck) out.typecheck = r.run(typecheck);
40
+ else if (deps.typescript && exists('tsconfig.json')) out.typecheck = `${r.exec} tsc --noEmit`;
41
+
42
+ if (scripts.test && !NPM_DEFAULT_TEST.test(scripts.test)) out.test = r.test;
43
+ else {
44
+ const alt = firstScript(scripts, ['test:unit', 'vitest', 'jest']);
45
+ if (alt) out.test = r.run(alt);
46
+ }
47
+
48
+ if (scripts.build) out.build = r.run('build');
49
+ return { commands: out, source: `package.json scripts (${r.name})` };
50
+ }
51
+
52
+ function detectPython(exists, read) {
53
+ const pyproject = read('pyproject.toml');
54
+ const reqs = read('requirements.txt') + read('requirements-dev.txt') + read('requirements/dev.txt');
55
+ if (!pyproject && !reqs && !exists('setup.py')) return null;
56
+ const all = `${pyproject}\n${reqs}`;
57
+ const prefix = exists('uv.lock') ? 'uv run ' : exists('poetry.lock') ? 'poetry run ' : exists('pdm.lock') ? 'pdm run ' : '';
58
+ const has = re => re.test(all);
59
+ const dep = name => new RegExp(`\\[tool\\.${name}\\b|(^|["'\\s,\\[])${name}([<>=~!\\[;\\s"',\\]]|$)`, 'm').test(all);
60
+ const out = {};
61
+ if (dep('ruff')) out.lint = `${prefix}ruff check .`;
62
+ else if (dep('flake8')) out.lint = `${prefix}flake8`;
63
+ if (dep('mypy')) out.typecheck = `${prefix}mypy .`;
64
+ else if (dep('pyright')) out.typecheck = `${prefix}pyright`;
65
+ if (dep('pytest') || has(/\[tool\.pytest/) || exists('pytest.ini') || exists('tests')) out.test = `${prefix}pytest`;
66
+ const tool = prefix ? prefix.trim().split(' ')[0] : 'pip';
67
+ return { commands: out, source: `Python project (${tool})` };
68
+ }
69
+
70
+ function detectJvm(exists) {
71
+ if (exists('mvnw') || exists('pom.xml')) {
72
+ const mvn = exists('mvnw') ? './mvnw -B' : 'mvn -B';
73
+ return { commands: { typecheck: `${mvn} -q test-compile`, test: `${mvn} verify`, build: `${mvn} -DskipTests package` }, source: 'Maven' };
74
+ }
75
+ if (exists('gradlew') || exists('build.gradle') || exists('build.gradle.kts')) {
76
+ const g = exists('gradlew') ? './gradlew' : 'gradle';
77
+ return {
78
+ commands: { lint: `${g} check -x test`, typecheck: `${g} testClasses`, test: `${g} test`, build: `${g} build -x test` },
79
+ source: 'Gradle',
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function detectDotnet(listRoot) {
86
+ const entries = listRoot();
87
+ const target = entries.find(f => f.endsWith('.sln') || f.endsWith('.slnx')) ?? entries.find(f => f.endsWith('.csproj'));
88
+ if (!target) return null;
89
+ return {
90
+ commands: {
91
+ lint: `dotnet format ${target} --verify-no-changes`,
92
+ typecheck: `dotnet build ${target} -warnaserror`,
93
+ test: `dotnet test ${target}`,
94
+ },
95
+ source: '.NET',
96
+ };
97
+ }
98
+
99
+ function detectGo(exists) {
100
+ if (!exists('go.mod')) return null;
101
+ return { commands: { lint: 'go vet ./...', typecheck: 'go build ./...', test: 'go test ./...' }, source: 'Go modules' };
102
+ }
103
+
104
+ function detectMake(read) {
105
+ const makefile = read('Makefile');
106
+ if (!makefile) return null;
107
+ const add = {};
108
+ for (const key of VERIFY_KEYS) {
109
+ if (new RegExp(`^${key}:`, 'm').test(makefile)) add[key] = `make ${key}`;
110
+ }
111
+ return Object.keys(add).length ? { commands: add, source: 'Makefile targets' } : null;
112
+ }
113
+
114
+ /**
115
+ * @param {string} cwd
116
+ * @param {{ existsSync: Function, readFileSync: Function, readdirSync?: Function }} fs
117
+ * @returns {{ commands: Partial<Record<'lint'|'typecheck'|'test'|'build', string>>, sources: string[] }}
118
+ */
119
+ export function detectVerifyCommands(cwd, fs) {
120
+ const exists = rel => fs.existsSync(path.join(cwd, rel));
121
+ const read = rel => {
122
+ try {
123
+ return fs.readFileSync(path.join(cwd, rel), 'utf8');
124
+ } catch {
125
+ return '';
126
+ }
127
+ };
128
+ const listRoot = () => {
129
+ try {
130
+ return fs.readdirSync ? fs.readdirSync(cwd) : [];
131
+ } catch {
132
+ return [];
133
+ }
134
+ };
135
+
136
+ const commands = {};
137
+ const sources = [];
138
+ const merge = found => {
139
+ if (!found) return;
140
+ let used = false;
141
+ for (const [k, v] of Object.entries(found.commands)) {
142
+ if (!commands[k] && v) {
143
+ commands[k] = v;
144
+ used = true;
145
+ }
146
+ }
147
+ if (used) sources.push(found.source);
148
+ };
149
+
150
+ // Explicit project scripts first, then commands inferred from the toolchain.
151
+ if (exists('package.json')) merge(detectNode(exists, read));
152
+ merge(detectMake(read));
153
+ merge(detectPython(exists, read));
154
+ merge(detectJvm(exists));
155
+ merge(detectDotnet(listRoot));
156
+ merge(detectGo(exists));
157
+ return { commands, sources };
158
+ }