Generated: 2026-02-08 (updated from 2026-02-07) Scope: All modules - enabling reuse across CLI, MCP, and HTTP interfaces
Goal: Extract service layers so the same business logic powers CLI, MCP server, and HTTP API.
Current state: 11 modules with varying levels of separation. Most have println!() in orchestration code, coupling output to business logic.
| Pattern | Count | Modules |
|---|---|---|
| Has API trait | 4 | pagerduty, gh, jira, web_search |
| Has service.rs | 3 | read, git, docs |
| Display separated | 9 | Most modules |
| Fully MCP-ready | 0 | None yet |
Issues found: 19 total (7 high, 8 medium, 4 low)
- Location:
src/pagerduty/mod.rs:65-149 - Problem:
cmd_*handlers create client, call API, format output, print - all in one - Impact: Cannot reuse for MCP/HTTP without duplicating logic
- Action:
- Create
src/pagerduty/service.rswith functions that return data:pub async fn list_oncalls(api: &impl PagerDutyApi, opts: OncallOptions) -> Result<Vec<Oncall>> pub async fn list_incidents(api: &impl PagerDutyApi, opts: IncidentOptions) -> Result<Vec<Incident>> pub async fn get_incident(api: &impl PagerDutyApi, id: &str) -> Result<Incident>
- Move
check_configured()to service layer - Keep
cmd_*handlers as thin CLI wrappers that call service + display
- Create
- Location:
src/slack/handlers.rs(334 lines, 21 println!) - Problem: Handlers mix business logic with output formatting
- Impact: Slack integration locked to CLI only
- Action:
- Create
src/slack/service.rs:pub async fn list_channels(client: &impl SlackApi) -> Result<Vec<Channel>> pub async fn get_channel_info(client: &impl SlackApi, id: &str) -> Result<ChannelInfo> pub async fn send_message(client: &impl SlackApi, channel: &str, text: &str) -> Result<MessageResult> pub async fn tidy_channels(client: &impl SlackApi, user: &UserInfo, dry_run: bool) -> Result<TidyReport>
- Create
SlackApitrait insrc/slack/client.rs - Move
verify_token()to client module
- Create
- Location:
src/newrelic/mod.rs:101-149 - Problem: Same pattern - handlers do everything inline
- Impact: Cannot query NewRelic from MCP tools
- Action:
- Create
src/newrelic/service.rs:pub async fn list_issues(client: &impl NewRelicApi, limit: usize) -> Result<Vec<Issue>> pub async fn list_incidents(client: &impl NewRelicApi, limit: usize) -> Result<Vec<Incident>> pub async fn run_nrql(client: &impl NewRelicApi, query: &str) -> Result<NrqlResults>
- Add
NewRelicApitrait to client
- Create
- Location:
src/sentry/mod.rs:117-174 - Problem: Handlers inline all logic
- Impact: Cannot use Sentry queries from MCP
- Action:
- Create
src/sentry/service.rs:pub async fn list_issues(client: &impl SentryApi, opts: IssueOptions) -> Result<Vec<Issue>> pub async fn get_issue(client: &impl SentryApi, id: &str) -> Result<Issue> pub async fn list_events(client: &impl SentryApi, issue_id: &str, limit: usize) -> Result<Vec<Event>>
- Add
SentryApitrait to client
- Create
- Location:
src/data/mod.rs(473 lines) - Problem: Large file with all handlers, mixes DB access with output
- Impact: Cannot expose data queries via MCP
- Action:
- Create
src/data/service.rs:pub fn sync_data(store: &SqliteStore, claude_dir: &Path, force: bool) -> Result<SyncResult> pub fn get_sessions(store: &SqliteStore, project: Option<&str>, limit: i64) -> Result<Vec<Session>> pub fn get_stats(store: &SqliteStore, since: Option<i64>) -> Result<UsageStats> pub fn search_messages(store: &SqliteStore, query: &str, limit: i64) -> Result<Vec<SearchResult>>
- Move
scan_debug_errors()to service (already returns data, good pattern) - Keep
open_db()andensure_synced()as helpers in mod.rs
- Create
- Location:
src/read/service.rs:23,37,42,45 - Problem: Service layer has 4
println!()calls - defeats the purpose - Impact: Cannot use read operations from MCP without stdout pollution
- Action:
- Change
run()to return an enum:pub enum ReadOutput { LinesAround { lines: Vec<Line>, center: usize, total: usize }, Diff(String), Interface(Vec<OutlineItem>), Outline(FileOutline), Content(String), } pub fn run(args: ReadArgs) -> Result<ReadOutput>
- Move formatting to CLI layer in mod.rs
- Change
- Location:
src/mcp/(new module) - Problem: No MCP interface exists yet
- Impact: Cannot use hu tools from Claude Code or other MCP clients
- Action:
- Create module structure:
src/mcp/ mod.rs # JSON-RPC stdio server, method dispatch cli.rs # clap subcommands (serve, list) types.rs # MCP protocol types (Tool, Prompt, Resource, etc.) templates.rs # All definitions as constants (like install/templates.rs) handlers.rs # tools/call, prompts/get, resources/read handlers - Define all three MCP primitives in templates.rs:
- Tools:
pagerduty_incidents,slack_search,sentry_issues, etc. - Prompts:
incident_response,daily_standup,error_analysis - Resources:
hu://config,hu://stats,hu://session/{id},hu://jira/{key}
- Tools:
- Implement JSON-RPC handlers for:
tools/list,tools/callprompts/list,prompts/getresources/list,resources/read,resources/templates/list
- Add CLI subcommand:
#[derive(Subcommand)] pub enum McpCommand { /// Start MCP server (JSON-RPC over stdio) Serve, /// List available tools, prompts, resources List { #[arg(long)] tools: bool, #[arg(long)] prompts: bool, #[arg(long)] resources: bool, }, }
- Register:
claude mcp add hu --transport stdio -- hu mcp serve
- Create module structure:
- Location:
src/newrelic/client/mod.rs - Problem: No trait defined - cannot mock for testing or swap implementations
- Impact: Tests require real API calls or
#[cfg(not(tarpaulin_include))] - Action: Add
NewRelicApitrait matchingPagerDutyApipattern
- Location:
src/sentry/client.rs - Problem: No trait - same issue as NewRelic
- Action: Add
SentryApitrait
- Location:
src/slack/client.rs(389 lines) - Problem: Large file, no trait for mockability
- Action: Extract
SlackApitrait, consider splitting client into smaller files
- Location: All modules
- Problem: Current:
pagerduty::types::Incident, should be:pagerduty::Incident - Impact: Verbose imports, exposes internal structure
- Action: Add
pub usestatements in eachmod.rs:// pagerduty/mod.rs pub use types::{Incident, Oncall, User, OutputFormat}; pub use client::{Client, Api};
- Location:
src/*/types.rs(each module defines own OutputFormat) - Problem: 6+ identical
OutputFormat { Json, Table }definitions - Impact: Duplication, inconsistency risk
- Action: Create
src/util/output.rswith shared type:#[derive(Debug, Clone, Copy)] pub enum OutputFormat { Json, Table }
- Location:
src/install/mod.rs(25 println!, 443 lines) - Problem: Mixes installation logic with output, file too large
- Impact: Cannot test installation logic without capturing stdout
- Action:
- Create
src/install/display.rsforprint_status_table()and output - Create
src/install/service.rswith:pub fn check_statuses(components: &[Component], base: &Path) -> Vec<ComponentStatus> pub fn install_components(components: &[Component], base: &Path) -> Result<InstallReport>
- Create
- Location:
src/gh/client/mod.rs(455 lines),src/slack/client.rs(389 lines) - Problem: Files exceed 300-500 line limit
- Impact: Hard to navigate, test, maintain
- Action: Split by concern:
gh/client/→mod.rs,runs.rs,prs.rs,checks.rsslack/client/→mod.rs,channels.rs,messages.rs,search.rs
- Location: Various modules
- Problem: Inconsistent:
run(),run_command(),cmd_*()patterns - Impact: Confusion when navigating codebase
- Action: Standardize to:
run(cmd: Command)- public dispatcher in mod.rsservice::*- business logic functions- Keep
cmd_*as private CLI-specific handlers if needed
- Location: Various type definitions
- Problem:
SentryConfiginsentry::config,SlackConfiginslack::config - Impact: Verbose:
sentry::config::SentryConfigvssentry::Config - Action: Rename to just
Config, re-export at module level
- Location: Each module has own
check_configured(&client)function - Problem: Duplicated error handling pattern
- Action: Consider trait method or shared helper:
pub trait Configured { fn ensure_configured(&self) -> Result<()>; }
- Location: Multiple
display/mod.rsfiles - Problem: Repeated
comfy_tablesetup boilerplate - Action: Create
src/util/table.rs:pub fn new_table() -> Table { let mut t = Table::new(); t.load_preset(UTF8_FULL_CONDENSED); t }
- Location:
CLAUDE.mdor newARCHITECTURE.md - Problem: Pattern not documented for contributors
- Action: Add architecture section explaining:
- Service layer returns data, never prints
- Traits for all API clients
- CLI handlers are thin wrappers
Suggested sequence for minimal disruption:
- H6 (read) - Small, isolated, proves the service pattern
- H1 (pagerduty) - Already has trait, closest to done
- M5 (OutputFormat) - Unblocks other refactors
- H3, H4 (newrelic, sentry) - Similar to pagerduty
- M1, M2 (traits) - Enable testing
- H7 (mcp) - Create MCP module with tool templates (depends on service layers)
- H2 (slack) - Larger but well-structured
- H5 (data) - Largest, most complex
- M6 (install) - Independent
- Medium/Low - As time permits
CLI args → cmd_handlers (fetch + print) → stdout
CLI args ──┐
├──→ service.rs (returns data) ──→ client (API calls)
MCP req ───┤ │
│ ↓
HTTP req ──┘ display (formatting) ← only CLI uses this
{module}/
mod.rs # Re-exports + command dispatch (thin)
cli.rs # clap args (CLI-only)
types.rs # Data structs
config.rs # Module config
service.rs # Business logic - RETURNS DATA, NEVER PRINTS
client/ # API client
mod.rs # Implements trait
tests.rs
display/ # Output formatting (CLI-only)
mod.rs
tests.rs
src/
mcp/ # hu mcp serve - MCP server (stdio)
mod.rs # JSON-RPC stdio handler
tools.rs # Tool registry (maps to service calls)
types.rs # MCP protocol types
templates.rs # Tool definitions as constants
serve/ # hu serve - HTTP API
mod.rs # axum server setup
routes.rs # Route handlers (maps to service calls)
types.rs # API request/response types
MCP has three primitives - define all as constants in src/mcp/templates.rs:
| Primitive | Control | Use Case | Claude Code UI |
|---|---|---|---|
| Tools | Model-controlled | Actions (query, search, create) | Auto-invoked by model |
| Prompts | User-controlled | Structured instructions | /mcp__hu__prompt_name |
| Resources | Application-driven | Read-only data context | @hu:resource://path |
// src/mcp/templates.rs
// ============================================================================
// TOOLS - Model-controlled actions
// ============================================================================
pub struct Tool {
pub name: &'static str,
pub description: &'static str,
pub input_schema: &'static str,
}
pub const PAGERDUTY_INCIDENTS: Tool = Tool {
name: "pagerduty_incidents",
description: "List PagerDuty incidents",
input_schema: r#"{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["triggered", "acknowledged", "resolved"] },
"limit": { "type": "integer", "default": 25 }
}
}"#,
};
pub const SLACK_SEARCH: Tool = Tool {
name: "slack_search",
description: "Search Slack messages",
input_schema: r#"{
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"count": { "type": "integer", "default": 20 }
},
"required": ["query"]
}"#,
};
pub const SENTRY_ISSUES: Tool = Tool {
name: "sentry_issues",
description: "List Sentry issues",
input_schema: r#"{
"type": "object",
"properties": {
"project": { "type": "string" },
"query": { "type": "string" },
"limit": { "type": "integer", "default": 25 }
}
}"#,
};
pub const ALL_TOOLS: &[&Tool] = &[
&PAGERDUTY_INCIDENTS,
&SLACK_SEARCH,
&SENTRY_ISSUES,
];
// ============================================================================
// PROMPTS - User-controlled structured messages (like slash commands)
// ============================================================================
pub struct PromptArg {
pub name: &'static str,
pub description: &'static str,
pub required: bool,
}
pub struct Prompt {
pub name: &'static str,
pub description: &'static str,
pub arguments: &'static [PromptArg],
}
pub const PROMPT_INCIDENT_RESPONSE: Prompt = Prompt {
name: "incident_response",
description: "Generate incident response checklist from PagerDuty alert",
arguments: &[
PromptArg { name: "incident_id", description: "PagerDuty incident ID", required: true },
],
};
pub const PROMPT_DAILY_STANDUP: Prompt = Prompt {
name: "daily_standup",
description: "Generate standup summary from recent activity",
arguments: &[
PromptArg { name: "days", description: "Days to look back (default: 1)", required: false },
],
};
pub const PROMPT_ERROR_ANALYSIS: Prompt = Prompt {
name: "error_analysis",
description: "Analyze Sentry error and suggest fixes",
arguments: &[
PromptArg { name: "issue_id", description: "Sentry issue ID", required: true },
],
};
pub const ALL_PROMPTS: &[&Prompt] = &[
&PROMPT_INCIDENT_RESPONSE,
&PROMPT_DAILY_STANDUP,
&PROMPT_ERROR_ANALYSIS,
];
// ============================================================================
// RESOURCES - Read-only data context (@ mentions)
// ============================================================================
pub struct Resource {
pub uri: &'static str,
pub name: &'static str,
pub description: &'static str,
pub mime_type: &'static str,
}
pub struct ResourceTemplate {
pub uri_template: &'static str,
pub name: &'static str,
pub description: &'static str,
pub mime_type: &'static str,
}
// Static resources
pub const RESOURCE_CONFIG: Resource = Resource {
uri: "hu://config",
name: "Configuration",
description: "Current hu CLI configuration",
mime_type: "application/json",
};
pub const RESOURCE_STATS: Resource = Resource {
uri: "hu://stats",
name: "Usage Statistics",
description: "Claude Code usage statistics",
mime_type: "application/json",
};
// Dynamic resource templates
pub const TEMPLATE_SESSION: ResourceTemplate = ResourceTemplate {
uri_template: "hu://session/{id}",
name: "Session",
description: "Claude Code session data",
mime_type: "application/json",
};
pub const TEMPLATE_JIRA_TICKET: ResourceTemplate = ResourceTemplate {
uri_template: "hu://jira/{key}",
name: "Jira Ticket",
description: "Jira ticket details",
mime_type: "application/json",
};
pub const ALL_RESOURCES: &[&Resource] = &[
&RESOURCE_CONFIG,
&RESOURCE_STATS,
];
pub const ALL_RESOURCE_TEMPLATES: &[&ResourceTemplate] = &[
&TEMPLATE_SESSION,
&TEMPLATE_JIRA_TICKET,
];// src/mcp/handlers.rs
// Tools - invoked by model via tools/call
pub async fn handle_tool_call(name: &str, args: Value) -> Result<ToolResult> {
match name {
"pagerduty_incidents" => {
let client = pagerduty::Client::new()?;
let data = pagerduty::service::list_incidents(&client, args.into()).await?;
Ok(ToolResult::json(data))
}
"slack_search" => {
let query = args["query"].as_str().ok_or(anyhow!("query required"))?;
let count = args["count"].as_u64().unwrap_or(20) as usize;
let client = slack::Client::new()?;
let data = slack::service::search(&client, query, count).await?;
Ok(ToolResult::json(data))
}
_ => Err(anyhow!("Unknown tool: {}", name)),
}
}
// Prompts - invoked by user via prompts/get, returns messages for LLM
pub async fn handle_prompt_get(name: &str, args: Value) -> Result<PromptResult> {
match name {
"incident_response" => {
let id = args["incident_id"].as_str().ok_or(anyhow!("incident_id required"))?;
let client = pagerduty::Client::new()?;
let incident = pagerduty::service::get_incident(&client, id).await?;
Ok(PromptResult::messages(vec![
Message::user(format!(
"Analyze this PagerDuty incident and create a response checklist:\n\n\
Title: {}\nStatus: {}\nService: {}\nCreated: {}\n\nDetails: {}",
incident.title, incident.status, incident.service,
incident.created_at, incident.description
)),
]))
}
"daily_standup" => {
let days = args["days"].as_u64().unwrap_or(1);
let store = data::open_db()?;
let stats = data::service::get_stats(&store, Some(days))?;
Ok(PromptResult::messages(vec![
Message::user(format!(
"Generate a standup summary from this activity:\n\n{}",
serde_json::to_string_pretty(&stats)?
)),
]))
}
_ => Err(anyhow!("Unknown prompt: {}", name)),
}
}
// Resources - read via resources/read, returns data
pub async fn handle_resource_read(uri: &str) -> Result<ResourceContents> {
match uri {
"hu://config" => {
let config = util::config::load()?;
Ok(ResourceContents::json(config))
}
"hu://stats" => {
let store = data::open_db()?;
let stats = data::service::get_stats(&store, None)?;
Ok(ResourceContents::json(stats))
}
uri if uri.starts_with("hu://session/") => {
let id = uri.strip_prefix("hu://session/").unwrap();
let store = data::open_db()?;
let session = data::service::get_session(&store, id)?;
Ok(ResourceContents::json(session))
}
uri if uri.starts_with("hu://jira/") => {
let key = uri.strip_prefix("hu://jira/").unwrap();
let client = jira::Client::new()?;
let ticket = jira::service::get_ticket(&client, key).await?;
Ok(ResourceContents::json(ticket))
}
_ => Err(anyhow!("Unknown resource: {}", uri)),
}
}# Start MCP server
hu mcp serve
# Add to Claude Code
claude mcp add hu --transport stdio -- hu mcp serveOr in .mcp.json for project sharing:
{
"mcpServers": {
"hu": { "command": "hu", "args": ["mcp", "serve"] }
}
}Tools (model auto-invokes):
> "Check if there are any open PagerDuty incidents"
# Model calls pagerduty_incidents tool automatically
Prompts (user invokes via slash command):
> /mcp__hu__incident_response P12345
# Returns structured prompt for incident analysis
Resources (user references via @ mention):
> Analyze my usage stats @hu:hu://stats
# Fetches resource and includes in context
# CLI
hu pagerduty incidents --limit 5
→ cli::parse() → pagerduty::cmd_incidents()
→ service::list_incidents(api, limit)
→ display::output_incidents(data, Table)
→ println!(table)
# MCP
{"method": "pagerduty/incidents", "params": {"limit": 5}}
→ mcp::handle() → mcp::tools::pagerduty_incidents()
→ service::list_incidents(api, limit)
→ json_rpc_response(data)
# HTTP
GET /api/pagerduty/incidents?limit=5
→ serve::routes::get_incidents()
→ service::list_incidents(api, limit)
→ Json(data)
- All service functions accept
&impl XxxApitrait objects - Service functions return
Result<T>where T is typed data - Display functions take data +
OutputFormat, returnResult<String>or print - MCP handlers will call service directly, format as JSON-RPC response
- HTTP handlers will call service directly, format as JSON body
After refactoring, each module should have:
service/tests.rs- Unit tests with mock clientsdisplay/tests.rs- Output formatting tests (already exist)client/tests.rs- Request/response parsing tests (most exist)
Once services are extracted, create src/lib.rs exposing:
pub mod pagerduty;
pub mod slack;
pub mod sentry;
pub mod newrelic;
pub mod data;
pub mod gh;
pub mod jira;Add interface subcommands to CLI:
// src/cli.rs
#[derive(Subcommand)]
pub enum Command {
// ... existing commands ...
/// Start MCP server (JSON-RPC over stdio)
Mcp,
/// Start HTTP API server
Serve {
/// Port to listen on
#[arg(short, long, default_value = "8080")]
port: u16,
},
}This enables:
hu slack channels- CLI with table outputhu mcp- MCP server (JSON-RPC over stdio)hu serve- HTTP API server (JSON responses)- External crates using
huas library
All three interfaces call the same service::* functions.