Skip to content

Commit beb4d44

Browse files
Antigravity Agentclaude
andcommitted
feat(tri-api): Phase 7 — Interactive TUI + MCP client (#66)
Add interactive terminal mode and MCP stdio client to tri-api: - tui.zig: ANSI colored TUI with tri> prompt, /quit, /sessions - mcp_client.zig: MCP stdio client (JSON-RPC 2.0 subprocess transport) - main.zig: Interactive mode when no args, batch mode with args - tool_executor.zig: executeDynamic routes to built-in or MCP tools - tool_protocol.zig: writeToolDefinitions without brackets for composability No args = interactive TUI loop. With args = batch mode (unchanged). MCP servers loaded from .tri-api/settings.json mcp_servers config. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent bb07f5e commit beb4d44

5 files changed

Lines changed: 782 additions & 53 deletions

File tree

src/tri-api/main.zig

Lines changed: 187 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// main.zig — TRI-API: Direct Anthropic API agentic loop
22
// No claude CLI dependency. Talks to api.anthropic.com/v1/messages directly.
3-
// Self-contained in src/tri-api/. Issues #60, #64.
3+
// Self-contained in src/tri-api/. Issues #60, #64, #66.
44
const std = @import("std");
55
const proto = @import("tool_protocol.zig");
66
const executor = @import("tool_executor.zig");
77
const session_store = @import("session_store.zig");
88
const permissions = @import("permissions.zig");
9+
const tui = @import("tui.zig");
10+
const mcp_client = @import("mcp_client.zig");
911

1012
const api_url = "https://api.anthropic.com/v1/messages";
1113
const api_version = "2023-06-01";
@@ -70,12 +72,78 @@ pub fn main() !void {
7072
return;
7173
}
7274

73-
if (prompt_start >= args.len) {
74-
std.debug.print("usage: tri-api [--model <m>] [--continue] [--resume <id>] [--sessions] <prompt>\n", .{});
75-
std.process.exit(1);
75+
// Load permission config
76+
var perms = permissions.loadConfig(allocator);
77+
defer perms.deinit(allocator);
78+
79+
// Load MCP servers from settings
80+
var mcp = mcp_client.McpManager.init(allocator);
81+
defer mcp.deinit();
82+
loadMcpServers(allocator, &mcp);
83+
84+
// Interactive mode (no prompt args) or batch mode
85+
if (prompt_start >= args.len and !do_list_sessions) {
86+
// Interactive TUI mode
87+
var ui = tui.Tui.init(allocator);
88+
ui.printBanner(model, @intCast(perms.allow_rules.items.len + perms.deny_rules.items.len));
89+
90+
// Show MCP servers
91+
for (mcp.servers.items) |server| {
92+
ui.printMcp(server.name, countServerTools(&mcp, server.name));
93+
}
94+
95+
var tool_exec = executor.ToolExecutor.init(allocator, &perms, &mcp);
96+
var messages = std.ArrayList(u8).empty;
97+
defer messages.deinit(allocator);
98+
99+
while (true) {
100+
const input = ui.readPrompt() orelse break;
101+
defer allocator.free(input);
102+
103+
// Handle slash commands
104+
if (input.len > 0 and input[0] == '/') {
105+
if (std.mem.eql(u8, input, "/quit") or std.mem.eql(u8, input, "/exit")) break;
106+
if (std.mem.eql(u8, input, "/sessions")) {
107+
if (store.listSessions()) |list| {
108+
defer allocator.free(list);
109+
ui.printSession(list);
110+
} else {
111+
ui.printAssistant("No sessions found.");
112+
}
113+
continue;
114+
}
115+
ui.printError("Unknown command. Use /quit or /sessions.");
116+
continue;
117+
}
118+
119+
// Build messages
120+
if (messages.items.len == 0) {
121+
try messages.appendSlice(allocator, "[{\"role\":\"user\",\"content\":\"");
122+
} else {
123+
// Strip trailing ] and append new user message
124+
if (messages.items.len > 0 and messages.items[messages.items.len - 1] == ']') {
125+
_ = messages.pop();
126+
}
127+
try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":\"");
128+
}
129+
try proto.writeJsonEscaped(messages.writer(allocator), input);
130+
try messages.appendSlice(allocator, "\"}");
131+
132+
// Run agentic loop for this prompt
133+
const stats = runAgenticLoop(allocator, api_key, model, &messages, &tool_exec, &mcp, &ui);
134+
ui.printTokens(stats.input_tokens, stats.output_tokens);
135+
136+
// Save session
137+
var save_buf = std.ArrayList(u8).empty;
138+
defer save_buf.deinit(allocator);
139+
try save_buf.appendSlice(allocator, messages.items);
140+
try save_buf.appendSlice(allocator, "]");
141+
store.save(save_buf.items, input);
142+
}
143+
return;
76144
}
77145

78-
// Join remaining args as prompt
146+
// Batch mode: join remaining args as prompt
79147
const prompt = std.mem.join(allocator, " ", args[prompt_start..]) catch {
80148
std.debug.print("error: out of memory\n", .{});
81149
std.process.exit(1);
@@ -109,7 +177,6 @@ pub fn main() !void {
109177

110178
// If resuming, prepend previous messages
111179
if (resume_messages) |rm| {
112-
// rm is like "[{...},{...}]" — strip trailing ] so we can append
113180
if (rm.len > 1 and rm[rm.len - 1] == ']') {
114181
try messages.appendSlice(allocator, rm[0 .. rm.len - 1]);
115182
try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":\"");
@@ -122,96 +189,168 @@ pub fn main() !void {
122189
try proto.writeJsonEscaped(messages.writer(allocator), prompt);
123190
try messages.appendSlice(allocator, "\"}");
124191

125-
// Load permission config
126-
var perms = permissions.loadConfig(allocator);
127-
defer perms.deinit(allocator);
192+
var tool_exec = executor.ToolExecutor.init(allocator, &perms, &mcp);
128193

129194
std.debug.print("[tri-api] permissions: {d} allow, {d} deny rules\n", .{ perms.allow_rules.items.len, perms.deny_rules.items.len });
130195

131-
var tool_exec = executor.ToolExecutor.init(allocator, &perms);
196+
const stats = runAgenticLoop(allocator, api_key, model, &messages, &tool_exec, &mcp, null);
197+
198+
// Close messages array and save session
199+
try messages.appendSlice(allocator, "]");
200+
store.save(messages.items, prompt);
201+
202+
std.debug.print("[tri-api] {d} input + {d} output tokens\n", .{ stats.input_tokens, stats.output_tokens });
203+
}
204+
205+
const LoopStats = struct { input_tokens: u32, output_tokens: u32 };
206+
207+
/// Run the agentic loop: send messages → parse → execute tools → repeat.
208+
fn runAgenticLoop(
209+
allocator: std.mem.Allocator,
210+
api_key: []const u8,
211+
model: []const u8,
212+
messages: *std.ArrayList(u8),
213+
tool_exec: *executor.ToolExecutor,
214+
mcp: *mcp_client.McpManager,
215+
ui_opt: ?*tui.Tui,
216+
) LoopStats {
132217
var total_input_tokens: u32 = 0;
133218
var total_output_tokens: u32 = 0;
134219

135-
// Agentic loop
136220
var turn: u32 = 0;
137221
while (turn < max_turns) : (turn += 1) {
138-
// Close messages array
139222
var request_body = std.ArrayList(u8).empty;
140223
defer request_body.deinit(allocator);
141224

142-
try request_body.appendSlice(allocator, "{\"model\":\"");
143-
try request_body.appendSlice(allocator, model);
144-
try request_body.appendSlice(allocator, "\",\"max_tokens\":8192,\"tools\":");
145-
try proto.writeToolDefinitions(request_body.writer(allocator));
146-
try request_body.appendSlice(allocator, ",\"messages\":");
147-
try request_body.appendSlice(allocator, messages.items);
148-
try request_body.appendSlice(allocator, "]}");
225+
request_body.appendSlice(allocator, "{\"model\":\"") catch break;
226+
request_body.appendSlice(allocator, model) catch break;
227+
request_body.appendSlice(allocator, "\",\"max_tokens\":8192,\"tools\":") catch break;
228+
229+
// Write built-in + MCP tool definitions
230+
const rw = request_body.writer(allocator);
231+
rw.writeByte('[') catch break;
232+
proto.writeToolDefinitions(rw) catch break;
233+
if (mcp.tools.items.len > 0) {
234+
rw.writeByte(',') catch break;
235+
mcp.writeToolDefinitions(rw) catch break;
236+
}
237+
rw.writeByte(']') catch break;
238+
239+
request_body.appendSlice(allocator, ",\"messages\":") catch break;
240+
request_body.appendSlice(allocator, messages.items) catch break;
241+
request_body.appendSlice(allocator, "]}") catch break;
149242

150-
std.debug.print("[tri-api] turn {d}: sending {d} bytes...\n", .{ turn + 1, request_body.items.len });
243+
if (ui_opt == null) {
244+
std.debug.print("[tri-api] turn {d}: sending {d} bytes...\n", .{ turn + 1, request_body.items.len });
245+
}
151246

152-
// POST to Anthropic API
153247
const response_body = httpPost(allocator, api_key, request_body.items) catch |err| {
154-
std.debug.print("[tri-api] HTTP error: {s}\n", .{@errorName(err)});
248+
if (ui_opt) |ui| {
249+
ui.printError(@errorName(err));
250+
} else {
251+
std.debug.print("[tri-api] HTTP error: {s}\n", .{@errorName(err)});
252+
}
155253
break;
156254
};
157255
defer allocator.free(response_body);
158256

159-
// Parse response
160257
var parsed = proto.parseResponse(allocator, response_body);
161258
defer parsed.deinit(allocator);
162259

163260
total_input_tokens += parsed.input_tokens;
164261
total_output_tokens += parsed.output_tokens;
165262

166-
// Process content blocks
167263
var has_tool_use = false;
168264

169265
// Build assistant message for conversation history
170-
try messages.appendSlice(allocator, ",{\"role\":\"assistant\",\"content\":");
171-
try messages.appendSlice(allocator, extractContentArray(response_body) orelse "[]");
172-
try messages.appendSlice(allocator, "}");
266+
messages.appendSlice(allocator, ",{\"role\":\"assistant\",\"content\":") catch break;
267+
messages.appendSlice(allocator, extractContentArray(response_body) orelse "[]") catch break;
268+
messages.appendSlice(allocator, "}") catch break;
173269

174270
for (parsed.blocks.items) |block| {
175271
switch (block) {
176272
.text => |text| {
177-
const stdout_file = std.fs.File.stdout();
178-
var write_buf: [4096]u8 = undefined;
179-
var w = stdout_file.writer(&write_buf);
180-
std.Io.Writer.writeAll(&w.interface, text) catch {};
181-
std.Io.Writer.writeAll(&w.interface, "\n") catch {};
182-
w.end() catch {};
273+
if (ui_opt) |ui| {
274+
ui.printAssistant(text);
275+
} else {
276+
const stdout_file = std.fs.File.stdout();
277+
var write_buf: [4096]u8 = undefined;
278+
var w = stdout_file.writer(&write_buf);
279+
std.Io.Writer.writeAll(&w.interface, text) catch {};
280+
std.Io.Writer.writeAll(&w.interface, "\n") catch {};
281+
w.end() catch {};
282+
}
183283
},
184284
.tool_use => |tool| {
185285
has_tool_use = true;
186-
std.debug.print("[tri-api] tool: {s}({s})\n", .{ tool.name, tool.id });
187286

188-
const tool_name = executor.ToolName.fromString(tool.name) orelse {
189-
std.debug.print("[tri-api] unknown tool: {s}\n", .{tool.name});
190-
continue;
191-
};
287+
if (ui_opt) |ui| {
288+
ui.printTool(tool.name, tool.input_json);
289+
} else {
290+
std.debug.print("[tri-api] tool: {s}({s})\n", .{ tool.name, tool.id });
291+
}
292+
293+
const result = tool_exec.executeDynamic(tool.name, tool.input_json);
192294

193-
const result = tool_exec.execute(tool_name, tool.input_json);
295+
if (result.is_error) {
296+
if (ui_opt) |ui| ui.printDenied(tool.name, "");
297+
}
194298

195299
// Append tool result to messages
196-
try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":[");
197-
try proto.writeToolResult(messages.writer(allocator), tool.id, result.output, result.is_error);
198-
try messages.appendSlice(allocator, "]}");
300+
messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":[") catch break;
301+
proto.writeToolResult(messages.writer(allocator), tool.id, result.output, result.is_error) catch break;
302+
messages.appendSlice(allocator, "]}") catch break;
199303
},
200304
}
201305
}
202306

203-
// Check stop condition
204307
if (std.mem.eql(u8, parsed.stop_reason, "end_turn") or !has_tool_use) {
205-
std.debug.print("[tri-api] done: {s}\n", .{parsed.stop_reason});
308+
if (ui_opt == null) {
309+
std.debug.print("[tri-api] done: {s}\n", .{parsed.stop_reason});
310+
}
206311
break;
207312
}
208313
}
209314

210-
// Close messages array and save session
211-
try messages.appendSlice(allocator, "]");
212-
store.save(messages.items, prompt);
315+
return .{ .input_tokens = total_input_tokens, .output_tokens = total_output_tokens };
316+
}
317+
318+
/// Load MCP servers from user + project settings.json.
319+
fn loadMcpServers(allocator: std.mem.Allocator, mcp: *mcp_client.McpManager) void {
320+
// Try project-local .tri-api/settings.json first, then user ~/.tri-api/settings.json
321+
const settings_data = blk: {
322+
break :blk std.fs.cwd().readFileAlloc(allocator, ".tri-api/settings.json", 64 * 1024) catch {
323+
const home = std.posix.getenv("HOME") orelse break :blk @as(?[]const u8, null);
324+
var path_buf: [512]u8 = undefined;
325+
const path = std.fmt.bufPrint(&path_buf, "{s}/.tri-api/settings.json", .{home}) catch break :blk @as(?[]const u8, null);
326+
break :blk std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch @as(?[]const u8, null);
327+
};
328+
};
329+
if (settings_data == null) return;
330+
defer allocator.free(settings_data.?);
331+
332+
var configs = mcp_client.loadMcpConfig(allocator, settings_data.?);
333+
for (configs.items) |cfg| {
334+
// Dupe name since cfg.name points into settings_data which gets freed
335+
const name_owned = allocator.dupe(u8, cfg.name) catch continue;
336+
const tool_count = mcp.connectServer(name_owned, cfg.command);
337+
if (tool_count > 0) {
338+
std.debug.print("[tri-api] MCP: {s} ({d} tools)\n", .{ name_owned, tool_count });
339+
}
340+
}
341+
configs.deinit(allocator);
342+
}
213343

214-
std.debug.print("[tri-api] {d} turns, {d} input + {d} output tokens\n", .{ turn + 1, total_input_tokens, total_output_tokens });
344+
/// Count tools belonging to a specific server.
345+
fn countServerTools(mcp: *mcp_client.McpManager, server_name: []const u8) u32 {
346+
var count: u32 = 0;
347+
for (mcp.tools.items) |tool| {
348+
// Tool names are "server.tool_name"
349+
if (std.mem.startsWith(u8, tool.name, server_name)) {
350+
count += 1;
351+
}
352+
}
353+
return count;
215354
}
216355

217356
/// Extract the raw "content":[...] array from response body.

0 commit comments

Comments
 (0)