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