Generated: 2026-02-08 | Last audited: 2026-03-30 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: 15+ modules. Critical path complete — MCP server is live with 7 tools. All high-priority service extractions done. Traits exist for 7 API clients.
| Pattern | Count | Modules |
|---|---|---|
| Has API trait | 7 | pagerduty, gh, jira, newrelic, sentry, slack, web_search |
| Has service.rs | 14 | pagerduty, sentry, newrelic, slack, jira, gh, read, git, docs, data, cron, context, shell/df, shell/ls |
| Display separated | 10+ | Most modules |
| MCP server | 1 | hu mcp serve — 7 tools (data_stats, data_search, data_sessions, data_errors, data_pricing, data_tools, read_file) |
Progress: 10 done, 2 partial, 7 not started out of 19 original items.
Note: MCP module is live. #[allow(dead_code)] annotations on public API functions can now be removed as MCP handlers consume them.
src/pagerduty/service.rs(314 lines) —list_oncalls,list_incidents,list_alerts,get_incident,get_current_user- All functions accept
&impl PagerDutyApi mod.rshas thincmd_*handlers behind#[cfg(not(tarpaulin_include))]- Re-exports public API functions for MCP/HTTP use
- Tests use
MockApi
src/slack/service/mod.rs— full service layer withlist_channels,get_channel_info,get_history,send_message,search_messages,list_users,build_user_lookup,authenticate,whoami,run_tidy,compute_tidy_summary- All functions accept
&impl SlackApi(M3 trait landed) handlers.rsreduced from 277 to 191 lines, 0println!calls — all output via display layer- New types:
AuthInfo,AuthResult,TidySummaryintypes.rs - Service tests in
service/tests.rs
src/newrelic/service.rs(219 lines) —list_issues,list_incidents,run_nrql- All functions accept
&impl NewRelicApi NewRelicApitrait atclient/mod.rs:19- Tests use
MockApi
src/sentry/service.rs(386 lines) —list_issues,get_issue,list_events- All functions accept
&impl SentryApi SentryApitrait atclient.rs:18- Tests use
MockApi
src/data/service.rs—open_db,ensure_synced,sync_data,get_sessions,get_session_messages,get_current_session_messages,get_stats,get_todos,get_pending_todos,search_messages,get_tool_stats,get_tool_detail,scan_debug_errors,compute_pricing,get_branch_stats,fetch_pr_infomod.rsreduced from 474 to 194 lines — thin handlers onlyPricingData,ModelUsageWithCost,BranchWithPr,PrInfo,build_model_costsmoved totypes.rs- Service tests cover
scan_debug_errors,compute_tidy_summary, validation functions
read/service.rsreturnsReadOutputenum with variantsFull,Outline,Interface,Around,Diff- No
println!in service layer read/mod.rsis a thin wrapper:service::run()->display::format()->print!()- Exposes
pub fn read(args) -> Result<ReadOutput>for programmatic use
src/mcp/— 6 files:mod.rs,cli.rs,types.rs,tools.rs,handlers.rs,server.rs- JSON-RPC 2.0 over stdio with
initialize,tools/list,tools/callmethods - 7 tools:
data_stats,data_search,data_sessions,data_errors,data_pricing,data_tools,read_file - 72 tests covering types, tools, handlers, and server dispatch
- CLI:
hu mcp serve(start server),hu mcp list(show tools) - Register:
claude mcp add hu -- hu mcp serve - Future: Add prompts, resources, and more tools (pagerduty, sentry, slack, jira) as needed
pub trait NewRelicApiatsrc/newrelic/client/mod.rs:19- Used in service.rs with
&impl NewRelicApi
pub trait SentryApiatsrc/sentry/client.rs:18- Used in service.rs with
&impl SentryApi
pub trait SlackApi: Send + Syncatsrc/slack/client.rs:21- 5 methods:
get,get_with_params,get_with_user_token,post,post_with_user_token - Low-level HTTP trait (unlike other modules' domain-level traits) — business logic lives in channels/, messages/, search/ free functions
- All consumers updated to
&impl SlackApi
- Types are re-exported in several modules: pagerduty, sentry, newrelic, slack, read, git
- Not re-exported: Client types and traits. External consumers still need
pagerduty::client::PagerDutyApi - Not started: data, install, docs have minimal re-exports (just command enum)
- Remaining action: Add
pub use client::{Client, XxxApi}to each module's mod.rs
- Single definition in
src/util/output.rs:#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] - 7 modules updated to re-export via
pub use crate::util::OutputFormat - All display/handler imports work unchanged through module-level re-exports
src/install/mod.rsis 369 lines with 25println!calls (was 443 in original plan)- No
display.rsorservice.rs - 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
src/gh/client/mod.rs: 417 lines (was 455 — slightly smaller but still over limit)src/slack/client.rs: 347 lines (was 389 — slightly smaller but still large)- Action: Split by concern:
gh/client/->mod.rs,runs.rs,prs.rs,checks.rsslack/client/->mod.rs,channels.rs,messages.rs,search.rs
- Two conventions in use:
run(): pagerduty, sentry, newrelic, eks, pipeline, readrun_command(): data, jira, gh, docs, utils, install, context, cron, shell
- Within modules, private handlers consistently use
cmd_*pattern - Action: Standardize to
run(cmd: Command)everywhere
SentryConfiginsentry::config,SlackConfiginslack::config, etc.- Action: Rename to just
Config, re-export at module level
- Renamed to
ensure_configuredconsistently across modules (pagerduty, sentry, newrelic, slack) - Each now lives in its module's
service.rs(not duplicated in handlers) - Remaining: No shared
Configuredtrait — each module implements its own version - Action: Consider trait method or shared helper:
pub trait Configured { fn ensure_configured(&self) -> Result<()>; }
- No
src/util/table.rsor sharednew_table()function - Each display module sets up
comfy_tableindependently - Action: Create
src/util/table.rs:pub fn new_table() -> Table { let mut t = Table::new(); t.load_preset(UTF8_FULL_CONDENSED); t }
CLAUDE.mddescribes the high-level architecture but not the service layer contract- No
ARCHITECTURE.mdexists - Action: Add architecture section explaining:
- Service layer returns data, never prints
- Traits for all API clients
- CLI handlers are thin wrappers
- Modules added since original plan:
cron,shell/df,shell/ls,context,pipeline,eks - Some already have service.rs (cron, shell/df, shell/ls)
- Others (pipeline, eks, context) may need service extraction
- Action: Audit each for service/trait/display separation
src/gh/service.rsandsrc/jira/service.rsexist with trait-based APIs- Both follow the correct pattern but were not in the original plan
- Status: Done — no action needed, noted for completeness
Critical path complete (M3 → H2 → M5 → H5 → H7). Remaining work:
M3 (slack trait)DoneH2 (slack service)DoneM5 (OutputFormat)DoneH5 (data service)DoneH7 (MCP server)Done- M6 (install) — independent, extract display/service
- M4 (flatten exports) — finish re-exporting client/trait types
- M7 (split large files) — gh client, slack client
- M8 (handler naming) — standardize run vs run_command
- N1 (new modules) — audit pipeline, eks, context
- Low priority — as time permits
CLI args -> cmd_handlers (fetch + print) -> stdout
CLI args -> cmd_handler -> service::*(api) -> display::*() -> print
^
MCP req --> mcp::server --> service::*(api) -> json response
|
| (14 modules have service.rs)
| (7 have API traits)
CLI args ---+
+---> service.rs (returns data) ---> client (API calls)
MCP req ----+ |
| v
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.