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 .
44const std = @import ("std" );
55const proto = @import ("tool_protocol.zig" );
66const executor = @import ("tool_executor.zig" );
77const session_store = @import ("session_store.zig" );
88const permissions = @import ("permissions.zig" );
9+ const tui = @import ("tui.zig" );
10+ const mcp_client = @import ("mcp_client.zig" );
911
1012const api_url = "https://api.anthropic.com/v1/messages" ;
1113const 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