1use crate::ocuse::{db, render, window, zen};
9use serde_json::{json, Value};
10
11const PROTOCOL_VERSION: &str = "2025-06-18";
12
13pub fn run(
15 db_path: &str,
16 days: i64,
17 tz_secs: i64,
18 colour: bool,
19 plan: &str,
20 period_start: Option<i64>,
21) {
22 use std::io::{BufRead, Write};
23 let stdin = std::io::stdin();
24 let mut out = std::io::stdout().lock();
25 for line in stdin.lock().lines() {
26 let Ok(line) = line else { break };
27 if line.trim().is_empty() {
28 continue;
29 }
30 let Ok(msg) = serde_json::from_str::<Value>(&line) else {
31 continue; };
33 if let Some(response) = handle(&msg, db_path, days, tz_secs, colour, plan, period_start) {
34 if writeln!(out, "{response}").is_err() {
35 break; }
37 out.flush().ok();
38 }
39 }
40}
41
42pub fn handle(
44 msg: &Value,
45 db_path: &str,
46 days: i64,
47 tz_secs: i64,
48 colour: bool,
49 plan: &str,
50 period_start: Option<i64>,
51) -> Option<Value> {
52 let method = msg.get("method")?.as_str()?;
53 let id = msg.get("id")?;
54 let result = match method {
55 "initialize" => Ok(json!({
56 "protocolVersion": msg
57 .pointer("/params/protocolVersion")
58 .and_then(|v| v.as_str())
59 .unwrap_or(PROTOCOL_VERSION),
60 "capabilities": { "tools": {} },
61 "serverInfo": { "name": "ocuse", "version": env!("CARGO_PKG_VERSION") },
62 })),
63 "ping" => Ok(json!({})),
64 "tools/list" => Ok(json!({ "tools": tools() })),
65 "tools/call" => tool_call(msg, db_path, days, tz_secs, colour, plan, period_start),
66 other => Err((-32601, format!("method not found: {other}"))),
67 };
68 Some(match result {
69 Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
70 Err((code, message)) => json!({
71 "jsonrpc": "2.0", "id": id,
72 "error": { "code": code, "message": message },
73 }),
74 })
75}
76
77fn tool_call(
78 msg: &Value,
79 db_path: &str,
80 days: i64,
81 tz_secs: i64,
82 colour: bool,
83 plan: &str,
84 period_start: Option<i64>,
85) -> Result<Value, (i64, String)> {
86 let name = msg
87 .pointer("/params/name")
88 .and_then(|v| v.as_str())
89 .unwrap_or_default();
90 let args = msg
91 .pointer("/params/arguments")
92 .cloned()
93 .unwrap_or(json!({}));
94 let number =
95 |key: &str, fallback: i64| args.get(key).and_then(|v| v.as_i64()).unwrap_or(fallback);
96 let plan_id = args
98 .get("plan")
99 .and_then(|v| v.as_str())
100 .unwrap_or(plan)
101 .to_string();
102
103 let rows = || db::read_rows(db_path, crate::ocuse::window::now_ms() - days * 86_400_000);
104 let text = match name {
105 "usage" => rows().map(|rows| {
106 render::render_text(
107 &window::build(&rows, &zen::catalog(), &plan_id, window::now_ms(), None),
108 db_path,
109 15,
110 colour,
111 )
112 }),
113 "plans" => Ok(render::plans_text(colour)),
114 "daily" => rows().map(|rows| {
115 render::bucket_text(
116 &rows,
117 window::now_ms(),
118 number("days", days),
119 86_400_000,
120 "day",
121 tz_secs,
122 colour,
123 )
124 }),
125 "hourly" => rows().map(|rows| {
126 render::bucket_text(
127 &rows,
128 window::now_ms(),
129 number("hours", 24),
130 3_600_000,
131 "hour",
132 tz_secs,
133 colour,
134 )
135 }),
136 "session" => {
137 rows().map(|rows| render::session_text(&rows, number("limit", 20) as usize, colour))
138 }
139 "model" => {
140 let wanted = args
141 .get("id")
142 .and_then(|v| v.as_str())
143 .unwrap_or_default()
144 .to_string();
145 rows().map(|rows| {
146 let report = window::build(
147 &rows,
148 &zen::catalog(),
149 &plan_id,
150 window::now_ms(),
151 period_start,
152 );
153 render::model_text(&report, &wanted, colour)
154 })
155 }
156 _ => {
157 return Ok(json!({
158 "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
159 "isError": true,
160 }))
161 }
162 };
163 Ok(match text {
164 Ok(body) => json!({ "content": [{ "type": "text", "text": body }] }),
165 Err(error) => json!({
166 "content": [{ "type": "text", "text": format!("error: {error}") }],
167 "isError": true,
168 }),
169 })
170}
171
172fn tools() -> Value {
173 json!([
174 { "name": "usage", "description": "OpenCode Go/Zen usage: period totals, rolling windows, per-model spend against each model's monthly limit. Defaults to the Go plan.", "inputSchema": { "type": "object", "properties": { "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" } } } },
175 { "name": "plans", "description": "The Go/Zen catalogue from the docs: both Go plans' per-model monthly limits and the shared $/1M rates.", "inputSchema": { "type": "object", "properties": {} } },
176 { "name": "daily", "description": "Spend, requests and tokens per day.", "inputSchema": { "type": "object", "properties": { "days": { "type": "integer", "description": "days of history (default 120)" } } } },
177 { "name": "hourly", "description": "Spend, requests and tokens per hour.", "inputSchema": { "type": "object", "properties": { "hours": { "type": "integer", "description": "hours of history (default 24)" } } } },
178 { "name": "session", "description": "Spend per OpenCode session, newest first.", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "description": "sessions to list (default 20)" } } } },
179 { "name": "model", "description": "One model's limit, spend and window figures, against the given Go plan.", "inputSchema": { "type": "object", "properties": { "id": { "type": "string", "description": "model id, e.g. glm-5.2" }, "plan": { "type": "string", "description": "Go plan: go (default) or go-plus" } }, "required": ["id"] } },
180 ])
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn call(method: &str, params: Value, id: i64) -> Value {
188 handle(
189 &json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }),
190 "/nonexistent/opencode.db",
191 120,
192 0,
193 false,
194 crate::ocuse::zen::DEFAULT_PLAN,
195 None,
196 )
197 .expect("notification")
198 }
199
200 #[test]
201 fn initialize_negotiates_the_client_version() {
202 let r = call("initialize", json!({ "protocolVersion": "2025-03-26" }), 1);
203 assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
204 assert_eq!(r["result"]["serverInfo"]["name"], "ocuse");
205 }
206
207 #[test]
208 fn unknown_method_is_jsonrpc_error() {
209 let r = call("resources/list", json!({}), 2);
210 assert_eq!(r["error"]["code"], -32601);
211 }
212
213 #[test]
214 fn unknown_tool_is_iserror_result_not_rpc_error() {
215 let r = call("tools/call", json!({ "name": "nope", "arguments": {} }), 3);
216 assert!(r.get("error").is_none());
217 assert_eq!(r["result"]["isError"], true);
218 }
219
220 #[test]
221 fn a_missing_store_reports_an_error_result() {
222 let r = call("tools/call", json!({ "name": "usage", "arguments": {} }), 4);
223 assert_eq!(r["result"]["isError"], true, "{r}");
224 assert!(r["result"]["content"][0]["text"]
225 .as_str()
226 .unwrap()
227 .contains("error"));
228 }
229
230 #[test]
231 fn tools_list_covers_every_command() {
232 let r = call("tools/list", json!({}), 5);
233 let names: Vec<&str> = r["result"]["tools"]
234 .as_array()
235 .unwrap()
236 .iter()
237 .map(|t| t["name"].as_str().unwrap())
238 .collect();
239 for expected in ["usage", "plans", "daily", "hourly", "session", "model"] {
240 assert!(
241 names.contains(&expected),
242 "{expected} missing from {names:?}"
243 );
244 }
245 }
246}