use std::net::IpAddr;
use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use frigg::settings::{LexicalBackendMode, SemanticRuntimeProvider, WatchMode};
use frigg::storage::DEFAULT_RETAINED_MANIFEST_SNAPSHOTS;
#[derive(Debug, Parser)]
#[command(name = "frigg", version, about = "Frigg MCP server")]
pub(crate) struct Cli {
#[arg(long, global = true)]
pub(crate) quiet: bool,
#[arg(long, global = true)]
pub(crate) verbose: bool,
#[arg(long = "workspace-root", value_name = "PATH", global = true)]
pub(crate) workspace_roots: Vec<PathBuf>,
#[arg(
long = "max-file-bytes",
value_name = "BYTES",
env = "FRIGG_MAX_FILE_BYTES",
hide_env = true,
global = true
)]
pub(crate) max_file_bytes: Option<usize>,
#[arg(
long,
env = "FRIGG_FULL_SCIP_INGEST",
hide_env = true,
global = true,
default_value_t = true
)]
pub(crate) full_scip_ingest: bool,
#[arg(long, value_name = "PORT", global = true)]
pub(crate) mcp_http_port: Option<u16>,
#[arg(long, value_name = "HOST", global = true)]
pub(crate) mcp_http_host: Option<IpAddr>,
#[arg(long, global = true)]
pub(crate) allow_remote_http: bool,
#[arg(
long,
value_name = "TOKEN",
env = "FRIGG_MCP_HTTP_AUTH_TOKEN",
hide_env_values = true,
global = true
)]
pub(crate) mcp_http_auth_token: Option<String>,
#[arg(
long,
value_name = "BOOL",
env = "FRIGG_SEMANTIC_RUNTIME_ENABLED",
hide_env = true,
global = true
)]
pub(crate) semantic_runtime_enabled: Option<bool>,
#[arg(
long,
value_name = "PROVIDER",
env = "FRIGG_SEMANTIC_RUNTIME_PROVIDER",
hide_env = true,
global = true
)]
pub(crate) semantic_runtime_provider: Option<SemanticRuntimeProvider>,
#[arg(
long,
value_name = "MODEL",
env = "FRIGG_SEMANTIC_RUNTIME_MODEL",
hide_env = true,
global = true
)]
pub(crate) semantic_runtime_model: Option<String>,
#[arg(
long,
value_name = "BOOL",
env = "FRIGG_SEMANTIC_RUNTIME_STRICT_MODE",
hide_env = true,
global = true
)]
pub(crate) semantic_runtime_strict_mode: Option<bool>,
#[arg(
long,
value_name = "URL",
env = "FRIGG_SEMANTIC_RUNTIME_OPENAI_COMPAT_ENDPOINT",
hide_env = true,
global = true
)]
pub(crate) semantic_runtime_openai_compat_endpoint: Option<String>,
#[arg(
long,
value_name = "MODE",
env = "FRIGG_WATCH_MODE",
hide_env = true,
global = true
)]
pub(crate) watch_mode: Option<WatchMode>,
#[arg(
long,
value_name = "MODE",
env = "FRIGG_LEXICAL_BACKEND",
hide_env = true,
global = true
)]
pub(crate) lexical_backend: Option<LexicalBackendMode>,
#[arg(
long,
value_name = "PATH",
env = "FRIGG_RIPGREP_EXECUTABLE",
hide_env = true,
global = true
)]
pub(crate) ripgrep_executable: Option<PathBuf>,
#[arg(
long,
value_name = "MILLISECONDS",
env = "FRIGG_WATCH_DEBOUNCE_MS",
hide_env = true,
global = true
)]
pub(crate) watch_debounce_ms: Option<u64>,
#[arg(
long,
value_name = "MILLISECONDS",
env = "FRIGG_WATCH_RETRY_MS",
hide_env = true,
global = true
)]
pub(crate) watch_retry_ms: Option<u64>,
#[arg(
long,
value_name = "COUNT",
env = "FRIGG_WATCH_MANIFEST_FAST_CONCURRENCY",
hide_env = true,
global = true
)]
pub(crate) watch_manifest_fast_concurrency: Option<usize>,
#[arg(
long,
value_name = "COUNT",
env = "FRIGG_WATCH_SEMANTIC_FOLLOWUP_CONCURRENCY",
hide_env = true,
global = true
)]
pub(crate) watch_semantic_followup_concurrency: Option<usize>,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
#[derive(Debug, Parser)]
#[command(name = "frigg", version, about = "Frigg MCP server")]
pub(crate) struct HiddenHookCli {
#[command(subcommand)]
pub(crate) command: HiddenHookCommand,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum HiddenHookCommand {
#[command(hide = true)]
Hook {
#[command(subcommand)]
event: HookEvent,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Subcommand)]
pub(crate) enum HookEvent {
#[command(name = "pretooluse")]
Pretooluse {
#[arg(long = "mode", value_enum, default_value_t = HookMode::Nudge)]
mode: HookMode,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub(crate) enum HookMode {
#[default]
Nudge,
Ask,
}
impl HookMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Nudge => "nudge",
Self::Ask => "ask",
}
}
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum Command {
Serve,
Adopt {
#[arg(
long,
value_enum,
alias = "hook",
num_args = 0..=1,
default_missing_value = "hook"
)]
target: Vec<AdoptTarget>,
#[arg(long = "client", value_enum)]
client: Vec<AdoptClient>,
#[arg(long, default_value_t = false)]
all: bool,
#[arg(long = "policy", value_enum, default_value_t = AdoptAgentsPolicy::Lightweight)]
policy: AdoptAgentsPolicy,
#[arg(long = "skill-provider", value_enum)]
skill_provider: Vec<SkillProvider>,
#[arg(long = "hook-mode", value_enum, default_value_t = HookMode::Nudge)]
hook_mode: HookMode,
#[arg(long, default_value_t = false)]
uninstall: bool,
#[arg(long, default_value_t = false)]
check: bool,
#[arg(long = "dry-run", default_value_t = false)]
dry_run: bool,
#[arg(long, default_value_t = false)]
force: bool,
},
Init,
#[command(alias = "reindex")]
Index {
#[arg(long, default_value_t = false)]
changed: bool,
#[arg(long, default_value_t = false)]
validate_embeddings: bool,
},
#[command(hide = true)]
RepairStorage,
Hash,
#[command(hide = true)]
PruneStorage {
#[arg(
long = "keep-manifest-snapshots",
default_value_t = DEFAULT_RETAINED_MANIFEST_SNAPSHOTS
)]
keep_manifest_snapshots: usize,
},
#[command(visible_alias = "content")]
Context {
#[arg(long, value_name = "DATE_OR_RFC3339")]
since: Option<String>,
#[arg(long, value_name = "DATE_OR_RFC3339")]
until: Option<String>,
#[arg(long, default_value_t = false)]
json: bool,
},
Stats {
#[arg(long, default_value_t = false)]
json: bool,
},
Status {
#[arg(long, default_value_t = false)]
json: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub(crate) enum AdoptAgentsPolicy {
#[default]
Lightweight,
Expanded,
}
impl From<AdoptAgentsPolicy> for frigg::agent_directive::AgentsPolicy {
fn from(value: AdoptAgentsPolicy) -> Self {
match value {
AdoptAgentsPolicy::Lightweight => Self::Lightweight,
AdoptAgentsPolicy::Expanded => Self::Expanded,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum AdoptClient {
Claude,
Codex,
Amp,
Cursor,
Copilot,
}
impl AdoptClient {
pub(crate) fn targets(self) -> &'static [AdoptTarget] {
match self {
Self::Claude => &[AdoptTarget::ClaudeMd],
Self::Codex => &[AdoptTarget::AgentsMd],
Self::Amp => &[AdoptTarget::AgentsMd],
Self::Cursor => &[AdoptTarget::Cursor, AdoptTarget::McpCursor],
Self::Copilot => &[AdoptTarget::Copilot],
}
}
pub(crate) fn skill_provider(self) -> Option<SkillProvider> {
match self {
Self::Claude => Some(SkillProvider::Claude),
Self::Codex => Some(SkillProvider::Codex),
Self::Amp => Some(SkillProvider::Amp),
Self::Cursor => Some(SkillProvider::Cursor),
Self::Copilot => Some(SkillProvider::Copilot),
}
}
}
pub(crate) fn expand_adopt_clients(
clients: &[AdoptClient],
mut targets: Vec<AdoptTarget>,
mut skill_providers: Vec<SkillProvider>,
) -> (Vec<AdoptTarget>, Vec<SkillProvider>) {
for client in clients {
for target in client.targets() {
if !targets.contains(target) {
targets.push(*target);
}
}
if let Some(provider) = client.skill_provider()
&& !skill_providers.contains(&provider)
{
skill_providers.push(provider);
}
}
(targets, skill_providers)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum AdoptTarget {
ClaudeMd,
AgentsMd,
Copilot,
Cursor,
McpProject,
McpCursor,
Hook,
}
impl AdoptTarget {
pub(crate) fn path(self) -> &'static str {
match self {
Self::ClaudeMd => "CLAUDE.md",
Self::AgentsMd => "AGENTS.md",
Self::Copilot => ".github/copilot-instructions.md",
Self::Cursor => ".cursor/rules/frigg.mdc",
Self::McpProject => ".mcp.json",
Self::McpCursor => ".cursor/mcp.json",
Self::Hook => ".claude/settings.json",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(crate) enum SkillProvider {
Amp,
Claude,
Codex,
Cursor,
Copilot,
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use clap::Parser;
use super::{
AdoptAgentsPolicy, AdoptClient, AdoptTarget, Cli, Command, HiddenHookCli,
HiddenHookCommand, HookEvent, SkillProvider, expand_adopt_clients,
};
#[test]
fn hash_command_parses_without_workspace_root() {
let cli = Cli::try_parse_from(["frigg", "hash"]).expect("hash command should parse");
assert!(cli.workspace_roots.is_empty());
assert!(matches!(cli.command, Some(Command::Hash)));
}
#[test]
fn adopt_command_parses_non_hook_flags() {
let cli = Cli::try_parse_from([
"frigg",
"adopt",
"--target",
"agents-md",
"--target",
"mcp-project",
"--dry-run",
"--check",
"--force",
"--uninstall",
"--hook",
])
.expect("adopt command should parse");
match cli.command {
Some(Command::Adopt {
target,
client,
all,
policy,
skill_provider,
hook_mode,
uninstall,
check,
dry_run,
force,
}) => {
assert_eq!(
target,
vec![
AdoptTarget::AgentsMd,
AdoptTarget::McpProject,
AdoptTarget::Hook
]
);
assert!(client.is_empty());
assert_eq!(hook_mode, super::HookMode::Nudge);
assert!(!all);
assert_eq!(policy, AdoptAgentsPolicy::Lightweight);
assert!(skill_provider.is_empty());
assert!(uninstall);
assert!(check);
assert!(dry_run);
assert!(force);
}
other => panic!("expected adopt command, got {other:?}"),
}
}
#[test]
fn adopt_command_parses_skill_providers() {
let cli = Cli::try_parse_from([
"frigg",
"adopt",
"--skill-provider",
"amp",
"--skill-provider",
"claude",
"--skill-provider",
"copilot",
])
.expect("adopt skill-provider should parse");
match cli.command {
Some(Command::Adopt { skill_provider, .. }) => {
assert_eq!(
skill_provider,
vec![
SkillProvider::Amp,
SkillProvider::Claude,
SkillProvider::Copilot
]
);
}
other => panic!("expected adopt command, got {other:?}"),
}
}
#[test]
fn adopt_command_parses_expanded_policy() {
let cli = Cli::try_parse_from(["frigg", "adopt", "--policy", "expanded"])
.expect("adopt command should parse expanded policy");
match cli.command {
Some(Command::Adopt {
policy: AdoptAgentsPolicy::Expanded,
..
}) => {}
other => panic!("expected expanded adopt policy, got {other:?}"),
}
}
#[test]
fn hidden_hook_pretooluse_command_parses() {
let cli = HiddenHookCli::try_parse_from(["frigg", "hook", "pretooluse"])
.expect("hidden hook command should parse");
match cli.command {
HiddenHookCommand::Hook { event } => {
assert_eq!(
event,
HookEvent::Pretooluse {
mode: super::HookMode::Nudge
}
)
}
}
}
#[test]
fn adopt_client_claude_expands_to_doc_plus_plugin_only() {
assert_eq!(AdoptClient::Claude.targets(), &[AdoptTarget::ClaudeMd]);
assert_eq!(
AdoptClient::Claude.skill_provider(),
Some(SkillProvider::Claude)
);
for excluded in [AdoptTarget::McpProject, AdoptTarget::Hook] {
assert!(
!AdoptClient::Claude.targets().contains(&excluded),
"{excluded:?} duplicates what the Claude plugin already provides"
);
}
}
#[test]
fn every_adopt_client_does_something() {
for client in [
AdoptClient::Claude,
AdoptClient::Codex,
AdoptClient::Amp,
AdoptClient::Cursor,
AdoptClient::Copilot,
] {
assert!(
!client.targets().is_empty() || client.skill_provider().is_some(),
"{client:?} expands to no work at all"
);
}
}
#[test]
fn expand_adopt_clients_is_additive_and_deduplicates() {
let (targets, providers) = expand_adopt_clients(
&[AdoptClient::Claude],
vec![AdoptTarget::McpProject],
vec![SkillProvider::Amp],
);
assert_eq!(
targets,
vec![AdoptTarget::McpProject, AdoptTarget::ClaudeMd],
"explicit target kept first, client target appended"
);
assert_eq!(providers, vec![SkillProvider::Amp, SkillProvider::Claude]);
}
#[test]
fn expand_adopt_clients_does_not_duplicate_an_explicit_target() {
let (targets, providers) = expand_adopt_clients(
&[AdoptClient::Claude, AdoptClient::Claude],
vec![AdoptTarget::ClaudeMd],
vec![SkillProvider::Claude],
);
assert_eq!(targets, vec![AdoptTarget::ClaudeMd]);
assert_eq!(providers, vec![SkillProvider::Claude]);
}
#[test]
fn expand_adopt_clients_without_clients_is_identity() {
let (targets, providers) =
expand_adopt_clients(&[], vec![AdoptTarget::AgentsMd], vec![SkillProvider::Codex]);
assert_eq!(targets, vec![AdoptTarget::AgentsMd]);
assert_eq!(providers, vec![SkillProvider::Codex]);
}
#[test]
fn only_documented_hosts_get_the_project_mcp_target() {
for client in [AdoptClient::Codex, AdoptClient::Copilot] {
assert!(
!client.targets().contains(&AdoptTarget::McpProject),
"{client:?} configures MCP through its own tooling, not .mcp.json"
);
}
assert!(
AdoptClient::Cursor
.targets()
.contains(&AdoptTarget::McpCursor)
);
}
}