From e82a733e64f92501e5538b6885ac1b96d01d5f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:58:52 +0000 Subject: [PATCH 1/2] fix: sync Cargo.lock with crate version 0.4.1 https://claude.ai/code/session_01R7u5FuNixEbMNQiFGBBx3H --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6a82c27..f33066f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agenthint" -version = "0.4.0" +version = "0.4.1" dependencies = [ "serde_json", ] From 169555e3b96da05b4f0b9f2b70cde23a3b7f1785 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 22:08:34 +0000 Subject: [PATCH 2/2] fix: align detection and init behavior across implementations - Resolve equal-confidence ties to the earliest rule in all languages (Rust previously picked the last match) - Deduplicate signals when a variable matches both an exact rule and a prefix rule - Sort prefix-matched signal names in TypeScript and Python to match Rust - Include env:CLAUDE_CODE_IS_COWORK in signals when it classifies cowork - Reject option-like names in 'agenthint init' consistently (exit 2) - Share one AI_AGENT normalizer between detection and init in TypeScript - Use parent_id() instead of spawning ps to find the Rust parent pid - Cache detection rules in the Python library - Compare CLI fixture stdoutContains literally in the Node test - Build and install linux-arm64 native binaries - Guard CI against a stale Cargo.lock and update release-please to keep it in sync; pin Python in mise - Document AGENTHINT_AGENT, ordering/tie rules, REPL_ID confidence, and the Windows parent-process limitation https://claude.ai/code/session_01R7u5FuNixEbMNQiFGBBx3H --- .github/release-please-config.json | 1 + .github/workflows/ci.yml | 3 + .github/workflows/native-binaries.yml | 5 ++ .mise.toml | 2 +- README.md | 4 +- SPEC.md | 15 +++- crates/agenthint/src/lib.rs | 110 +++++++++++++++----------- crates/agenthint/src/main.rs | 14 ++-- docs/signals.md | 6 +- fixtures/cli-cases.json | 6 ++ fixtures/detection-cases.json | 26 +++++- install.sh | 1 + python/agenthint/__init__.py | 16 +++- python/agenthint/cli.py | 2 +- src/agent-names.ts | 35 ++++++++ src/cli.ts | 6 +- src/index.ts | 66 ++++------------ src/init.ts | 20 +---- test/cli.test.mjs | 2 +- 19 files changed, 204 insertions(+), 136 deletions(-) create mode 100644 src/agent-names.ts diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 3112a9d..5c4dc99 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "plugins": ["cargo-workspace"], "packages": { ".": { "release-type": "node", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cc33f2..34166bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: Install npm dependencies run: npm ci + - name: Check Cargo.lock is current + run: cargo metadata --locked --format-version 1 > /dev/null + - name: Run checks run: npm run check diff --git a/.github/workflows/native-binaries.yml b/.github/workflows/native-binaries.yml index 0467ee3..3feded0 100644 --- a/.github/workflows/native-binaries.yml +++ b/.github/workflows/native-binaries.yml @@ -31,6 +31,11 @@ jobs: binary: agenthint asset: agenthint-linux-x64 target: "" + - name: linux-arm64 + os: ubuntu-24.04-arm + binary: agenthint + asset: agenthint-linux-arm64 + target: "" - name: macos-arm64 os: macos-latest binary: agenthint diff --git a/.mise.toml b/.mise.toml index 7c34a7f..71e8dc4 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,4 +1,4 @@ [tools] node = "lts" rust = "stable" - +python = "3.12" diff --git a/README.md b/README.md index 5ccb76e..1ed417f 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Every detection result includes: Detection priority: 1. `AGENTHINT_DISABLE` -2. `AGENTHINT_FORCE` +2. `AGENTHINT_FORCE` (optionally named via `AGENTHINT_AGENT`) 3. Explicit `AI_AGENT` 4. Known environment signals 5. Documented filesystem signals @@ -193,6 +193,8 @@ Detection priority: Known agents include Codex, Claude Code, Cursor, Gemini CLI, Aider, Augment CLI, AMP, OpenCode, OpenClaw, GitHub Copilot, Replit, Devin, Google Antigravity, Pi, Kiro CLI, Windsurf, Cline, Roo Code, Kilo Code, Mistral Vibe, v0, and Cowork. +Some signals are weak. For example `REPL_ID` is present in every Replit workspace, including human-driven sessions, so it reports a low confidence. Exit-code consumers that want to avoid false positives can read `confidence` from `agenthint --json` and apply their own threshold. + Custom agents are supported through any non-empty `AI_AGENT` value. ## Docs diff --git a/SPEC.md b/SPEC.md index 91747ed..6043e3a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,6 +39,18 @@ AI_AGENT=my-custom-agent my-tool `AI_AGENT` should be checked before heuristic signals. Empty and whitespace-only values should be ignored. +## Override Conventions + +`AGENTHINT_DISABLE` forces a no-agent result and `AGENTHINT_FORCE` forces an agent result; both take precedence over `AI_AGENT` and heuristics. When `AGENTHINT_FORCE` is set, `AGENTHINT_AGENT` optionally names the forced agent; otherwise the agent is `unknown`. Truthy values are `1`, `true`, `yes`, and `on`. + +## Signal Ordering and Ties + +To keep results portable across implementations: + +- Heuristic matches report every matching signal, deduplicated, in rule-registry order. +- Signal names matched by an environment prefix rule are sorted lexicographically. +- When multiple heuristic rules match with equal confidence, the earliest rule in the registry wins. + ## Parent Process Signals Implementations may inspect the direct parent process name as a low-confidence heuristic. Parent process checks should be configurable because process names can be unavailable, ambiguous, platform-specific, or controlled by wrappers. @@ -80,11 +92,12 @@ Initial candidates: - `KILOCODE_AGENT` - `OPENCLAW_AGENT` - `AGENTHINT_FORCE` +- `AGENTHINT_AGENT` - `AGENTHINT_DISABLE` Implementations must not print environment variable values by default. Signal names are enough for diagnostics. -`CLAUDE_CODE_IS_COWORK` is a classifier only. It may select `cowork` when another Claude signal is present, but should not be treated as an agent signal by itself. +`CLAUDE_CODE_IS_COWORK` is a classifier only. It may select `cowork` when another Claude signal is present, but should not be treated as an agent signal by itself. When it does select `cowork`, `env:CLAUDE_CODE_IS_COWORK` is included in `signals` to keep the classification explainable. Known agent names without stable heuristic signals should still be supported through `AI_AGENT`. Current explicit-only known names: diff --git a/crates/agenthint/src/lib.rs b/crates/agenthint/src/lib.rs index 6a23721..c3a0fe2 100644 --- a/crates/agenthint/src/lib.rs +++ b/crates/agenthint/src/lib.rs @@ -78,18 +78,25 @@ pub fn detect_agent_with_options(options: DetectAgentOptions) -> AgentHintResult } let matches = detection_matches(&options.env); - if let Some(best) = matches - .iter() - .max_by(|left, right| left.confidence.total_cmp(&right.confidence)) - { + if let Some(best) = matches.iter().reduce(|best, current| { + if current.confidence > best.confidence { + current + } else { + best + } + }) { + let mut signals: Vec = Vec::new(); + for signal in matches.iter().flat_map(|agent_match| &agent_match.signals) { + if !signals.contains(signal) { + signals.push(signal.clone()); + } + } + return AgentHintResult { is_agent: true, agent: Some(best.agent.clone()), confidence: best.confidence, - signals: matches - .iter() - .flat_map(|agent_match| agent_match.signals.clone()) - .collect(), + signals, }; } @@ -219,7 +226,9 @@ fn doctor_setup_json(result: &AgentHintResult) -> String { } pub fn format_init(agent: Option<&str>) -> String { - let Some(agent) = normalize_agent_name(agent.map(|value| value.to_string()).as_ref()) else { + let Some(agent) = normalize_agent_name(agent.map(|value| value.to_string()).as_ref()) + .filter(|agent| !agent.starts_with('-')) + else { return [ "agenthint init", "", @@ -293,15 +302,25 @@ fn detection_matches(env: &HashMap) -> Vec { let mut matches = Vec::new(); for rule in generated_rules::ENVIRONMENT_RULES { - let agent = if rule.agent == "claude-code" - && !present(env, &["CLAUDE_CODE_IS_COWORK"]).is_empty() - { - "cowork" - } else { - rule.agent - }; + let mut signals = present(env, rule.names); + if signals.is_empty() { + continue; + } - push_present(&mut matches, env, agent, rule.confidence, rule.names); + let mut agent = rule.agent; + if rule.agent == "claude-code" { + let cowork_signals = present(env, &["CLAUDE_CODE_IS_COWORK"]); + if !cowork_signals.is_empty() { + agent = "cowork"; + signals.extend(cowork_signals); + } + } + + matches.push(AgentMatch { + agent: agent.to_string(), + confidence: rule.confidence, + signals, + }); } for rule in generated_rules::PREFIX_RULES { @@ -332,22 +351,13 @@ fn from_parent_process(options: &DetectAgentOptions) -> Option } fn parent_process_name() -> Option { - let ppid_output = Command::new("ps") - .args(["-o", "ppid=", "-p", &std::process::id().to_string()]) - .output() - .ok()?; - - if !ppid_output.status.success() { - return None; - } - - let ppid = String::from_utf8(ppid_output.stdout).ok()?; - let ppid = ppid.trim(); + let ppid = parent_pid()?; - if ppid.is_empty() { + if ppid == 0 { return None; } + let ppid = ppid.to_string(); let proc_path = format!("/proc/{ppid}/comm"); if let Ok(value) = std::fs::read_to_string(proc_path) { @@ -358,7 +368,7 @@ fn parent_process_name() -> Option { } let output = Command::new("ps") - .args(["-o", "comm=", "-p", ppid]) + .args(["-o", "comm=", "-p", ppid.as_str()]) .output() .ok()?; @@ -376,6 +386,16 @@ fn parent_process_name() -> Option { } } +#[cfg(unix)] +fn parent_pid() -> Option { + Some(std::os::unix::process::parent_id()) +} + +#[cfg(not(unix))] +fn parent_pid() -> Option { + None +} + fn normalize_process_name(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -395,23 +415,6 @@ fn agent_from_process_name(name: &str) -> Option<&'static str> { .map(|rule| rule.agent) } -fn push_present( - matches: &mut Vec, - env: &HashMap, - agent: &str, - confidence: f32, - names: &[&str], -) { - let signals = present(env, names); - if !signals.is_empty() { - matches.push(AgentMatch { - agent: agent.to_string(), - confidence, - signals, - }); - } -} - fn push_prefix( matches: &mut Vec, env: &HashMap, @@ -755,9 +758,21 @@ mod tests { let not_cowork = detect(env(&[("CLAUDE_CODE_IS_COWORK", "1")])); assert_eq!(cowork.agent.as_deref(), Some("cowork")); + assert_eq!( + cowork.signals, + vec!["env:CLAUDE_CODE", "env:CLAUDE_CODE_IS_COWORK"] + ); assert!(!not_cowork.is_agent); } + #[test] + fn equal_confidence_ties_prefer_the_earlier_rule() { + let result = detect(env(&[("GEMINI_CLI", "1"), ("CURSOR_AGENT", "1")])); + + assert_eq!(result.agent.as_deref(), Some("cursor")); + assert_eq!(result.signals, vec!["env:CURSOR_AGENT", "env:GEMINI_CLI"]); + } + #[test] fn supports_force_and_disable() { let forced = detect(env(&[ @@ -835,5 +850,6 @@ mod tests { assert!(format_init(Some("codex")).contains("AI_AGENT=codex")); assert!(format_init(Some("roo")).contains("AI_AGENT=roo-code")); assert!(format_init(None).contains("agenthint init ")); + assert!(format_init(Some("--help")).contains("agenthint init ")); } } diff --git a/crates/agenthint/src/main.rs b/crates/agenthint/src/main.rs index 297b4ea..f66bed6 100644 --- a/crates/agenthint/src/main.rs +++ b/crates/agenthint/src/main.rs @@ -5,17 +5,13 @@ use agenthint::{ fn main() { let args = std::env::args().skip(1).collect::>(); - if args.iter().any(|arg| arg == "-h" || arg == "--help") { - if args.len() == 1 { - print_help(); - std::process::exit(0); - } - - print_usage_error(&format!("invalid usage: {}", args.join(" "))); + if args.len() == 1 && (args[0] == "-h" || args[0] == "--help") { + print_help(); + std::process::exit(0); } - if let Some(init_index) = args.iter().position(|arg| arg == "init") { - if init_index != 0 || args.len() != 2 || args[1].trim().is_empty() { + if args.first().is_some_and(|arg| arg == "init") { + if args.len() != 2 || args[1].trim().is_empty() || args[1].starts_with('-') { print_usage_error(&format_init(None)); } diff --git a/docs/signals.md b/docs/signals.md index 8fd5469..329017c 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -45,7 +45,9 @@ Detection is advisory. Signals describe why `agenthint` returned a result; they | `env:KILOCODE_AGENT` | `kilocode` | `0.82` | heuristic | | `env:OPENCLAW_AGENT` | `openclaw` | `0.82` | heuristic | -`CLAUDE_CODE_IS_COWORK` is only a classifier. It selects `cowork` when another Claude signal is present; it is not an agent signal by itself. +`CLAUDE_CODE_IS_COWORK` is only a classifier. It selects `cowork` when another Claude signal is present; it is not an agent signal by itself. When it selects `cowork`, `env:CLAUDE_CODE_IS_COWORK` is included in `signals` so the classification stays explainable. + +`REPL_ID` is present in every Replit workspace, including human-driven sessions, which is why it reports a low `0.65` confidence. Exit-code consumers that want to avoid false positives can read `confidence` from `agenthint --json` and apply their own threshold. ## Filesystem Heuristics @@ -69,6 +71,8 @@ Detection is advisory. Signals describe why `agenthint` returned a result; they Parent process signals report normalized executable names only, not full paths. +Parent process detection reads `/proc` or `ps`, so it is effectively unavailable on Windows unless a parent process name is supplied explicitly through library options. + ## Stdio Hints | Signal | Agent | Confidence | Type | Notes | diff --git a/fixtures/cli-cases.json b/fixtures/cli-cases.json index 986fe24..11e9ac9 100644 --- a/fixtures/cli-cases.json +++ b/fixtures/cli-cases.json @@ -46,5 +46,11 @@ "env": {}, "status": 0, "stdout": "AI_AGENT=roo-code\n\nUse this value in the environment used for agent tool calls.\n" + }, + { + "name": "init rejects option-like names", + "args": ["init", "--json"], + "env": {}, + "status": 2 } ] diff --git a/fixtures/detection-cases.json b/fixtures/detection-cases.json index c7c0b44..bbdf151 100644 --- a/fixtures/detection-cases.json +++ b/fixtures/detection-cases.json @@ -53,7 +53,7 @@ "isAgent": true, "agent": "cowork", "confidence": 0.9, - "signals": ["env:CLAUDE_CODE"] + "signals": ["env:CLAUDE_CODE", "env:CLAUDE_CODE_IS_COWORK"] }, { "name": "aider prefix", @@ -63,6 +63,30 @@ "confidence": 0.86, "signals": ["env:AIDER_MODEL"] }, + { + "name": "prefix signals are sorted", + "env": { "AIDER_ZZZ": "1", "AIDER_MODEL": "sonnet", "AIDER_AAA": "1" }, + "isAgent": true, + "agent": "aider", + "confidence": 0.86, + "signals": ["env:AIDER_AAA", "env:AIDER_MODEL", "env:AIDER_ZZZ"] + }, + { + "name": "exact and prefix matches are deduplicated", + "env": { "CURSOR_AGENT": "1", "CURSOR_TRACE_ID": "trace" }, + "isAgent": true, + "agent": "cursor", + "confidence": 0.92, + "signals": ["env:CURSOR_AGENT", "env:CURSOR_TRACE_ID"] + }, + { + "name": "equal confidence tie prefers the earlier rule", + "env": { "GEMINI_CLI": "1", "CURSOR_AGENT": "1" }, + "isAgent": true, + "agent": "cursor", + "confidence": 0.92, + "signals": ["env:CURSOR_AGENT", "env:GEMINI_CLI"] + }, { "name": "stronger heuristic wins over earlier weaker signal", "env": { "REPL_ID": "repl-id", "ANTIGRAVITY_AGENT": "1" }, diff --git a/install.sh b/install.sh index c8232de..f09746f 100644 --- a/install.sh +++ b/install.sh @@ -29,6 +29,7 @@ detect_asset() { Linux) case "$arch" in x86_64 | amd64) echo "agenthint-linux-x64" ;; + aarch64 | arm64) echo "agenthint-linux-arm64" ;; *) echo "agenthint install: unsupported Linux architecture: $arch" >&2; exit 1 ;; esac ;; diff --git a/python/agenthint/__init__.py b/python/agenthint/__init__.py index 2195f96..bbbcbc7 100644 --- a/python/agenthint/__init__.py +++ b/python/agenthint/__init__.py @@ -4,6 +4,7 @@ import os import subprocess from dataclasses import dataclass +from functools import lru_cache from importlib.resources import files from pathlib import Path from typing import Callable, Mapping @@ -60,7 +61,8 @@ def detect_agent( matches = _detection_matches(env) if matches: best = max(matches, key=lambda match: match["confidence"]) - return AgentHintResult(True, best["agent"], best["confidence"], [signal for match in matches for signal in match["signals"]]) + signals = list(dict.fromkeys(signal for match in matches for signal in match["signals"])) + return AgentHintResult(True, best["agent"], best["confidence"], signals) if check_filesystem and file_exists("/opt/.devin"): return AgentHintResult(True, "devin", 0.9, ["file:/opt/.devin"]) @@ -132,7 +134,7 @@ def format_doctor_json(result: AgentHintResult) -> str: def format_init(agent: str | None) -> str: normalized = _normalize_agent_name(agent) - if normalized is None: + if normalized is None or normalized.startswith("-"): return "\n".join( [ "agenthint init", @@ -169,7 +171,12 @@ def _detection_matches(env: Mapping[str, str]) -> list[dict[str, object]]: for rule in rules["environmentRules"]: signals = _present(env, rule["names"]) if signals: - agent = "cowork" if rule["agent"] == "claude-code" and _present(env, ["CLAUDE_CODE_IS_COWORK"]) else rule["agent"] + agent = rule["agent"] + if agent == "claude-code": + cowork_signals = _present(env, ["CLAUDE_CODE_IS_COWORK"]) + if cowork_signals: + agent = "cowork" + signals = signals + cowork_signals matches.append({"agent": agent, "confidence": rule["confidence"], "signals": signals}) for rule in rules["prefixRules"]: @@ -224,7 +231,7 @@ def _present(env: Mapping[str, str], names: list[str]) -> list[str]: def _prefix_present(env: Mapping[str, str], prefix: str) -> list[str]: - return [f"env:{name}" for name, value in env.items() if name.startswith(prefix) and value] + return sorted(f"env:{name}" for name, value in env.items() if name.startswith(prefix) and value) def _is_truthy(value: str | None) -> bool: @@ -295,6 +302,7 @@ def _setup_hint(agent: str) -> str: return hints.get(agent, f"Set AI_AGENT={agent} in the agent's tool-call environment.") +@lru_cache(maxsize=1) def _rules() -> dict[str, object]: return json.loads(files("agenthint").joinpath("detection-rules.json").read_text(encoding="utf8")) diff --git a/python/agenthint/cli.py b/python/agenthint/cli.py index 71b12c7..bd4073e 100644 --- a/python/agenthint/cli.py +++ b/python/agenthint/cli.py @@ -20,7 +20,7 @@ def main() -> None: raise SystemExit(0) if args[:1] == ["init"]: - if len(args) != 2 or not args[1].strip(): + if len(args) != 2 or not args[1].strip() or args[1].startswith("-"): print_usage_error(format_init(None)) print(format_init(args[1])) diff --git a/src/agent-names.ts b/src/agent-names.ts new file mode 100644 index 0000000..d635556 --- /dev/null +++ b/src/agent-names.ts @@ -0,0 +1,35 @@ +import type { GeneratedKnownAgent } from "./generated-rules.js"; + +export type KnownAgent = GeneratedKnownAgent; + +export type AgentName = KnownAgent | (string & {}); + +export function normalizeAgentName(value: string | undefined): AgentName | null { + const normalized = value?.trim(); + + if (normalized == null || normalized === "") { + return null; + } + + if (normalized === "github-copilot" || normalized === "github-copilot-cli") { + return "copilot"; + } + + if (normalized.startsWith("claude-code")) { + return "claude-code"; + } + + if (normalized === "roo" || normalized === "roo-code") { + return "roo-code"; + } + + if (normalized === "kilo-code" || normalized === "kilocode") { + return "kilocode"; + } + + if (normalized === "mistral-vibe" || normalized === "vibe") { + return "mistral-vibe"; + } + + return normalized; +} diff --git a/src/cli.ts b/src/cli.ts index 5894774..d0c37d9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,11 +11,13 @@ if (rawArgs.length === 1 && (rawArgs[0] === "-h" || rawArgs[0] === "--help")) { } if (rawArgs[0] === "init") { - if (rawArgs.length !== 2 || rawArgs[1]?.trim() === "") { + const agent = rawArgs[1]; + + if (rawArgs.length !== 2 || agent == null || agent.trim() === "" || agent.startsWith("-")) { printUsageError(formatInit(undefined)); } - console.log(formatInit(rawArgs[1])); + console.log(formatInit(agent)); process.exit(0); } diff --git a/src/index.ts b/src/index.ts index c1b251d..c0e82d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,10 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { basename } from "node:path"; -import { - ENVIRONMENT_RULES, - type GeneratedKnownAgent, - KNOWN_AGENTS, - PARENT_PROCESS_RULES, - PREFIX_RULES, -} from "./generated-rules.js"; +import { type AgentName, type KnownAgent, normalizeAgentName } from "./agent-names.js"; +import { ENVIRONMENT_RULES, PARENT_PROCESS_RULES, PREFIX_RULES } from "./generated-rules.js"; -export type KnownAgent = GeneratedKnownAgent; - -export type AgentName = KnownAgent | (string & {}); +export type { AgentName, KnownAgent } from "./agent-names.js"; export type AgentHintResult = { isAgent: boolean; @@ -47,7 +40,15 @@ const DETECTION_RULES: DetectionRule[] = [ present(env, ["CLAUDE_CODE_IS_COWORK"]).length > 0 ? "cowork" : "claude-code" : rule.agent, confidence: rule.confidence, - match: (env: NodeJS.ProcessEnv) => present(env, [...rule.names]), + match: (env: NodeJS.ProcessEnv) => { + const signals = present(env, [...rule.names]); + + if (rule.agent === "claude-code" && signals.length > 0) { + signals.push(...present(env, ["CLAUDE_CODE_IS_COWORK"])); + } + + return signals; + }, })), ...PREFIX_RULES.map((rule) => ({ agent: rule.agent, @@ -96,7 +97,7 @@ export function detectAgent(options: DetectAgentOptions = {}): AgentHintResult { isAgent: true, agent: typeof best.agent === "function" ? best.agent(env) : best.agent, confidence: best.confidence, - signals: matches.flatMap((match) => match.signals), + signals: [...new Set(matches.flatMap((match) => match.signals))], }; } @@ -238,51 +239,14 @@ function present(env: NodeJS.ProcessEnv, names: string[]): string[] { function prefixPresent(env: NodeJS.ProcessEnv, prefix: string): string[] { return Object.keys(env) .filter((name) => name.startsWith(prefix) && env[name] != null && env[name] !== "") - .map((name) => `env:${name}`); + .map((name) => `env:${name}`) + .sort(); } function isTruthy(value: string | undefined): boolean { return value != null && TRUE_VALUES.has(value.toLowerCase()); } -function normalizeAgentName(value: string | undefined): AgentName | null { - const normalized = value?.trim(); - - if (normalized == null || normalized === "") { - return null; - } - - if (normalized === "github-copilot" || normalized === "github-copilot-cli") { - return "copilot"; - } - - if (normalized.startsWith("claude-code")) { - return "claude-code"; - } - - if (normalized === "roo" || normalized === "roo-code") { - return "roo-code"; - } - - if (normalized === "kilo-code" || normalized === "kilocode") { - return "kilocode"; - } - - if (normalized === "mistral-vibe" || normalized === "vibe") { - return "mistral-vibe"; - } - - if (isKnownAgent(normalized)) { - return normalized; - } - - return normalized; -} - -function isKnownAgent(value: string): value is KnownAgent { - return KNOWN_AGENTS.includes(value as KnownAgent); -} - function ttyHints(options: DetectAgentOptions): string[] { const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY; const stdinIsTTY = options.stdinIsTTY ?? process.stdin.isTTY; diff --git a/src/init.ts b/src/init.ts index ab31fdd..7a06d05 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,12 +1,4 @@ -import type { AgentName } from "./index.js"; - -const INIT_ALIASES: Record = { - "github-copilot": "copilot", - "github-copilot-cli": "copilot", - roo: "roo-code", - "kilo-code": "kilocode", - vibe: "mistral-vibe", -}; +import { type AgentName, normalizeAgentName } from "./agent-names.js"; export function formatInit(agent: string | undefined): string { const normalized = normalizeInitAgent(agent); @@ -31,15 +23,11 @@ export function formatInit(agent: string | undefined): string { } function normalizeInitAgent(agent: string | undefined): AgentName | null { - const value = agent?.trim(); + const normalized = normalizeAgentName(agent); - if (value == null || value === "") { + if (normalized == null || normalized.startsWith("-")) { return null; } - if (value.startsWith("claude-code")) { - return "claude-code"; - } - - return INIT_ALIASES[value] ?? value; + return normalized; } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 929107e..9c4c13a 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -22,7 +22,7 @@ describe("agenthint CLI", () => { } for (const expected of fixture.stdoutContains ?? []) { - assert.match(result.stdout, new RegExp(expected), fixture.name); + assert.ok(result.stdout.includes(expected), `${fixture.name}: ${expected}`); } } });