diff --git a/src/gall/daemon.gleam b/src/gall/daemon.gleam index 34137a2..b3f91da 100644 --- a/src/gall/daemon.gleam +++ b/src/gall/daemon.gleam @@ -72,6 +72,9 @@ fn git_blame_ffi(dir: String, path: String) -> String @external(erlang, "gall_ffi", "git_show_file") fn git_show_file_ffi(dir: String, ref: String, path: String) -> String +@external(erlang, "gall_ffi", "exec") +fn exec_ffi(dir: String, command: String) -> String + @external(erlang, "gall_ffi", "list_gestalt_sessions") fn list_gestalt_sessions_ffi(gall_dir: String) -> String @@ -383,6 +386,29 @@ fn handle_tool_call( } } + // Exec — shell command execution, witnessed as @exec + "exec" -> { + case json.get_string(args, "command") { + Error(_) -> #( + state, + Some(make_response( + id, + content_text(err_json("exec requires command")), + )), + None, + ) + Ok(command) -> { + let out = exec_ffi(state.work_dir, command) + let #(next_state, exec_frag) = record_exec(state, command, out) + #( + next_state, + Some(make_response(id, content_text(json_string(out)))), + exec_frag, + ) + } + } + } + _ -> #( state, Some(make_response( @@ -592,6 +618,43 @@ fn record_read( } } +// --------------------------------------------------------------------------- +// @exec annotation +// --------------------------------------------------------------------------- + +/// When the agent runs a shell command through gall, record it as an @exec +/// Fragment. The fragment data carries the command and a hash of the output +/// (not the full output, which could be huge). +fn record_exec( + state: State, + command: String, + output: String, +) -> #(State, Option(fragmentation.Fragment)) { + case state.sess { + Idle -> #(state, None) + Active(session: s, ..) as active -> { + let ts = int.to_string(now()) + let author = case session.config(s) { + session.SessionConfig(author: a, ..) -> a + } + let fragmentation.Sha(self: output_hash) = fragmentation.hash(output) + let w = + fragmentation.witnessed( + fragmentation.Author(author), + fragmentation.Committer("gall"), + fragmentation.Timestamp(ts), + fragmentation.Message("@exec"), + ) + let data = "command: " <> command <> "\noutput_sha: " <> output_hash + let r = fragmentation.ref(fragmentation.hash(ts <> data), "exec") + let frag = fragmentation.shard(r, w, data) + let #(s2, _) = session.act(s, "@exec", "command: " <> command) + let next_sess = Active(..active, session: s2) + #(State(..state, sess: next_sess), Some(frag)) + } + } +} + fn path_visibility(path: String) -> String { case string.starts_with(path, "visibility/private/") { True -> ":private" diff --git a/src/gall/tools.gleam b/src/gall/tools.gleam index 522b1da..1ed9141 100644 --- a/src/gall/tools.gleam +++ b/src/gall/tools.gleam @@ -16,7 +16,10 @@ /// All tool names, in the order they appear in the daemon's tools/list. pub fn tool_names() -> List(String) { - list_append(ado_tool_names(), git_tool_names()) + list_append( + ado_tool_names(), + list_append(git_tool_names(), exec_tool_names()), + ) } /// ADO witnessing tool names. @@ -29,6 +32,11 @@ pub fn git_tool_names() -> List(String) { ["git_status", "git_diff", "git_log", "git_blame", "git_show_file"] } +/// Exec tool names (daemon-only). +pub fn exec_tool_names() -> List(String) { + ["exec"] +} + // --------------------------------------------------------------------------- // Composed tool lists (for tools/list responses) // --------------------------------------------------------------------------- @@ -53,6 +61,8 @@ pub fn daemon_tools_json() -> String { <> git_blame_schema() <> "," <> git_show_file_schema() + <> "," + <> exec_schema() <> "]}" } @@ -183,6 +193,20 @@ pub fn git_show_file_schema() -> String { <> "\"required\":[\"path\"]}}" } +// --------------------------------------------------------------------------- +// Exec tool schema (daemon-only) +// --------------------------------------------------------------------------- + +pub fn exec_schema() -> String { + "{\"name\":\"exec\"," + <> "\"description\":\"Execute a shell command in the project directory. Witnessed as @exec in the session trace.\"," + <> "\"inputSchema\":{\"type\":\"object\"," + <> "\"properties\":{" + <> "\"command\":{\"type\":\"string\"," + <> "\"description\":\"Shell command to execute.\"}}," + <> "\"required\":[\"command\"]}}" +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/gall_ffi.erl b/src/gall_ffi.erl index 486502d..ec28dd7 100644 --- a/src/gall_ffi.erl +++ b/src/gall_ffi.erl @@ -27,7 +27,9 @@ git_show_file/3, %% Gestalt resources list_gestalt_sessions/1, - read_gestalt_session/2 + read_gestalt_session/2, + %% Shell exec + exec/2 ]). %% --------------------------------------------------------------------------- @@ -471,3 +473,12 @@ send_patch(RepoDir, Remote) -> ++ " push " ++ RemoteStr ++ " HEAD 2>&1") end, ok. + +%% --------------------------------------------------------------------------- +%% Shell exec +%% --------------------------------------------------------------------------- + +%% Execute a shell command in Dir, capturing stdout+stderr. +exec(Dir, Command) -> + Cmd = "cd " ++ binary_to_list(Dir) ++ " && " ++ binary_to_list(Command) ++ " 2>&1", + unicode:characters_to_binary(os:cmd(Cmd)). diff --git a/test/gall_exec_test.gleam b/test/gall_exec_test.gleam new file mode 100644 index 0000000..563685e --- /dev/null +++ b/test/gall_exec_test.gleam @@ -0,0 +1,36 @@ +import gall/tools +import gleam/list +import gleam/string +import gleeunit/should + +// --------------------------------------------------------------------------- +// exec schema +// --------------------------------------------------------------------------- + +pub fn exec_schema_has_name_test() { + let schema = tools.exec_schema() + schema |> string.contains("\"name\":\"exec\"") |> should.be_true() +} + +pub fn exec_schema_has_required_command_test() { + let schema = tools.exec_schema() + schema |> string.contains("\"required\":[\"command\"]") |> should.be_true() +} + +// --------------------------------------------------------------------------- +// tool names include exec +// --------------------------------------------------------------------------- + +pub fn tool_names_include_exec_test() { + let names = tools.tool_names() + names |> list.contains("exec") |> should.be_true() +} + +// --------------------------------------------------------------------------- +// daemon_tools_json includes exec +// --------------------------------------------------------------------------- + +pub fn daemon_tools_json_contains_exec_test() { + let json = tools.daemon_tools_json() + json |> string.contains("\"exec\"") |> should.be_true() +} diff --git a/test/gall_tools_test.gleam b/test/gall_tools_test.gleam index c523e29..2321657 100644 --- a/test/gall_tools_test.gleam +++ b/test/gall_tools_test.gleam @@ -11,7 +11,7 @@ pub fn tool_names_include_ado_tools_test() { names |> should.equal([ "observe", "decide", "act", "commit", "git_status", "git_diff", "git_log", - "git_blame", "git_show_file", + "git_blame", "git_show_file", "exec", ]) }