483 lines
13 KiB
TypeScript
483 lines
13 KiB
TypeScript
import { lastCommandSegment } from "./splitCompositeCommand.ts";
|
||
|
||
export interface CommandDetectionResult {
|
||
type: string;
|
||
command: string | null;
|
||
confidence: number;
|
||
category:
|
||
| "git"
|
||
| "test"
|
||
| "build"
|
||
| "shell"
|
||
| "docker"
|
||
| "package"
|
||
| "infra"
|
||
| "cloud"
|
||
| "generic";
|
||
matchedPatterns: string[];
|
||
}
|
||
|
||
type Detector = {
|
||
type: string;
|
||
category: CommandDetectionResult["category"];
|
||
commandPatterns: RegExp[];
|
||
contentPatterns: RegExp[];
|
||
};
|
||
|
||
const COMMAND_PREFIXES = [
|
||
"git",
|
||
"make",
|
||
"gradle",
|
||
"gradlew",
|
||
"dotnet",
|
||
"terraform",
|
||
"tofu",
|
||
"opentofu",
|
||
"systemctl",
|
||
"npm",
|
||
"pnpm",
|
||
"yarn",
|
||
"vitest",
|
||
"jest",
|
||
"pytest",
|
||
"python",
|
||
"go",
|
||
"cargo",
|
||
"tsc",
|
||
"eslint",
|
||
"webpack",
|
||
"vite",
|
||
"biome",
|
||
"prettier",
|
||
"turbo",
|
||
"nx",
|
||
"playwright",
|
||
"ruff",
|
||
"mypy",
|
||
"pip",
|
||
"uv",
|
||
"poetry",
|
||
"golangci-lint",
|
||
"bundle",
|
||
"rubocop",
|
||
"kubectl",
|
||
"composer",
|
||
"gh",
|
||
"docker",
|
||
"aws",
|
||
"gcloud",
|
||
"ssh",
|
||
"rsync",
|
||
"curl",
|
||
"wget",
|
||
"ls",
|
||
"find",
|
||
"grep",
|
||
"rg",
|
||
"ag",
|
||
"ps",
|
||
"df",
|
||
"du",
|
||
];
|
||
|
||
const COMMAND_PREFIX_PATTERN = new RegExp(`^(?:${COMMAND_PREFIXES.join("|")})\\b`);
|
||
|
||
const DETECTORS: Detector[] = [
|
||
{
|
||
type: "git-status",
|
||
category: "git",
|
||
commandPatterns: [/^git\s+status\b/i],
|
||
contentPatterns: [
|
||
/^On branch /m,
|
||
/^Changes (?:not staged|to be committed)/m,
|
||
/^Untracked files:/m,
|
||
],
|
||
},
|
||
{
|
||
type: "git-branch",
|
||
category: "git",
|
||
commandPatterns: [/^git\s+branch\b/i, /^git\s+checkout\b/i, /^git\s+switch\b/i],
|
||
contentPatterns: [/^\*\s+\S+/m, /Switched to (?:a new )?branch/i, /Already on ['"][^'"]+['"]/i],
|
||
},
|
||
{
|
||
type: "git-diff",
|
||
category: "git",
|
||
commandPatterns: [/^git\s+diff\b/i, /^git\s+show\b/i],
|
||
contentPatterns: [/^diff --git /m, /^@@\s+-\d+,\d+\s+\+\d+,\d+\s+@@/m],
|
||
},
|
||
{
|
||
type: "git-log",
|
||
category: "git",
|
||
commandPatterns: [/^git\s+log\b/i],
|
||
contentPatterns: [/^commit [0-9a-f]{7,40}/m, /^Author: /m],
|
||
},
|
||
{
|
||
type: "make",
|
||
category: "build",
|
||
commandPatterns: [/^make\b/i],
|
||
contentPatterns: [/^make\[\d+\]: (?:Entering|Leaving) directory/m, /make: \*\*\* /],
|
||
},
|
||
{
|
||
type: "gradle",
|
||
category: "build",
|
||
commandPatterns: [/^(?:gradle|gradlew|\.\/gradlew)\b/i],
|
||
contentPatterns: [/^> Task :/m, /^BUILD (?:SUCCESSFUL|FAILED)\b/m],
|
||
},
|
||
{
|
||
type: "dotnet",
|
||
category: "build",
|
||
commandPatterns: [/^dotnet\s+(?:build|test|run|restore|publish|pack|msbuild)\b/i, /^dotnet\b/i],
|
||
contentPatterns: [/^Build (?:succeeded|FAILED)\b/m, /\b(?:error|warning) CS\d+\b/m],
|
||
},
|
||
{
|
||
type: "terraform-plan",
|
||
category: "infra",
|
||
commandPatterns: [/^terraform\s+plan\b/i],
|
||
contentPatterns: [/Terraform will perform the following actions:/, /Plan: \d+ to add/i],
|
||
},
|
||
{
|
||
type: "tofu-plan",
|
||
category: "infra",
|
||
commandPatterns: [/^(?:tofu|opentofu)\s+plan\b/i],
|
||
contentPatterns: [/OpenTofu will perform the following actions:/, /Plan: \d+ to add/i],
|
||
},
|
||
{
|
||
type: "systemctl-status",
|
||
category: "infra",
|
||
commandPatterns: [/^systemctl\s+status\b/i],
|
||
contentPatterns: [/^\s*Loaded:\s+/m, /^\s*Active:\s+/m, /^●\s+\S+\.service/m],
|
||
},
|
||
{
|
||
type: "test-vitest",
|
||
category: "test",
|
||
commandPatterns: [/^vitest\b/i, /^npm\s+(?:run\s+)?test:vitest\b/i],
|
||
contentPatterns: [/\bvitest\b/i, /^ ✓ /m, /^ ❯ /m, /Test Files\s+\d+\s+(?:passed|failed)/i],
|
||
},
|
||
{
|
||
type: "test-jest",
|
||
category: "test",
|
||
commandPatterns: [/^jest\b/i, /^npm\s+(?:run\s+)?test\b/i],
|
||
contentPatterns: [/Test Suites:\s+\d+/i, /Tests:\s+\d+/i, /^PASS\s+/m, /^FAIL\s+/m],
|
||
},
|
||
{
|
||
type: "test-pytest",
|
||
category: "test",
|
||
commandPatterns: [/^pytest\b/i, /^python\s+-m\s+pytest\b/i],
|
||
contentPatterns: [/=+\s+(?:\d+\s+)?(?:passed|failed|errors?)/i, /^E\s+/m, /^FAILED /m],
|
||
},
|
||
{
|
||
type: "test-cargo",
|
||
category: "test",
|
||
commandPatterns: [/^cargo\s+test\b/i, /^cargo\s+nextest\b/i],
|
||
contentPatterns: [
|
||
/^running \d+ tests?/m,
|
||
/^test\s+[\w:.-]+\s+\.\.\.\s+(?:ok|FAILED|ignored)/m,
|
||
/test result:\s+(?:ok|FAILED)/i,
|
||
],
|
||
},
|
||
{
|
||
type: "test-go",
|
||
category: "test",
|
||
commandPatterns: [/^go\s+test\b/i],
|
||
contentPatterns: [/^(?:ok|FAIL)\s+[\w./-]+\s+[\d.]+s/m, /^--- FAIL: /m, /^panic: /m],
|
||
},
|
||
{
|
||
type: "build-typescript",
|
||
category: "build",
|
||
commandPatterns: [/^tsc\b/i, /^npm\s+run\s+typecheck\b/i],
|
||
contentPatterns: [/TS\d{4}:/, /error TS\d{4}/i],
|
||
},
|
||
{
|
||
type: "build-eslint",
|
||
category: "build",
|
||
commandPatterns: [/^eslint\b/i, /^npm\s+run\s+lint\b/i],
|
||
contentPatterns: [/\s+\d+:\d+\s+(?:error|warning)\s+/, /✖\s+\d+\s+problems?/],
|
||
},
|
||
{
|
||
type: "build-webpack",
|
||
category: "build",
|
||
commandPatterns: [/^webpack\b/i, /^npx\s+webpack\b/i, /^npm\s+run\s+build:webpack\b/i],
|
||
contentPatterns: [
|
||
/webpack\s+\d/i,
|
||
/compiled (?:successfully|with \d+ errors?)/i,
|
||
/asset .+\.js/i,
|
||
],
|
||
},
|
||
{
|
||
type: "build-vite",
|
||
category: "build",
|
||
commandPatterns: [/^vite\s+build\b/i, /^npm\s+run\s+build\b/i, /^pnpm\s+build\b/i],
|
||
contentPatterns: [/vite v[\d.]+/i, /✓ built in/i, /transforming \(\d+\)/i],
|
||
},
|
||
{
|
||
type: "biome",
|
||
category: "build",
|
||
commandPatterns: [/^biome\b/i, /^npx\s+biome\b/i],
|
||
contentPatterns: [/lint\/[A-Za-z0-9/.-]+/, /Checked \d+ files? in/i],
|
||
},
|
||
{
|
||
type: "prettier",
|
||
category: "build",
|
||
commandPatterns: [/^prettier\b/i, /^npx\s+prettier\b/i],
|
||
contentPatterns: [/^Checking formatting\.\.\./m, /Code style issues found/i],
|
||
},
|
||
{
|
||
type: "turbo",
|
||
category: "build",
|
||
commandPatterns: [/^turbo\b/i, /^npx\s+turbo\b/i],
|
||
contentPatterns: [/^• Packages in scope:/m, /^Tasks:\s+\d+\s+successful/m],
|
||
},
|
||
{
|
||
type: "nx",
|
||
category: "build",
|
||
commandPatterns: [/^nx\b/i, /^npx\s+nx\b/i],
|
||
contentPatterns: [/^NX\s+/m, /^> nx run /m],
|
||
},
|
||
{
|
||
type: "playwright",
|
||
category: "test",
|
||
commandPatterns: [/^playwright\s+test\b/i, /^npx\s+playwright\s+test\b/i],
|
||
contentPatterns: [/Running \d+ tests? using \d+ workers?/i, /^\s+\d+ failed/m],
|
||
},
|
||
{
|
||
type: "npm-install",
|
||
category: "package",
|
||
commandPatterns: [/^(?:npm|pnpm|yarn)\s+(?:install|add|update)\b/i],
|
||
contentPatterns: [
|
||
/added \d+ packages/i,
|
||
/packages are looking for funding/i,
|
||
/audited \d+ packages/i,
|
||
],
|
||
},
|
||
{
|
||
type: "npm-audit",
|
||
category: "package",
|
||
commandPatterns: [/^(?:npm|pnpm|yarn)\s+audit\b/i],
|
||
contentPatterns: [/found \d+ vulnerabilities/i, /\b(?:low|moderate|high|critical)\b/i],
|
||
},
|
||
{
|
||
type: "ruff",
|
||
category: "build",
|
||
commandPatterns: [/^ruff\b/i, /^uv\s+run\s+ruff\b/i],
|
||
contentPatterns: [/^[\w./-]+\.py:\d+:\d+:\s+[A-Z]\d+/m, /Found \d+ errors?\./i],
|
||
},
|
||
{
|
||
type: "mypy",
|
||
category: "build",
|
||
commandPatterns: [/^mypy\b/i, /^python\s+-m\s+mypy\b/i],
|
||
contentPatterns: [/^[\w./-]+\.py:\d+:\s+error:/m, /Found \d+ errors? in \d+ files?/i],
|
||
},
|
||
{
|
||
type: "pip",
|
||
category: "package",
|
||
commandPatterns: [/^pip\s+(?:install|download|uninstall)\b/i, /^python\s+-m\s+pip\b/i],
|
||
contentPatterns: [/^Collecting /m, /^Successfully installed /m],
|
||
},
|
||
{
|
||
type: "uv-sync",
|
||
category: "package",
|
||
commandPatterns: [/^uv\s+sync\b/i, /^uv\s+pip\s+install\b/i],
|
||
contentPatterns: [/^Resolved \d+ packages?/m, /^Installed \d+ packages?/m],
|
||
},
|
||
{
|
||
type: "poetry-install",
|
||
category: "package",
|
||
commandPatterns: [/^poetry\s+install\b/i],
|
||
contentPatterns: [/^Installing dependencies from lock file/m, /^Package operations:/m],
|
||
},
|
||
{
|
||
type: "golangci-lint",
|
||
category: "build",
|
||
commandPatterns: [/^golangci-lint\b/i],
|
||
contentPatterns: [/^[\w./-]+\.go:\d+:\d+:/m, /^\d+ issues?:/m],
|
||
},
|
||
{
|
||
type: "bundle-install",
|
||
category: "package",
|
||
commandPatterns: [/^bundle\s+install\b/i],
|
||
contentPatterns: [/^Fetching gem metadata from /m, /^Bundle complete!/m],
|
||
},
|
||
{
|
||
type: "rubocop",
|
||
category: "build",
|
||
commandPatterns: [/^rubocop\b/i, /^bundle\s+exec\s+rubocop\b/i],
|
||
contentPatterns: [/^Inspecting \d+ files/m, /^[\w./-]+\.rb:\d+:\d+:\s+[A-Z]:/m],
|
||
},
|
||
{
|
||
type: "docker-ps",
|
||
category: "docker",
|
||
commandPatterns: [/^docker\s+ps\b/i],
|
||
contentPatterns: [/^CONTAINER ID\s+IMAGE\s+COMMAND/m],
|
||
},
|
||
{
|
||
type: "docker-logs",
|
||
category: "docker",
|
||
commandPatterns: [/^docker\s+logs\b/i, /^docker\s+compose\s+logs\b/i],
|
||
contentPatterns: [
|
||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/m,
|
||
/\b(?:ERROR|WARN|INFO)\b/,
|
||
/^Attaching to /m,
|
||
],
|
||
},
|
||
{
|
||
type: "aws",
|
||
category: "cloud",
|
||
commandPatterns: [/^aws\b/i],
|
||
contentPatterns: [/An error occurred \([A-Za-z0-9]+\) when calling/, /^(?:upload|download): /m],
|
||
},
|
||
{
|
||
type: "gcloud",
|
||
category: "cloud",
|
||
commandPatterns: [/^gcloud\b/i],
|
||
contentPatterns: [/^ERROR: \(gcloud\./m, /^Updated property \[/m],
|
||
},
|
||
{
|
||
type: "ssh",
|
||
category: "cloud",
|
||
commandPatterns: [/^ssh\b/i],
|
||
contentPatterns: [
|
||
/Permission denied \(/,
|
||
/Host key verification failed/,
|
||
/Connection timed out/,
|
||
],
|
||
},
|
||
{
|
||
type: "rsync",
|
||
category: "cloud",
|
||
commandPatterns: [/^rsync\b/i],
|
||
contentPatterns: [/^sending incremental file list/m, /^rsync error:/m],
|
||
},
|
||
{
|
||
type: "curl",
|
||
category: "cloud",
|
||
commandPatterns: [/^curl\b/i],
|
||
contentPatterns: [/curl: \(\d+\)/, /^HTTP\/\d(?:\.\d)? \d{3}/m],
|
||
},
|
||
{
|
||
type: "wget",
|
||
category: "cloud",
|
||
commandPatterns: [/^wget\b/i],
|
||
contentPatterns: [/^--\d{4}-\d{2}-\d{2}/m, /^ERROR \d{3}:/m],
|
||
},
|
||
{
|
||
type: "json-output",
|
||
category: "generic",
|
||
commandPatterns: [/^jq\b/i, /^cat\s+.*\.json\b/i],
|
||
contentPatterns: [/^\s*[\[{][\s\S]*[\]}]\s*$/],
|
||
},
|
||
{
|
||
type: "shell-ls",
|
||
category: "shell",
|
||
commandPatterns: [/^ls(?:\s+-[A-Za-z]+)?\b/i],
|
||
contentPatterns: [/^total \d+/m, /^\S+\s+\S+\s+\d+\s+\w+\s+\d{1,2}\s+/m],
|
||
},
|
||
{
|
||
type: "shell-find",
|
||
category: "shell",
|
||
commandPatterns: [/^find\b/i],
|
||
contentPatterns: [/^(?:\.{1,2}|\/|[\w.-]+\/).+/m],
|
||
},
|
||
{
|
||
type: "shell-grep",
|
||
category: "shell",
|
||
commandPatterns: [/^(?:grep|rg|ag)\b/i],
|
||
contentPatterns: [
|
||
/^[\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|txt):\d*:/m,
|
||
/^[\w./-]+\/[\w./-]+:\d*:/m,
|
||
],
|
||
},
|
||
{
|
||
type: "shell-ps",
|
||
category: "shell",
|
||
commandPatterns: [/^ps\b/i],
|
||
contentPatterns: [/^(?:USER\s+PID|\s*PID\s+)/m],
|
||
},
|
||
{
|
||
type: "shell-df",
|
||
category: "shell",
|
||
commandPatterns: [/^df\b/i],
|
||
contentPatterns: [/^Filesystem\s+.*Use%/m],
|
||
},
|
||
{
|
||
type: "shell-du",
|
||
category: "shell",
|
||
commandPatterns: [/^du\b/i],
|
||
contentPatterns: [/^\d+(?:\.\d+)?[KMGTP]?\s+\S+/m],
|
||
},
|
||
{
|
||
type: "error-stacktrace",
|
||
category: "generic",
|
||
commandPatterns: [],
|
||
contentPatterns: [
|
||
/Traceback \(most recent call last\):/,
|
||
/^\s+at\s+\S+\s+\(.+:\d+:\d+\)/m,
|
||
/^panic: /m,
|
||
/^thread '[^']+' panicked at/m,
|
||
],
|
||
},
|
||
{
|
||
type: "generic-error",
|
||
category: "generic",
|
||
commandPatterns: [],
|
||
contentPatterns: [/Error:/, /Exception:/, /Traceback \(most recent call last\):/],
|
||
},
|
||
];
|
||
|
||
export function detectCommandFromText(text: string): string | null {
|
||
const firstLines = text.split(/\r?\n/).slice(0, 4);
|
||
for (const line of firstLines) {
|
||
const trimmed = line.trim().replace(/^\$\s+/, "");
|
||
if (!trimmed) continue;
|
||
if (COMMAND_PREFIX_PATTERN.test(trimmed)) {
|
||
return lastCommandSegment(trimmed);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function detectCommandType(text: string, command?: string | null): CommandDetectionResult {
|
||
const detectedCommand =
|
||
lastCommandSegment(command?.trim() || detectCommandFromText(text) || "") || null;
|
||
let best: CommandDetectionResult | null = null;
|
||
|
||
for (const detector of DETECTORS) {
|
||
const matchedPatterns: string[] = [];
|
||
const commandMatched =
|
||
Boolean(detectedCommand) &&
|
||
detector.commandPatterns.some((pattern) => {
|
||
const matched = pattern.test(detectedCommand ?? "");
|
||
if (matched) matchedPatterns.push(pattern.source);
|
||
return matched;
|
||
});
|
||
const contentMatches = detector.contentPatterns.filter((pattern) => {
|
||
const matched = pattern.test(text);
|
||
if (matched) matchedPatterns.push(pattern.source);
|
||
return matched;
|
||
}).length;
|
||
if (!commandMatched && contentMatches === 0) continue;
|
||
|
||
const confidence = Math.min(1, (commandMatched ? 0.55 : 0) + contentMatches * 0.25);
|
||
if (!best || confidence > best.confidence) {
|
||
best = {
|
||
type: detector.type,
|
||
command: detectedCommand,
|
||
confidence,
|
||
category: detector.category,
|
||
matchedPatterns,
|
||
};
|
||
}
|
||
}
|
||
|
||
return (
|
||
best ?? {
|
||
type: "unknown",
|
||
command: detectedCommand,
|
||
confidence: detectedCommand ? 0.35 : 0.1,
|
||
category: "generic",
|
||
matchedPatterns: [],
|
||
}
|
||
);
|
||
}
|
||
|
||
export const detectCommandOutput = detectCommandType;
|