codemini-cli 0.5.8 → 0.5.10

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.
@@ -11,6 +11,8 @@ import { readSkillRegistry } from './skill-registry.js';
11
11
 
12
12
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
13
13
  const BUNDLED_SKILLS_DIR = path.resolve(MODULE_DIR, '..', '..', 'skills');
14
+ const SKILL_CATALOG_FILE = 'codemini.skills.json';
15
+ const FRONTMATTER_READ_BYTES = 16 * 1024;
14
16
 
15
17
  function parseArrayText(value) {
16
18
  const inner = value.slice(1, -1).trim();
@@ -46,6 +48,79 @@ function parseFrontmatter(raw) {
46
48
  return { metadata, content };
47
49
  }
48
50
 
51
+ function readFrontmatterMetadata(filePath) {
52
+ let fd;
53
+ try {
54
+ fd = fs.openSync(filePath, 'r');
55
+ const buffer = Buffer.alloc(FRONTMATTER_READ_BYTES);
56
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
57
+ const raw = buffer.subarray(0, bytesRead).toString('utf8');
58
+ if (!raw.startsWith('---\n')) return {};
59
+ const end = raw.indexOf('\n---\n', 4);
60
+ if (end === -1) return {};
61
+ return parseFrontmatter(raw.slice(0, end + 5)).metadata;
62
+ } catch {
63
+ return {};
64
+ } finally {
65
+ if (fd !== undefined) {
66
+ try { fs.closeSync(fd); } catch {}
67
+ }
68
+ }
69
+ }
70
+
71
+ function readSkillCatalog(baseDir) {
72
+ const catalogPath = path.join(baseDir, SKILL_CATALOG_FILE);
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
75
+ return parsed && typeof parsed === 'object' && parsed.skills && typeof parsed.skills === 'object'
76
+ ? parsed.skills
77
+ : {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ function normalizeStringArray(value) {
84
+ if (Array.isArray(value)) {
85
+ return value.map((item) => String(item || '').trim()).filter(Boolean);
86
+ }
87
+ const single = String(value || '').trim();
88
+ return single ? [single] : [];
89
+ }
90
+
91
+ function catalogMetadata(catalog, name) {
92
+ const entry = catalog?.[name];
93
+ if (!entry || typeof entry !== 'object') return {};
94
+ return {
95
+ ...(entry.description ? { description: String(entry.description) } : {}),
96
+ ...(entry.mode ? { mode: String(entry.mode) } : {}),
97
+ ...(entry.enabled !== undefined ? { enabled: entry.enabled !== false } : {}),
98
+ ...(entry.priority !== undefined ? { priority: Number(entry.priority) } : {}),
99
+ triggers: normalizeStringArray(entry.triggers)
100
+ };
101
+ }
102
+
103
+ function commandWithContent(command, parsedContent) {
104
+ if (parsedContent !== undefined) {
105
+ return { ...command, content: parsedContent };
106
+ }
107
+
108
+ let cached;
109
+ let loaded = false;
110
+ return Object.defineProperty({ ...command }, 'content', {
111
+ enumerable: true,
112
+ configurable: true,
113
+ get() {
114
+ if (!loaded) {
115
+ const raw = fs.readFileSync(command.path, 'utf8');
116
+ cached = parseFrontmatter(raw).content;
117
+ loaded = true;
118
+ }
119
+ return cached;
120
+ }
121
+ });
122
+ }
123
+
49
124
  function safeEntries(dir) {
50
125
  try {
51
126
  return fs.readdirSync(dir);
@@ -104,77 +179,95 @@ function loadMarkdownCommandsFromDir(baseDir, source, out) {
104
179
 
105
180
  function loadLegacySkillsFromDir(baseDir, source, out) {
106
181
  if (!fs.existsSync(baseDir)) return;
182
+ const catalog = readSkillCatalog(baseDir);
107
183
  for (const entry of safeEntries(baseDir)) {
108
184
  if (!isSafeEntry(entry)) continue;
109
185
  const full = path.join(baseDir, entry);
110
186
  const stat = fs.statSync(full);
111
187
  if (!stat.isDirectory()) continue;
188
+ const catalogMeta = catalogMetadata(catalog, entry);
112
189
  const skillFile = path.join(full, 'SKILL.md');
113
190
  if (!fs.existsSync(skillFile)) continue;
114
- const raw = fs.readFileSync(skillFile, 'utf8');
115
- const parsed = parseFrontmatter(raw);
116
- setCommand(out, entry, {
191
+ const frontmatter = readFrontmatterMetadata(skillFile);
192
+ setCommand(out, entry, commandWithContent({
117
193
  name: entry,
118
194
  source: `${source}-skill`,
119
195
  path: skillFile,
120
196
  metadata: {
121
- description: parsed.metadata.description || 'Legacy skill',
197
+ ...frontmatter,
198
+ ...catalogMeta,
199
+ description: catalogMeta.description || frontmatter.description || 'Legacy skill',
122
200
  type: 'skill'
123
- },
124
- content: parsed.content
125
- });
201
+ }
202
+ }));
126
203
  }
127
204
  }
128
205
 
129
206
  function loadBundledSkillsFromDir(baseDir, out) {
130
207
  if (!fs.existsSync(baseDir)) return;
208
+ const catalog = readSkillCatalog(baseDir);
131
209
  for (const entry of safeEntries(baseDir)) {
132
210
  if (!isSafeEntry(entry)) continue;
133
211
  const full = path.join(baseDir, entry);
134
212
  const stat = fs.statSync(full);
135
213
  if (!stat.isDirectory()) continue;
214
+ const catalogMeta = catalogMetadata(catalog, entry);
136
215
  const skillFile = path.join(full, 'SKILL.md');
137
216
  if (!fs.existsSync(skillFile)) continue;
138
- const raw = fs.readFileSync(skillFile, 'utf8');
139
- const parsed = parseFrontmatter(raw);
140
- setCommand(out, entry, {
217
+ const frontmatter = readFrontmatterMetadata(skillFile);
218
+ setCommand(out, entry, commandWithContent({
141
219
  name: entry,
142
220
  source: 'bundled-skill',
143
221
  path: skillFile,
144
222
  metadata: {
145
- ...parsed.metadata,
223
+ ...frontmatter,
224
+ ...catalogMeta,
146
225
  type: 'skill',
147
- version: parsed.metadata.version || '0.1.0',
148
- description: parsed.metadata.description || 'Bundled skill'
149
- },
150
- content: parsed.content
151
- });
226
+ version: frontmatter.version || '0.1.0',
227
+ description: catalogMeta.description || frontmatter.description || 'Bundled skill'
228
+ }
229
+ }));
230
+ }
231
+ }
232
+
233
+ function applySkillCatalogPatches(baseDir, out) {
234
+ const catalog = readSkillCatalog(baseDir);
235
+ for (const name of Object.keys(catalog)) {
236
+ const existing = out.get(name);
237
+ if (!existing || existing.metadata?.type !== 'skill') continue;
238
+ const meta = catalogMetadata(catalog, name);
239
+ existing.metadata = {
240
+ ...existing.metadata,
241
+ ...meta,
242
+ description: meta.description || existing.metadata.description || ''
243
+ };
152
244
  }
153
245
  }
154
246
 
155
247
  function loadInstalledSkillsFromRegistry(baseDir, registry, out) {
156
248
  if (!registry || !Array.isArray(registry.skills)) return;
249
+ const catalog = readSkillCatalog(baseDir);
157
250
  for (const skill of registry.skills) {
158
251
  if (skill.enabled === false) continue;
159
252
  const name = skill.name;
160
253
  if (out.has(name)) continue;
254
+ const catalogMeta = catalogMetadata(catalog, name);
161
255
  const entry = skill.entryFile || 'SKILL.md';
162
256
  const full = path.join(baseDir, name, entry);
163
257
  if (!fs.existsSync(full)) continue;
164
- const raw = fs.readFileSync(full, 'utf8');
165
- const parsed = parseFrontmatter(raw);
166
- setCommand(out, name, {
258
+ const frontmatter = readFrontmatterMetadata(full);
259
+ setCommand(out, name, commandWithContent({
167
260
  name,
168
261
  source: 'registry-skill',
169
262
  path: full,
170
263
  metadata: {
171
- ...parsed.metadata,
264
+ ...frontmatter,
265
+ ...catalogMeta,
172
266
  type: 'skill',
173
- version: skill.version || parsed.metadata.version || '0.0.0',
174
- description: skill.description || parsed.metadata.description || 'Installed skill'
175
- },
176
- content: parsed.content
177
- });
267
+ version: skill.version || frontmatter.version || '0.0.0',
268
+ description: catalogMeta.description || skill.description || frontmatter.description || 'Installed skill'
269
+ }
270
+ }));
178
271
  }
179
272
  }
180
273
 
@@ -201,10 +294,12 @@ export async function loadCommandsAndSkills(cwd = process.cwd()) {
201
294
  const commands = new Map();
202
295
 
203
296
  loadBundledSkillsFromDir(BUNDLED_SKILLS_DIR, commands);
297
+ applySkillCatalogPatches(getProjectSkillsDir(cwd), commands);
204
298
  loadMarkdownCommandsFromDir(getCommandsDir(), 'global', commands);
205
299
  loadMarkdownCommandsFromDir(getProjectCommandsDir(cwd), 'project', commands);
206
300
  loadLegacySkillsFromDir(getSkillsDir(), 'global', commands);
207
301
  loadLegacySkillsFromDir(getProjectSkillsDir(cwd), 'project', commands);
302
+ applySkillCatalogPatches(getProjectSkillsDir(cwd), commands);
208
303
  const registry = await readSkillRegistry();
209
304
  loadInstalledSkillsFromRegistry(getSkillsDir(), registry, commands);
210
305
 
@@ -196,7 +196,25 @@ function suggestionForToken(token, config) {
196
196
  return 'Prefer structured tools like read, edit, write, grep, glob, and list first. If you need shell fallback, use allowed shell commands for search and local context such as rg, find, grep, sed, cat, or ls.';
197
197
  }
198
198
 
199
- function validateCdSegment(command, workspaceRoot) {
199
+ function allowedPathRoots(workspaceRoot, config = {}) {
200
+ return [
201
+ workspaceRoot,
202
+ ...(Array.isArray(config?.policy?.allowed_paths) ? config.policy.allowed_paths : [])
203
+ ]
204
+ .map((item) => String(item || '').trim())
205
+ .filter(Boolean)
206
+ .map((item) => path.resolve(item));
207
+ }
208
+
209
+ function isWithinAnyRoot(candidatePath, roots = []) {
210
+ const resolvedCandidate = path.resolve(candidatePath);
211
+ return roots.some((root) => {
212
+ const relative = path.relative(root, resolvedCandidate);
213
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
214
+ });
215
+ }
216
+
217
+ function validateCdSegment(command, workspaceRoot, config = {}) {
200
218
  const tokens = tokenizeTopLevel(command);
201
219
  if (tokens.length === 1) {
202
220
  return { allowed: false, reason: 'cd requires a target path in safe mode' };
@@ -210,11 +228,9 @@ function validateCdSegment(command, workspaceRoot) {
210
228
  return { allowed: false, reason: 'cd target is not allowed in safe mode' };
211
229
  }
212
230
 
213
- const resolvedRoot = path.resolve(workspaceRoot);
214
- const resolvedTarget = path.resolve(resolvedRoot, rawTarget);
215
- const relative = path.relative(resolvedRoot, resolvedTarget);
216
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
217
- return { allowed: false, reason: `cd escapes workspace: ${rawTarget}` };
231
+ const resolvedTarget = path.resolve(path.resolve(workspaceRoot), rawTarget);
232
+ if (!isWithinAnyRoot(resolvedTarget, allowedPathRoots(workspaceRoot, config))) {
233
+ return { allowed: false, reason: `cd escapes workspace or allowed paths: ${rawTarget}` };
218
234
  }
219
235
 
220
236
  return { allowed: true };
@@ -246,7 +262,7 @@ export function evaluateCommandPolicy(command, config, workspaceRoot = process.c
246
262
  for (const item of inspectedTokens) {
247
263
  if (SHELL_KEYWORDS.has(item.token)) continue;
248
264
  if (item.token === 'cd') {
249
- const cdCheck = validateCdSegment(item.raw, workspaceRoot);
265
+ const cdCheck = validateCdSegment(item.raw, workspaceRoot, config);
250
266
  if (!cdCheck.allowed) {
251
267
  return { allowed: false, reason: cdCheck.reason, suggestion: suggestionForToken(item.token, config) };
252
268
  }
@@ -263,11 +279,19 @@ export function evaluateCommandPolicy(command, config, workspaceRoot = process.c
263
279
  }
264
280
  }
265
281
 
266
- const workspaceLower = String(workspaceRoot).toLowerCase().replace(/\//g, '\\');
282
+ const allowedLower = allowedPathRoots(workspaceRoot, config).map((item) => item.toLowerCase().replace(/\//g, '\\'));
267
283
  const windowsAbsPath = lower.match(/[a-z]:\\[^\s'"]+/g) || [];
268
284
  for (const p of windowsAbsPath) {
269
- if (!p.startsWith(workspaceLower)) {
270
- return { allowed: false, reason: `absolute path outside workspace: ${p}`, suggestion: suggestionForToken(token, config) };
285
+ if (!allowedLower.some((root) => p === root || p.startsWith(`${root}\\`))) {
286
+ return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
287
+ }
288
+ }
289
+
290
+ const posixAbsPath = cmd.match(/(?<![:/\w])\/(?!\/)[^\s'"]+/g) || [];
291
+ const allowedResolved = allowedPathRoots(workspaceRoot, config);
292
+ for (const p of posixAbsPath) {
293
+ if (!isWithinAnyRoot(p, allowedResolved)) {
294
+ return { allowed: false, reason: `absolute path outside workspace or allowed paths: ${p}`, suggestion: suggestionForToken(token, config) };
271
295
  }
272
296
  }
273
297
 
@@ -36,7 +36,9 @@ const DEFAULT_CONFIG = {
36
36
  read_file_max_chars: 24000,
37
37
  prompt_budget_audit: false,
38
38
  microcompact_enabled: true,
39
- microcompact_keep_recent: 5
39
+ microcompact_keep_recent: 5,
40
+ project_instructions_enabled: true,
41
+ project_instructions_max_chars: 12000
40
42
  },
41
43
  execution: {
42
44
  mode: 'normal',
@@ -89,6 +91,7 @@ const DEFAULT_CONFIG = {
89
91
  policy: {
90
92
  safe_mode: true,
91
93
  allow_dangerous_commands: false,
94
+ allowed_paths: [],
92
95
  command_allowlist: [],
93
96
  blocked_commands: [],
94
97
  blocked_path_patterns: [],
@@ -195,6 +198,9 @@ function normalizePolicyLists(config) {
195
198
  next.policy.command_allowlist = uniqueStrings(
196
199
  Array.isArray(next.policy.command_allowlist) ? next.policy.command_allowlist : []
197
200
  );
201
+ next.policy.allowed_paths = uniqueStrings(
202
+ Array.isArray(next.policy.allowed_paths) ? next.policy.allowed_paths : []
203
+ );
198
204
  next.policy.blocked_commands = uniqueStrings(
199
205
  Array.isArray(next.policy.blocked_commands) ? next.policy.blocked_commands : []
200
206
  );
@@ -212,6 +218,13 @@ function parseValue(input) {
212
218
  if (input === 'true') return true;
213
219
  if (input === 'false') return false;
214
220
  if (input === 'null') return null;
221
+ if ((input.startsWith('[') && input.endsWith(']')) || (input.startsWith('{') && input.endsWith('}'))) {
222
+ try {
223
+ return JSON.parse(input);
224
+ } catch {
225
+ return input;
226
+ }
227
+ }
215
228
  if (!Number.isNaN(Number(input)) && input.trim() !== '') return Number(input);
216
229
  return input;
217
230
  }
@@ -25,6 +25,7 @@ const PROJECT_MARKER_FILES = new Set([
25
25
  const LANGUAGE_BY_EXT = EXTENSION_LANGUAGE_MAP;
26
26
 
27
27
  const initCache = new BoundedCache({ maxSize: 32, ttlMs: 10 * 60 * 1000 });
28
+ const ignoreRulesCache = new BoundedCache({ maxSize: 128, ttlMs: 60 * 1000 });
28
29
  const PROJECT_CONTEXT_MAX_FILES = 6;
29
30
 
30
31
  function clipList(values, max = 32) {
@@ -92,9 +93,24 @@ function gitignorePatternToRegex(pattern) {
92
93
  }
93
94
 
94
95
  async function readIgnoreFileRules(cwd, fileName) {
96
+ const filePath = path.join(cwd, fileName);
97
+ const stat = await safeStat(filePath);
98
+ const cacheKey = `${filePath}:${Number(stat?.mtimeMs || 0)}:${Number(stat?.size || 0)}`;
99
+ if (ignoreRulesCache.has(cacheKey)) return ignoreRulesCache.get(cacheKey);
100
+
101
+ for (const key of ignoreRulesCache.keys()) {
102
+ if (String(key).startsWith(`${filePath}:`) && key !== cacheKey) {
103
+ ignoreRulesCache.delete(key);
104
+ }
105
+ }
106
+
95
107
  try {
96
- const raw = await fs.readFile(path.join(cwd, fileName), 'utf8');
97
- return raw
108
+ if (!stat?.isFile()) {
109
+ ignoreRulesCache.set(cacheKey, []);
110
+ return [];
111
+ }
112
+ const raw = await fs.readFile(filePath, 'utf8');
113
+ const rules = raw
98
114
  .split(/\r?\n/)
99
115
  .map((line) => line.trim())
100
116
  .filter((line) => line && !line.startsWith('#'))
@@ -114,7 +130,10 @@ async function readIgnoreFileRules(cwd, fileName) {
114
130
  };
115
131
  })
116
132
  .filter((rule) => rule.normalized);
133
+ ignoreRulesCache.set(cacheKey, rules);
134
+ return rules;
117
135
  } catch {
136
+ ignoreRulesCache.set(cacheKey, []);
118
137
  return [];
119
138
  }
120
139
  }
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const DEFAULT_MAX_CHARS = 12000;
5
+ const CANDIDATE_FILES = [
6
+ 'AGENTS.md',
7
+ path.join('.agents', 'AGENTS.md'),
8
+ path.join('.agents', 'agents.md'),
9
+ path.join('.codemini', 'AGENTS.md'),
10
+ 'CLAUDE.md'
11
+ ];
12
+
13
+ function trimProjectInstructions(value, maxChars = DEFAULT_MAX_CHARS) {
14
+ const text = String(value || '').trim();
15
+ if (!text) return '';
16
+ const limit = Math.max(1000, Number(maxChars) || DEFAULT_MAX_CHARS);
17
+ if (text.length <= limit) return text;
18
+ return `${text.slice(0, limit - 120).trimEnd()}\n\n[Project instructions truncated: keep AGENTS.md concise or move details into linked docs.]`;
19
+ }
20
+
21
+ async function readFirstExistingFile(cwd, candidates = CANDIDATE_FILES) {
22
+ let current = path.resolve(cwd);
23
+ while (true) {
24
+ for (const candidate of candidates) {
25
+ const absolutePath = path.resolve(current, candidate);
26
+ let stat;
27
+ try {
28
+ stat = await fs.stat(absolutePath);
29
+ } catch {
30
+ continue;
31
+ }
32
+ if (!stat?.isFile()) continue;
33
+ const content = await fs.readFile(absolutePath, 'utf8');
34
+ const relativePath = path.relative(cwd, absolutePath) || candidate;
35
+ return {
36
+ path: absolutePath,
37
+ relativePath: relativePath.split(path.sep).join('/'),
38
+ content
39
+ };
40
+ }
41
+ const parent = path.dirname(current);
42
+ if (parent === current) break;
43
+ current = parent;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export async function loadProjectInstructions({
49
+ cwd = process.cwd(),
50
+ config = {},
51
+ maxChars = config?.context?.project_instructions_max_chars
52
+ } = {}) {
53
+ const enabled = config?.context?.project_instructions_enabled !== false;
54
+ if (!enabled) return '';
55
+
56
+ const found = await readFirstExistingFile(cwd);
57
+ if (!found) return '';
58
+
59
+ const body = trimProjectInstructions(found.content, maxChars);
60
+ if (!body) return '';
61
+
62
+ return [
63
+ 'Project Instructions:',
64
+ `Source: ${found.relativePath}`,
65
+ body
66
+ ].join('\n');
67
+ }
68
+
69
+ export function buildDefaultAgentsMd() {
70
+ return `# AGENTS.md
71
+
72
+ This file gives coding agents stable project instructions. Keep it short and use it as a map, not as full documentation.
73
+
74
+ ## Project
75
+
76
+ - Describe what this repository is and the main runtime or product surface.
77
+ - Note required runtime versions and package managers.
78
+
79
+ ## Commands
80
+
81
+ - Install: \`npm install\`
82
+ - Test: \`npm test\`
83
+ - Build: add the project build command here.
84
+
85
+ ## Task Routing
86
+
87
+ - CLI or command behavior: list the entry files here.
88
+ - Runtime behavior: list the core runtime files here.
89
+ - Web UI behavior: list the server, state, and component roots here.
90
+ - Tests: list focused test files for common changes.
91
+
92
+ ## Rules
93
+
94
+ - Use project/file indexes for orientation, then inspect real source files before editing.
95
+ - Keep generated output and build artifacts out of manual edits.
96
+ - Put reusable workflows in skills; put always-needed project facts and routing rules here.
97
+ `;
98
+ }
package/src/core/shell.js CHANGED
@@ -23,6 +23,74 @@ const READY_OUTPUT_PATTERNS = [
23
23
  /\bhttp:\/\/127\.0\.0\.1\b/i,
24
24
  /\bhttp:\/\/localhost\b/i
25
25
  ];
26
+ const INSTALL_COMMAND_PATTERNS = [
27
+ /\b(?:npm|pnpm|yarn|bun)\s+install\b/i,
28
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:ci|i|add)\b/i,
29
+ /\buv\s+pip\s+install\b/i,
30
+ /\bpip\s+install\b/i,
31
+ /\bcargo\s+install\b/i,
32
+ /\bbundle\s+install\b/i,
33
+ /\bcomposer\s+install\b/i
34
+ ];
35
+ const BUILD_COMMAND_RE = /\b(?:build|compile|bundle|pack|transpile)\b/i;
36
+ const TEST_COMMAND_PATTERNS = [
37
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|check|typecheck)\b/i,
38
+ /\b(?:jest|vitest|mocha|ava|pytest|go\s+test|cargo\s+test|dotnet\s+test)\b/i
39
+ ];
40
+ const FRONTEND_SERVICE_PATTERNS = [
41
+ /\bvite\b/i,
42
+ /\bnext\s+dev\b/i,
43
+ /\bnuxt\s+dev\b/i,
44
+ /\bastro\s+dev\b/i,
45
+ /\bremix\s+dev\b/i,
46
+ /\bsvelte-kit\s+dev\b/i,
47
+ /\bwebpack\s+serve\b/i,
48
+ /\bvue-cli-service\s+serve\b/i,
49
+ /\breact-scripts\s+start\b/i,
50
+ /\bstorybook\b/i,
51
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:client|frontend|front-end|web|ui)\b/i,
52
+ /\b(?:client|frontend|front-end|web|ui)\b.*\b(?:dev|start|serve|preview)\b/i
53
+ ];
54
+ const BACKEND_SERVICE_PATTERNS = [
55
+ /\bpython\s+-m\s+http\.server\b/i,
56
+ /\buvicorn\b/i,
57
+ /\bgunicorn\b/i,
58
+ /\bflask\s+run\b/i,
59
+ /\bdjango\s+runserver\b/i,
60
+ /\brails\s+(?:s|server)\b/i,
61
+ /\bmvn(?:w)?\s+spring-boot:run\b/i,
62
+ /\bgradle(?:w)?\s+bootRun\b/i,
63
+ /\bgradle(?:w)?\s+run\b/i,
64
+ /\bjava\b.*\bserver\b/i,
65
+ /\bdotnet\s+run\b/i,
66
+ /\bgo\s+run\b.*\b(server|cmd\/server|main\.go)\b/i,
67
+ /\bnest\s+start\b/i,
68
+ /\bnodemon\b/i,
69
+ /\bts-node-dev\b/i,
70
+ /\bair\b/i,
71
+ /\bphp\s+artisan\s+serve\b/i,
72
+ /\bsymfony\s+server:start\b/i,
73
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:server|api|backend)\b/i,
74
+ /\b(?:server|api|backend)\b.*\b(?:dev|start|serve|preview)\b/i
75
+ ];
76
+ const DATABASE_SERVICE_PATTERNS = [
77
+ /\bpostgres(?:ql)?\b/i,
78
+ /\bmysql\b/i,
79
+ /\bmariadb\b/i,
80
+ /\bmongod\b/i,
81
+ /\bredis-server\b/i,
82
+ /\b(?:docker|docker-compose|docker compose)\s+.*\b(?:db|database|postgres|mysql|mongo|redis)\b/i,
83
+ /\b(?:db|database|postgres|mysql|mongo|redis)\b.*\b(?:start|up|serve|run)\b/i
84
+ ];
85
+ const DOCKER_SERVICE_PATTERNS = [
86
+ /\bdocker\s+compose\s+up\b/i,
87
+ /\bdocker-compose\s+up\b/i,
88
+ /\bdocker\s+run\b/i,
89
+ /\bdocker\s+start\b/i
90
+ ];
91
+ const PACKAGE_SERVICE_RE = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b/i;
92
+ const VITE_OR_SERVE_RE = /\b(?:vite|serve)\b/i;
93
+ const SERVICE_HINT_RE = /\b(?:watch|serve|server|dev|preview)\b/i;
26
94
  const AUTO_STOP_GRACE_MS = 150;
27
95
  const LONG_RUNNING_STARTUP_WINDOW_MS = 1500;
28
96
 
@@ -41,105 +109,43 @@ export function classifyCommandIntent(command) {
41
109
  return { kind: 'generic', longRunning: false };
42
110
  }
43
111
 
44
- if (
45
- /\b(?:npm|pnpm|yarn|bun)\s+install\b/i.test(value) ||
46
- /\b(?:npm|pnpm|yarn|bun)\s+(?:ci|i|add)\b/i.test(value) ||
47
- /\buv\s+pip\s+install\b/i.test(value) ||
48
- /\bpip\s+install\b/i.test(value) ||
49
- /\bcargo\s+install\b/i.test(value) ||
50
- /\bbundle\s+install\b/i.test(value) ||
51
- /\bcomposer\s+install\b/i.test(value)
52
- ) {
112
+ if (matchesAny(value, INSTALL_COMMAND_PATTERNS)) {
53
113
  return { kind: 'install', longRunning: false };
54
114
  }
55
115
 
56
- if (/\b(?:build|compile|bundle|pack|transpile)\b/i.test(value)) {
116
+ if (BUILD_COMMAND_RE.test(value)) {
57
117
  return { kind: 'build', longRunning: false };
58
118
  }
59
119
 
60
- if (
61
- /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|check|typecheck)\b/i.test(value) ||
62
- /\b(?:jest|vitest|mocha|ava|pytest|go\s+test|cargo\s+test|dotnet\s+test)\b/i.test(value)
63
- ) {
120
+ if (matchesAny(value, TEST_COMMAND_PATTERNS)) {
64
121
  return { kind: 'test', longRunning: false };
65
122
  }
66
123
 
67
- const frontendServicePatterns = [
68
- /\bvite\b/i,
69
- /\bnext\s+dev\b/i,
70
- /\bnuxt\s+dev\b/i,
71
- /\bastro\s+dev\b/i,
72
- /\bremix\s+dev\b/i,
73
- /\bsvelte-kit\s+dev\b/i,
74
- /\bwebpack\s+serve\b/i,
75
- /\bvue-cli-service\s+serve\b/i,
76
- /\breact-scripts\s+start\b/i,
77
- /\bstorybook\b/i,
78
- /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:client|frontend|front-end|web|ui)\b/i,
79
- /\b(?:client|frontend|front-end|web|ui)\b.*\b(?:dev|start|serve|preview)\b/i
80
- ];
81
- if (matchesAny(value, frontendServicePatterns)) {
124
+ if (matchesAny(value, FRONTEND_SERVICE_PATTERNS)) {
82
125
  return { kind: 'frontend-service', longRunning: true };
83
126
  }
84
127
 
85
- const backendServicePatterns = [
86
- /\bpython\s+-m\s+http\.server\b/i,
87
- /\buvicorn\b/i,
88
- /\bgunicorn\b/i,
89
- /\bflask\s+run\b/i,
90
- /\bdjango\s+runserver\b/i,
91
- /\brails\s+(?:s|server)\b/i,
92
- /\bmvn(?:w)?\s+spring-boot:run\b/i,
93
- /\bgradle(?:w)?\s+bootRun\b/i,
94
- /\bgradle(?:w)?\s+run\b/i,
95
- /\bjava\b.*\bserver\b/i,
96
- /\bdotnet\s+run\b/i,
97
- /\bgo\s+run\b.*\b(server|cmd\/server|main\.go)\b/i,
98
- /\bnest\s+start\b/i,
99
- /\bnodemon\b/i,
100
- /\bts-node-dev\b/i,
101
- /\bair\b/i,
102
- /\bphp\s+artisan\s+serve\b/i,
103
- /\bsymfony\s+server:start\b/i,
104
- /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview)\b.*\b(?:server|api|backend)\b/i,
105
- /\b(?:server|api|backend)\b.*\b(?:dev|start|serve|preview)\b/i
106
- ];
107
- if (matchesAny(value, backendServicePatterns)) {
128
+ if (matchesAny(value, BACKEND_SERVICE_PATTERNS)) {
108
129
  return { kind: 'backend-service', longRunning: true };
109
130
  }
110
131
 
111
- const databaseServicePatterns = [
112
- /\bpostgres(?:ql)?\b/i,
113
- /\bmysql\b/i,
114
- /\bmariadb\b/i,
115
- /\bmongod\b/i,
116
- /\bredis-server\b/i,
117
- /\b(?:docker|docker-compose|docker compose)\s+.*\b(?:db|database|postgres|mysql|mongo|redis)\b/i,
118
- /\b(?:db|database|postgres|mysql|mongo|redis)\b.*\b(?:start|up|serve|run)\b/i
119
- ];
120
- if (matchesAny(value, databaseServicePatterns)) {
132
+ if (matchesAny(value, DATABASE_SERVICE_PATTERNS)) {
121
133
  return { kind: 'database-service', longRunning: true };
122
134
  }
123
135
 
124
- const dockerServicePatterns = [
125
- /\bdocker\s+compose\s+up\b/i,
126
- /\bdocker-compose\s+up\b/i,
127
- /\bdocker\s+run\b/i,
128
- /\bdocker\s+start\b/i
129
- ];
130
- if (matchesAny(value, dockerServicePatterns)) {
136
+ if (matchesAny(value, DOCKER_SERVICE_PATTERNS)) {
131
137
  return { kind: 'docker-service', longRunning: true };
132
138
  }
133
139
 
134
140
  if (
135
- /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b/i.test(value) ||
136
- /\b(?:vite|serve)\b/i.test(value) ||
137
- /\b(?:watch|serve|server|dev|preview)\b/i.test(value)
141
+ PACKAGE_SERVICE_RE.test(value) ||
142
+ VITE_OR_SERVE_RE.test(value) ||
143
+ SERVICE_HINT_RE.test(value)
138
144
  ) {
139
145
  return { kind: 'service', longRunning: true };
140
146
  }
141
147
 
142
- if (/\b(?:watch|serve|server|dev|preview)\b/i.test(value)) {
148
+ if (SERVICE_HINT_RE.test(value)) {
143
149
  return { kind: 'service', longRunning: true };
144
150
  }
145
151