Packages
fnord
0.9.22
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/ai/tools/cmd.ex
defmodule AI.Tools.Cmd do
@default_timeout_ms 30_000
@max_timeout_ms 300_000
@runner """
#!/bin/sh
set -euf
tmp="$1"; shift
exec "$@" < "$tmp"
"""
# ----------------------------------------------------------------------------
# Behaviour implementation
# ----------------------------------------------------------------------------
@behaviour AI.Tools
@impl AI.Tools
def async?, do: false
@impl AI.Tools
def is_available?, do: true
@impl AI.Tools
def read_args(args) do
case validate_commands(args) do
:ok ->
case Map.get(args, "operator") do
nil -> {:ok, args}
op when op in ["|", "&&"] -> {:ok, args}
_ -> {:error, :invalid_argument, "operator must be '|' or '&&'"}
end
{:error, reason} ->
{:error, :invalid_argument, reason}
end
end
@impl AI.Tools
def ui_note_on_request(%{"commands" => commands, "description" => desc} = args) do
op = Map.get(args, "operator", "&&")
timeout = sanitize_timeout(args)
command = format_commands(op, commands)
{"(timeout: #{div(timeout, 1000)}s) cmd> #{command}", desc}
end
def ui_note_on_request(other) do
{"cmd", "Invalid JSON args: #{inspect(other)}"}
end
@impl AI.Tools
def ui_note_on_result(%{"commands" => commands} = args, result) do
op = Map.get(args, "operator", "&&")
command = format_commands(op, commands)
{"cmd> #{command}", result}
end
@impl AI.Tools
def tool_call_failure_message(_args, _error_message), do: :default
@impl AI.Tools
def spec do
{os_family, os_name} = :os.type()
allowed =
Services.Approvals.Shell.preapproved_cmds()
|> Enum.map(&"- #{&1}")
|> Enum.join("\n")
user_prefixes =
Services.Approvals.Shell.list_user_prefixes()
|> Enum.map(&"- #{&1}")
|> Enum.join("\n")
|> case do
"" -> "(none)"
s -> s
end
user_regexes =
Services.Approvals.Shell.list_user_regexes()
|> Enum.map(&"- /#{&1}/")
|> Enum.join("\n")
|> case do
"" -> "(none)"
s -> s
end
%{
type: "function",
function: %{
name: "cmd_tool",
description: """
Runs commands via Elixir's System.cmd/3 - NOT through a shell interpreter.
Each entry in the `commands` array is executed directly as a process with
its own `command` and `args`. The `operator` field determines how commands
are combined: `|` pipes stdout between them, `&&` runs them sequentially.
This is NOT a shell. Shell syntax (pipes, redirects, semicolons, subshells,
env var expansion) has no meaning inside `command` or `args` fields. To pipe
or chain commands, use the `operator` field with multiple `commands` entries.
Use for: filesystem ops (rm, mv, mkdir), project commands (git, make, npm),
and CLI tools needed for your task. Prefer specialized tools when available.
Check tool availability with `which <tool>` or `command -v <tool>`.
Execution context:
- Working directory: the project's source root
- No TTY - some commands behave differently
- Glob patterns (*.ex, src/**/*.ts) are NOT expanded; pass literal paths
- Environment variables are NOT expanded in args
- Shell binaries (bash, sh, zsh) cannot be invoked directly
- Interactive commands will hang and time out
Approval rules:
- File-modifying commands (awk, find -exec, patch) require user approval
- Read-only sed (without -i/-f/e/w) is preapproved
- Unapproved commands may be auto-denied if the user is not monitoring
Command-specific notes:
- rg: must include an explicit path arg when under '&&' or as first pipeline stage
- wc: must have file args or receive pipeline input; reading from stdin is not supported
For commands that vary by OS (grep/sed), the current OS is: #{os_name} (#{os_family}).
Available tools on PATH:
#{available_tools()}
Preapproved commands/patterns:
#{allowed}
#{user_prefixes}
#{user_regexes}
""",
parameters: %{
type: "object",
required: ["description", "commands"],
additionalProperties: false,
properties: %{
description: %{
type: "string",
description: """
Explain to the user what the command does and why it is needed.
This will be displayed to the user in the approval dialog.
"""
},
timeout_ms: %{
type: "integer",
description: """
Optional execution timeout in milliseconds.
Defaults to #{@default_timeout_ms}.
Must be > 0 and ≤ #{@max_timeout_ms}.
"""
},
operator: %{
type: "string",
enum: ["|", "&&"],
default: "&&",
description: """
Optional: Determines how the `commands` array entries are combined. Defaults to `&&` when omitted.
`|` pipes stdout of each command into stdin of the next: cmd1 | cmd2 | cmd3
`&&` runs commands sequentially, stopping on failure: cmd1 && cmd2 && cmd3
The operator is inserted BETWEEN each entry in the `commands` array.
Do NOT place the operator inside any command's `args`.
"""
},
commands: %{
type: "array",
description: """
A list of commands to execute, combined using the `operator`.
Each entry is a separate command stage with its own `command` and `args`.
WRONG - do not embed shell operators in args:
```json
[{"command": "git", "args": ["log", "--oneline", "|", "head", "-5"]}]
```
The `|` above becomes a literal argument to `git`, not a pipe.
CORRECT - pipeline (operator: "|"):
```json
[
{"command": "git", "args": ["log", "--oneline"]},
{"command": "head", "args": ["-5"]}
]
```
CORRECT - sequential (operator: "&&"):
```json
[
{"command": "mkdir", "args": ["-p", "out"]},
{"command": "cp", "args": ["src/config.json", "out/"]}
]
```
""",
items: %{
type: "object",
description: "An individual command within the overall pipeline.",
required: ["command", "args"],
additionalProperties: false,
properties: %{
command: %{
type: "string",
description: """
The base command to execute, without any arguments or options.
MUST be either:
1. A bare command name on the user's PATH (e.g., "git", "rg", "npm")
2. An abs path **starting with `./`** within the project (eg "./fnord" or "./scripts/run-tests.sh")
Relative paths are NOT allowed for security reasons.
"""
},
args: %{
type: "array",
description: """
A list of options to pass to the command.
DO NOT include the command itself.
""",
items: %{
type: "string",
description: """
A single argument or option for the command.
Do not escape or quote.
Env vars are NOT expanded.
Shell operators (|, &&, ;, >, <, >>) are NOT valid args.
To pipe or chain commands, use the `operator` field with
multiple entries in the `commands` array.
"""
}
}
}
}
}
}
}
}
}
end
@impl AI.Tools
def call(opts) do
with {:ok, desc} <- AI.Tools.get_arg(opts, "description"),
{:ok, commands} <- AI.Tools.get_arg(opts, "commands"),
timeout_ms <- sanitize_timeout(opts),
{:ok, project} <- Store.get_project() do
opts
|> Map.get("operator", "&&")
# NOTE: do NOT resolve commands before approvals; we want approvals to see
# the literal command string (e.g. ./make) and we want missing commands to
# error out cleanly without having to approve them first.
|> route(commands, desc, timeout_ms, project.source_root)
end
end
# ----------------------------------------------------------------------------
# Validation and argument processing
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Sanitize timeout to be a positive integer within allowed range
# ----------------------------------------------------------------------------
defp sanitize_timeout(opts) do
opts
|> Map.get("timeout_ms", @default_timeout_ms)
|> to_integer()
|> clamp_timeout()
end
# Coerce timeout value to integer. JSON decoders and LLMs may deliver
# the value as a float (e.g. 300000.0) or string ("300000") even though
# the spec declares it as integer. Params coercion handles the common
# cases, but this is a safety net for any code path that bypasses it.
defp to_integer(val) when is_integer(val), do: val
defp to_integer(val) when is_float(val), do: trunc(val)
defp to_integer(val) when is_binary(val) do
case Integer.parse(val) do
{i, ""} -> i
_ -> @default_timeout_ms
end
end
defp to_integer(_), do: @default_timeout_ms
defp clamp_timeout(val) when val <= 0, do: @default_timeout_ms
defp clamp_timeout(val) when val > @max_timeout_ms, do: @max_timeout_ms
defp clamp_timeout(val), do: val
# ----------------------------------------------------------------------------
# Resolve commands to absolute paths where possible.
# ----------------------------------------------------------------------------
defp resolve_commands(commands, root) do
commands
|> Enum.reduce_while([], fn command, acc ->
command
|> find_executable(root)
|> case do
{:ok, resolved_cmd} -> {:cont, [resolved_cmd | acc]}
{:error, msg} -> {:halt, {:error, msg}}
end
end)
|> case do
{:error, msg} -> {:error, msg}
resolved_commands -> {:ok, Enum.reverse(resolved_commands)}
end
end
# ----------------------------------------------------------------------------
# Command resolution (project-aware)
#
# LLMs *love* `rg`, but they often forget to provide a path argument, even
# when it's explicitly called out in the tool spec. This special case adds
# the project's source_root as a path argument if no other path-like
# arguments are provided. If the LLM provides a pattern arg that *looks* like
# a path, and no other path-like args, we can't do anything sensible, since
# we don't know *which* arg they borked, so we just let the command fail.
# -----------------------------------------------------------------------------
defp find_executable(command, root) when is_binary(command) do
cond do
String.starts_with?(command, "/") ->
path = Path.expand(command)
if executable?(path), do: {:ok, path}, else: {:error, :not_found}
String.starts_with?(command, "~") ->
path = Path.expand(command)
if executable?(path), do: {:ok, path}, else: {:error, :not_found}
String.starts_with?(command, "./") ->
# explicit local relative path: interpret relative to project root, but only if it stays within
# the project root after expansion (fail closed on ../ escapes).
path = Path.expand(command, root)
cond do
Util.path_within_root?(path, root) and executable?(path) -> {:ok, path}
true -> {:error, :not_found}
end
String.contains?(command, "/") ->
# Any other slash-containing command is rejected: local execution must be explicit via "./".
{:error, :not_found}
true ->
# bare command: resolve ONLY via PATH
case System.find_executable(command) do
nil -> {:error, :not_found}
path -> {:ok, path}
end
end
end
defp find_executable(%{"command" => command, "args" => args} = cmd, root)
when is_list(args) do
result =
if String.contains?(command, " ") do
command
|> find_executable(root)
|> case do
{:ok, resolved} ->
{:ok, %{"command" => resolved, "args" => args}}
# Try splitting on spaces and see if the first part is executable
{:error, :not_found} ->
[base | extra_args] = String.split(command, " ")
case find_executable(base, root) do
{:ok, resolved} -> {:ok, %{"command" => resolved, "args" => extra_args ++ args}}
{:error, :not_found} -> {:error, "Command not found: #{format_command(cmd)}"}
end
end
else
case find_executable(command, root) do
{:ok, resolved} -> {:ok, %{"command" => resolved, "args" => args}}
{:error, :not_found} -> {:error, "Command not found: #{format_command(cmd)}"}
end
end
# LLMs frequently stutter the command name into the first arg, e.g.
# command: "git", args: ["git", "status", ...]. Deduplicate when the
# resolved executable's basename matches the first argument.
case result do
{:ok, %{"command" => resolved, "args" => [first | rest]} = resolved_cmd} ->
if Path.basename(resolved) == first do
{:ok, %{resolved_cmd | "args" => rest}}
else
{:ok, resolved_cmd}
end
other ->
other
end
end
defp find_executable(%{"command" => _} = cmd, _root) do
cmd
|> Map.put("args", [])
|> find_executable("")
end
defp executable?(path) do
case File.stat(path) do
{:ok, %File.Stat{mode: mode}} -> :erlang.band(mode, 0o111) != 0
_ -> false
end
end
# ----------------------------------------------------------------------------
# Context-aware validation for rg and wc
# We fail-closed in two specific contexts to avoid timeouts and ambiguity:
# - When operator is '&&': stdin is not piped, so commands must have explicit input.
# - When operator is '|' and this is the first stage: no prior stdin exists yet.
# In these cases:
# - rg must include an explicit path-like positional arg (e.g., '.', './dir', '/abs', or a non-flag token).
# - wc must include file args (non-flag tokens). Use of '-' (stdin) is disallowed because reading from stdin can hang the command.
# Anywhere else, these commands are allowed (e.g., later stages in a pipeline).
# ----------------------------------------------------------------------------
defp validate_command_context(op, commands, root) do
case op do
"&&" ->
if invalid_under_andand?(commands, root) do
{:denied,
"""
One or more commands require an explicit input source under '&&'.
- rg must include an explicit path (e.g., '.' or a directory) when not reading from stdin.
- wc must include file args (e.g., 'wc -l FILE') or '-' for stdin.
Consider using a pipeline (|) or adding explicit args.
"""}
else
:ok
end
"|" ->
case commands do
[first | _] ->
if invalid_first_stage?(first, root) do
{:denied,
"First stage requires an explicit input source:\n" <>
"- rg must include an explicit path (e.g., '.' or a directory).\n" <>
"- wc must include file args (e.g., 'wc -l FILE')."}
else
:ok
end
_ ->
:ok
end
end
end
defp invalid_under_andand?(commands, root) do
Enum.any?(commands, fn %{"command" => cmd, "args" => args} ->
base = Path.basename(cmd)
case base do
"rg" -> not rg_has_pathlike_positional?(args, root)
"wc" -> not has_files_only?(args)
_ -> false
end
end)
end
defp invalid_first_stage?(%{"command" => cmd, "args" => args}, root) do
base = Path.basename(cmd)
case base do
"rg" -> not rg_has_pathlike_positional?(args, root)
"wc" -> not has_files_only?(args)
_ -> false
end
end
defp rg_has_pathlike_positional?(args, root) when is_list(args) do
args
|> rg_candidate_paths()
|> Enum.any?(fn token ->
path_token_within_root_exists?(token, root) || glob_within_root?(token, root)
end)
end
defp rg_candidate_paths(args) when is_list(args) do
args
|> do_rg(false, false)
|> Enum.reverse()
end
defp do_rg([], _after_dd, _pattern_seen), do: []
defp do_rg(["--" | rest], _after_dd, pattern_seen), do: do_rg(rest, true, pattern_seen)
defp do_rg(["-e", _pattern | rest], after_dd, _pattern_seen), do: do_rg(rest, after_dd, true)
defp do_rg(["--regexp", _pattern | rest], after_dd, _pattern_seen),
do: do_rg(rest, after_dd, true)
defp do_rg([arg | rest], after_dd, pattern_seen) do
cond do
String.starts_with?(arg, "-") and not after_dd ->
do_rg(rest, after_dd, pattern_seen)
not pattern_seen ->
do_rg(rest, after_dd, true)
true ->
[arg | do_rg(rest, after_dd, true)]
end
end
defp path_token_within_root_exists?(token, root) do
case expand_within_root(token, root) do
{:ok, path} -> File.exists?(path)
_ -> false
end
end
defp expand_within_root(token, root) do
expanded = Path.expand(token, root)
if Util.path_within_root?(expanded, root) do
{:ok, expanded}
else
{:error, :out_of_root}
end
end
defp glob_within_root?(pattern, root) do
pattern_path = Path.join(root, pattern)
try do
case Path.wildcard(pattern_path) do
[] -> false
matches -> Enum.any?(matches, &Util.path_within_root?(&1, root))
end
rescue
_ in [ErlangError, ArgumentError] -> false
end
end
# Helper for wc: file args (non-flag tokens) or '-' for stdin
defp has_files_only?(args) when is_list(args) do
Enum.any?(args, fn a -> not String.starts_with?(a, "-") end) &&
not Enum.any?(args, &(&1 == "-"))
end
# ----------------------------------------------------------------------------
# Execution and routing
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# The fact that this function exists at all is so, *so* frustrating.
# ----------------------------------------------------------------------------
defp route(op, commands, desc, timeout_ms, root) do
# Preflight checks run before any encoding or execution so that all code
# paths (including encoding-failure fallback) are covered.
has_shell_invocation? =
Enum.any?(commands, fn %{"command" => cmd, "args" => args} ->
is_version_check? = "--version" in args
is_shell? = Path.basename(cmd) in ~w(sh ash bash csh dash fish ksh tcsh zsh)
is_shell? and !is_version_check?
end)
# The cmd_tool is available outside of edit mode, so we need to
# double-check that the user actually wants to allow editing before
# we let an apply_patch through.
is_apply_patch? =
Enum.any?(commands, fn %{"command" => cmd, "args" => args} ->
cond do
cmd =~ ~r/\b(z|ba)?sh .*? <<<.*?\b(patch|apply_patch|git apply)\b/ ->
true
Path.basename(cmd) in ["bash", "sh", "zsh"] and
Enum.any?(args, fn a ->
String.contains?(a, "apply_patch") or String.contains?(a, "patch")
end) ->
true
Path.basename(cmd) in ["apply_patch", "patch"] ->
true
Path.basename(cmd) == "git" and "apply" in args ->
true
true ->
false
end
end)
is_fnord_help? =
Enum.count(commands) == 1 &&
Enum.any?(commands, fn %{"command" => cmd, "args" => args} ->
Path.basename(cmd) == "fnord" &&
(Enum.any?(args, &String.contains?(&1, "help")) ||
Enum.any?(args, &String.contains?(&1, "--help")) ||
Enum.any?(args, &String.contains?(&1, "-h")))
end)
is_edit_mode? = Settings.get_edit_mode()
cond do
is_fnord_help? ->
UI.info("Oof", "The LLM called cmd_tool for its own help text. Rerouting.")
AI.Tools.SelfHelp.Cli.call(%{})
# apply_patch check must come before the generic shell invocation denial,
# because LLMs often wrap apply_patch in `bash -lc "apply_patch ..."` which
# triggers both detections. We want to re-route to the RePatcher, not deny.
is_edit_mode? and is_apply_patch? ->
UI.info("Oof", "The LLM attempted to apply a patch with cmd_tool. Rerouting.")
# Encode the commands for ApplyPatch - it expects a JSON patch string.
# Wrap the result so the coordinator knows the changes were already
# applied by the re-route. Without this, the coordinator sees a
# cmd_tool success and may attempt the same edit again with stale
# hashes, not realizing the file was already modified.
json =
case SafeJson.encode(commands, pretty: true) do
{:ok, j} -> j
_ -> inspect(commands)
end
case AI.Tools.ApplyPatch.call(%{"patch" => json}) do
{:ok, msg} ->
{:ok,
"NOTE: Your apply_patch was intercepted and applied via file_edit_tool. " <>
"The changes below have ALREADY been made to the file(s). " <>
"Do NOT attempt to apply them again.\n\n#{msg}"}
{:error, msg} ->
{:error,
"NOTE: Your apply_patch was intercepted and routed to file_edit_tool, " <>
"but the edit failed. Some changes MAY have been partially applied. " <>
"Re-read the target file(s) before deciding whether to retry.\n\n#{msg}"}
end
is_apply_patch? and not is_edit_mode? ->
{:denied, "Cannot edit files; the user did not pass --edit."}
has_shell_invocation? ->
{:denied,
"""
Execute commands directly; do not invoke through a shell.
If you need specific shell features, write a temp script file
and execute that directly.
"""}
true ->
run_as_shell_commands(op, commands, desc, timeout_ms, root)
end
end
defp run_as_shell_commands(_, [%{"command" => "echo", "args" => [msg]}], _, _, _) do
AI.Tools.Notify.call(%{"level" => "info", "message" => msg})
{:ok, {"#{msg}\n", 0}}
end
defp run_as_shell_commands(op, commands, desc, timeout_ms, root) do
case validate_command_context(op, commands, root) do
{:denied, msg} ->
{:denied, msg}
:ok ->
# Ask approvals about the original (unresolved) commands first. If approved,
# resolve to concrete executables using the project root and then run.
{op, commands, desc}
|> Services.Approvals.confirm(:shell)
|> case do
{:ok, :approved} ->
case resolve_commands(commands, root) do
{:ok, resolved} -> run_pipeline(op, resolved, timeout_ms, root)
{:error, reason} -> {:error, reason}
end
other ->
other
end
end
end
# ----------------------------------------------------------------------------
# Run the commands as a shell pipeline or sequence, collecting the output.
# ----------------------------------------------------------------------------
defp run_pipeline(op, commands, timeout_ms, root, acc \\ nil)
defp run_pipeline(_, [], _, _, ""), do: {:ok, "(no output)"}
defp run_pipeline(_, [], _, _, acc), do: {:ok, acc}
defp run_pipeline(op, [command | rest], timeout_ms, root, acc) do
input =
case op do
"|" -> acc
"&&" -> nil
end
UI.report_step("Executing command", format_command(command))
command
|> shell_out(timeout_ms, root, input)
|> case do
{:error, :timeout} ->
{:ok,
"""
Command: #{format_command(command)}
Error: timed out after #{timeout_ms} ms
Remember that interactive commands will always time out.
Some commands behave differently outside of an interactive shell.
"""}
{:ok, {out, 0}} ->
case op do
"|" ->
run_pipeline(op, rest, timeout_ms, root, out)
"&&" ->
acc = """
#{acc}
$ #{format_command(command)}
#{out}
"""
run_pipeline(op, rest, timeout_ms, root, acc)
end
# Some commands exit non-zero even when behaving as expected, like grep.
# In this case, we still want to return the output, but in a true shell
# pipeline, it would still stop processing the rest of the commands.
{:ok, {out, code}} ->
{:ok,
"""
#{acc}
-----
Command: #{format_command(command)}
Exit status: #{code}
Output:
#{out}
"""}
end
end
defp shell_out(%{"command" => cmd, "args" => args}, timeout_ms, root, nil) do
run_with_timeout(timeout_ms, fn ->
{:ok, System.cmd(cmd, args, cd: root, stderr_to_stdout: true)}
end)
end
defp shell_out(%{"command" => cmd, "args" => args}, timeout_ms, root, stdin) do
Util.Temp.with_tmp(stdin, fn tmp ->
# Ensure stdin temp file is not world/group readable regardless of umask
with :ok <- File.chmod(tmp, 0o600),
{:ok, runner} <- Briefly.create(),
:ok <- File.write(runner, @runner),
:ok <- File.chmod(runner, 0o700) do
run_with_timeout(
timeout_ms,
fn ->
try do
{:ok, System.cmd(runner, [tmp, cmd | args], cd: root, stderr_to_stdout: true)}
after
# normal completion cleanup
File.rm(tmp)
File.rm(runner)
end
end,
on_timeout: fn ->
# timeout cleanup fallback
File.rm(tmp)
File.rm(runner)
end
)
end
end)
end
# ----------------------------------------------------------------------------
# Runs a function with the specified `timeout`, returning `{:error,
# :timeout}` if it times out.
# ----------------------------------------------------------------------------
defp run_with_timeout(timeout, fun, opts \\ []) do
on_timeout = Keyword.get(opts, :on_timeout, fn -> :ok end)
task =
Services.Globals.Spawn.async(fn ->
try do
fun.()
rescue
e -> {:error, e}
end
end)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} ->
result
nil ->
# task did not respond, was force-killed
on_timeout.()
{:error, :timeout}
{:exit, _reason} ->
on_timeout.()
{:error, :timeout}
end
end
# ----------------------------------------------------------------------------
# Argument validation helpers
# ----------------------------------------------------------------------------
defp validate_commands(%{"commands" => commands}) when is_list(commands) do
commands
|> Enum.with_index()
|> Enum.reduce_while(:ok, fn {cmd, idx}, _acc ->
case validate_command(cmd, idx) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
defp validate_commands(%{"commands" => commands}) do
{:error, "commands must be a list, got: #{inspect(commands)}"}
end
defp validate_commands(_) do
{:error, "missing required field 'commands'"}
end
defp validate_command(%{"command" => cmd, "args" => args}, idx)
when is_binary(cmd) and is_list(args) do
# Check that all args are strings
case Enum.all?(args, &is_binary/1) do
true -> :ok
false -> {:error, "command[#{idx}].args must be a list of strings"}
end
end
defp validate_command(%{"command" => cmd}, _idx) when is_binary(cmd) do
# args is optional, can be missing
:ok
end
defp validate_command(cmd, idx) do
{:error,
"command[#{idx}] invalid format: expected {command: string, args: [strings]}, got: #{inspect(cmd)}"}
end
# ----------------------------------------------------------------------------
# Formatting helpers
# ----------------------------------------------------------------------------
defp format_commands(op, commands) do
try do
commands
|> Enum.map(&format_command/1)
|> Enum.join(" #{op} ")
rescue
_ -> "Invalid command list: #{inspect(commands, pretty: true)}"
end
end
# Check if an argument needs quoting (non-flag arguments)
defp needs_quoting?(arg) do
# Only quote non-flag arguments that contain a space
!String.starts_with?(arg, "-") && String.contains?(arg, " ")
end
# Escape and quote an argument for display
defp quote_arg(arg) do
# Escape backslashes and double quotes
escaped =
arg
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
"\"#{escaped}\""
end
defp format_command(%{"command" => command, "args" => args}) do
formatted_args =
Enum.map(args, fn arg ->
if needs_quoting?(arg) do
quote_arg(arg)
else
arg
end
end)
[command | formatted_args]
|> Enum.join(" ")
end
defp format_command(invalid_format) do
"Invalid command format: #{inspect(invalid_format, pretty: true)}"
end
# ----------------------------------------------------------------------------
# Available tools
# ----------------------------------------------------------------------------
@non_posix_tools_memo_table :cmd_tools_cache
@non_posix_tools [
["ack", "--version"],
["ag", "--version"],
["docker", "--version"],
["docker-compose", "--version"],
["expect", "-v"],
["fd", "--version"],
["fzf", "--version"],
["gh", "--version"],
["git", "--version"],
["helm", "--version"],
["kubectl", "version", "--client=true"],
["perl", "-e", "print \"$^V\\n\""],
["rg", "--version"]
]
defp available_tools do
# Create memoization table if it doesn't exist
if :ets.info(@non_posix_tools_memo_table) == :undefined do
:ets.new(@non_posix_tools_memo_table, [:named_table, :public, read_concurrency: true])
end
# Check if we have a cached value
case :ets.lookup(@non_posix_tools_memo_table, :cached) do
[{:cached, result}] ->
result
[] ->
result =
@non_posix_tools
# Filter out tools that are not on our PATH
|> Util.async_filter(fn [cmd | _] ->
cmd
|> System.find_executable()
|> case do
nil -> false
_ -> true
end
end)
# For each remaining tool, run the command to get its version
|> Util.async_stream(fn [cmd | args] ->
case System.cmd(cmd, args, stderr_to_stdout: true) do
{out, 0} ->
out
|> String.split("\n")
|> List.first()
|> String.trim()
|> then(&"- #{cmd}: #{&1}")
_ ->
"- #{cmd}: (unknown version)"
end
end)
# Resolve async_stream results, ignoring any errors
|> Enum.reduce([], fn
{:ok, line}, acc -> [line | acc]
{:error, _}, acc -> acc
end)
|> Enum.sort()
|> Enum.join("\n")
:ets.insert(@non_posix_tools_memo_table, {:cached, result})
result
end
end
end