Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
version: 2
updates:
- package-ecosystem: "mix"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "elixir"
commit-message:
prefix: "deps"
include: "scope"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "ci"
commit-message:
prefix: "deps"
include: "scope"
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ jobs:
with:
otp_versions: '["27", "28"]'
elixir_versions: '["1.18", "1.19"]'
experimental_compile_elixir_versions: '["v1.20.0-rc.4"]'
experimental_compile_otp_versions: '["28.4.1"]'
experimental_compile_otp_name: "28"
test_command: mix test
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ on:
required: false
type: boolean
default: false
version_override:
description: "Optional bare SemVer override (for example 1.2.3, not v1.2.3)"
required: false
type: string
default: ""

permissions:
contents: write
Expand All @@ -30,4 +35,5 @@ jobs:
dry_run: ${{ inputs.dry_run }}
hex_dry_run: ${{ inputs.hex_dry_run }}
skip_tests: ${{ inputs.skip_tests }}
version_override: ${{ inputs.version_override }}
secrets: inherit
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ This package maps OpenCode JSON output into normalized `Jido.Harness.Event` stru
Add dependencies:

```elixir
{:jido_harness, "~> 0.1"}
{:jido_opencode, "~> 0.1"}
{:jido_harness, github: "agentjido/jido_harness", branch: "main", override: true}
{:jido_opencode, github: "agentjido/jido_opencode", branch: "main"}
```

This repo is currently aligned as part of the GitHub-based harness package set rather than a Hex release line.

Then install deps:

```bash
Expand All @@ -37,10 +39,10 @@ request = Jido.Harness.RunRequest.new!(%{prompt: "Summarize changes", cwd: "/rep

## Runtime Requirements (Z.AI v1)

- Required env: `ZAI_API_KEY`
- Optional env: `ZAI_API_KEY` when using env-based Z.AI auth
- Optional env:
- `ZAI_BASE_URL` (defaulted in runtime contract to `https://api.z.ai/api/anthropic`)
- `OPENCODE_MODEL` (defaulted to `zai_custom/glm-4.5-air`)
- `OPENCODE_MODEL` (defaulted to `zai-coding-plan/glm-4.5-air`)
- CLI: `opencode` (install via `npm install -g opencode-ai`)

Helpful tasks:
Expand All @@ -65,10 +67,31 @@ Apache-2.0

## Package Purpose

`jido_opencode` is the OpenCode adapter package for `jido_harness`, currently scoped to Z.AI-compatible runtime/auth flows.
`jido_opencode` is the OpenCode adapter package for `jido_harness`, currently scoped to Z.AI-compatible runtime/auth flows using the installed `zai-coding-plan/*` provider models.

## Testing Paths

- Unit/contract tests: `mix test`
- Full quality gate: `mix quality`
- Optional live checks: `mix opencode.install && mix opencode.compat && mix opencode.smoke "hello"`

## Live Integration Test

`jido_opencode` includes an opt-in live adapter test that runs the real OpenCode CLI through the harness adapter path:

```bash
mix test --include integration test/jido_opencode/integration/adapter_live_integration_test.exs
```

The test auto-loads `.env` and is excluded from default `mix test` runs.

Environment knobs:

- `ZAI_API_KEY` when using env-based OpenCode auth
- `ZAI_BASE_URL` and `OPENCODE_MODEL` for custom endpoint/model selection
- `JIDO_OPENCODE_LIVE_PROMPT` to override the default prompt
- `JIDO_OPENCODE_LIVE_CWD` to override the working directory
- `JIDO_OPENCODE_LIVE_MODEL` to force a specific model
- `JIDO_OPENCODE_LIVE_TIMEOUT_MS` to extend the per-run timeout
- `JIDO_OPENCODE_REQUIRE_SUCCESS=1` to fail unless the terminal event is successful
- `JIDO_OPENCODE_CLI_PATH` to target a non-default OpenCode CLI binary
31 changes: 4 additions & 27 deletions lib/jido_opencode/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ defmodule Jido.OpenCode.Adapter do
"GH_PROMPT_DISABLED" => "1",
"GIT_TERMINAL_PROMPT" => "0",
"ZAI_BASE_URL" => "https://api.z.ai/api/anthropic",
"OPENCODE_MODEL" => "zai_custom/glm-4.5-air"
"OPENCODE_MODEL" => "zai-coding-plan/glm-4.5-air"
},
runtime_tools_required: ["opencode"],
compatibility_probes: [
Expand All @@ -80,35 +80,12 @@ defmodule Jido.OpenCode.Adapter do
}
],
auth_bootstrap_steps: [
"""
cat > opencode.json <<'JIDO_OPENCODE_CONFIG_EOF'
{
"$schema": "https://opencode.ai/config.json",
"model": "{env:OPENCODE_MODEL}",
"provider": {
"zai_custom": {
"name": "Z.AI (Anthropic-compatible)",
"npm": "@ai-sdk/anthropic",
"options": {
"baseURL": "{env:ZAI_BASE_URL}",
"apiKey": "{env:ZAI_API_KEY}"
},
"models": {
"glm-4.5-air": {},
"glm-4.7": {},
"glm-5": {}
}
}
}
}
JIDO_OPENCODE_CONFIG_EOF
""",
"opencode models zai_custom 2>&1 | grep -q 'zai_custom/'"
"opencode models zai-coding-plan 2>&1 | grep -q 'zai-coding-plan/'"
],
triage_command_template:
"if command -v timeout >/dev/null 2>&1; then timeout 120 opencode run --model ${OPENCODE_MODEL:-zai_custom/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; else opencode run --model ${OPENCODE_MODEL:-zai_custom/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; fi",
"if command -v timeout >/dev/null 2>&1; then timeout 120 opencode run --model ${OPENCODE_MODEL:-zai-coding-plan/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; else opencode run --model ${OPENCODE_MODEL:-zai-coding-plan/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; fi",
coding_command_template:
"if command -v timeout >/dev/null 2>&1; then timeout 180 opencode run --model ${OPENCODE_MODEL:-zai_custom/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; else opencode run --model ${OPENCODE_MODEL:-zai_custom/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; fi",
"if command -v timeout >/dev/null 2>&1; then timeout 180 opencode run --model ${OPENCODE_MODEL:-zai-coding-plan/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; else opencode run --model ${OPENCODE_MODEL:-zai-coding-plan/glm-4.5-air} --format json \"$(cat {{prompt_file}})\"; fi",
success_markers: [
%{"type" => "result", "subtype" => "success"},
%{"status" => "success"}
Expand Down
3 changes: 2 additions & 1 deletion lib/jido_opencode/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ defmodule Jido.OpenCode.CLI do
args,
timeout: options.timeout_ms,
cd: options.cwd,
env: env_to_list(options.env)
env: env_to_list(options.env),
pty: true
)
end

Expand Down
2 changes: 1 addition & 1 deletion lib/jido_opencode/options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Jido.OpenCode.Options do
__MODULE__,
%{
cwd: Zoi.string() |> Zoi.nullable() |> Zoi.optional(),
model: Zoi.string() |> Zoi.default("zai_custom/glm-4.5-air"),
model: Zoi.string() |> Zoi.default("zai-coding-plan/glm-4.5-air"),
timeout_ms: Zoi.integer() |> Zoi.default(180_000),
format: Zoi.string() |> Zoi.default("json"),
env: Zoi.map(Zoi.string(), Zoi.string()) |> Zoi.default(%{}),
Expand Down
128 changes: 108 additions & 20 deletions lib/jido_opencode/system_command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,119 @@ defmodule Jido.OpenCode.SystemCommand do
timeout = Keyword.get(opts, :timeout, 5_000)
env = Keyword.get(opts, :env, [])
cd = Keyword.get(opts, :cd)
pty? = Keyword.get(opts, :pty, false)

task =
Task.async(fn ->
cmd_opts =
[stderr_to_stdout: true, env: env]
|> maybe_put(:cd, cd)
with {:ok, exec_program, exec_args} <- resolve_command(program, args, pty?),
{:ok, port} <- open_port(exec_program, exec_args, env, cd) do
read_port(port, deadline_after(timeout), [])
end
rescue
error -> {:error, error}
end

try do
{:ok, System.cmd(program, args, cmd_opts)}
rescue
error -> {:error, error}
end
end)

case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, {output, 0}}} -> {:ok, output}
{:ok, {:ok, {output, status}}} -> {:error, %{status: status, output: output}}
{:ok, {:error, reason}} -> {:error, reason}
{:exit, reason} -> {:error, reason}
nil -> {:error, %{status: :timeout, output: ""}}
defp resolve_command(program, args, true) do
case pty_command(program, args) do
{:ok, wrapped_program, wrapped_args} -> {:ok, wrapped_program, wrapped_args}
:error -> {:ok, program, args}
end
end

defp resolve_command(program, args, false), do: {:ok, program, args}

defp open_port(program, args, env, cd) do
port =
Port.open({:spawn_executable, to_charlist(resolve_executable!(program))}, port_opts(args, env, cd))

{:ok, port}
rescue
error -> {:error, error}
end

defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp read_port(port, deadline_ms, output) do
receive do
{^port, {:data, chunk}} ->
read_port(port, deadline_ms, [output, chunk])

{^port, {:exit_status, 0}} ->
{:ok, normalize_output(IO.iodata_to_binary(output))}

{^port, {:exit_status, status}} ->
{:error, %{status: status, output: normalize_output(IO.iodata_to_binary(output))}}
after
remaining_timeout(deadline_ms) ->
Port.close(port)
{:error, %{status: :timeout, output: normalize_output(IO.iodata_to_binary(output))}}
end
end

defp deadline_after(timeout) do
System.monotonic_time(:millisecond) + timeout
end

defp remaining_timeout(deadline_ms) do
max(deadline_ms - System.monotonic_time(:millisecond), 0)
end

defp port_opts(args, env, cd) do
[
:binary,
:exit_status,
:hide,
:use_stdio,
:stderr_to_stdout,
args: Enum.map(args, &to_charlist/1),
env: Enum.map(env, fn {key, value} -> {to_charlist(key), to_charlist(value)} end)
]
|> maybe_put_tuple(:cd, cd && to_charlist(cd))
end

defp pty_command(program, args) do
case System.find_executable("script") do
nil ->
:error

script ->
case :os.type() do
{:unix, :darwin} ->
{:ok, script, ["-q", "/dev/null", program | args]}

{:unix, _} ->
shell_command =
["exec", shell_escape(program) | Enum.map(args, &shell_escape/1)]
|> Enum.join(" ")

{:ok, script, ["-q", "-e", "-c", shell_command, "/dev/null"]}

_ ->
:error
end
end
end

defp normalize_output(output) do
output
|> String.replace_prefix("^D\b\b", "")
|> String.replace_prefix("^\u0008\u0008", "")
end

defp shell_escape(value) when is_binary(value) do
escaped = String.replace(value, "'", "'\"'\"'")
"'#{escaped}'"
end

defp resolve_executable!(program) when is_binary(program) do
cond do
String.contains?(program, "/") and File.regular?(program) ->
program

resolved = System.find_executable(program) ->
resolved

true ->
raise ArgumentError, "executable not found: #{program}"
end
end

defp maybe_put_tuple(opts, _key, nil), do: opts
defp maybe_put_tuple(opts, key, value), do: [{key, value} | opts]
end
2 changes: 1 addition & 1 deletion lib/mix/tasks/opencode.smoke.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Mix.Tasks.Opencode.Smoke do
Execute a minimal OpenCode prompt for smoke validation.

mix opencode.smoke "Return OK"
mix opencode.smoke "Summarize this repo" --cwd /path --timeout 30000 --model zai_custom/glm-4.5-air
mix opencode.smoke "Summarize this repo" --cwd /path --timeout 30000 --model zai-coding-plan/glm-4.5-air
"""

@shortdoc "Run a minimal OpenCode smoke prompt"
Expand Down
2 changes: 1 addition & 1 deletion test/jido_opencode/adapter_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ defmodule Jido.OpenCode.AdapterTest do
assert "opencode" in contract.runtime_tools_required
assert String.contains?(contract.triage_command_template, "opencode run")
assert String.contains?(contract.coding_command_template, "opencode run")
assert Enum.any?(contract.auth_bootstrap_steps, &String.contains?(&1, "opencode models zai_custom"))
assert Enum.any?(contract.auth_bootstrap_steps, &String.contains?(&1, "opencode models zai-coding-plan"))
end

test "run/2 maps json output to harness events" do
Expand Down
47 changes: 47 additions & 0 deletions test/jido_opencode/integration/adapter_live_integration_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule Jido.OpenCode.Integration.AdapterLiveIntegrationTest do
use ExUnit.Case, async: false
use Jido.OpenCode.LiveIntegrationCase

alias Jido.Harness.RunRequest
alias Jido.OpenCode.Adapter

@integration_skip_reason Jido.OpenCode.LiveIntegrationCase.skip_reason()

if @integration_skip_reason do
@moduletag skip: @integration_skip_reason
end

test "adapter emits a terminal harness event via the real OpenCode CLI", ctx do
attrs =
%{
prompt: ctx.prompt,
cwd: ctx.cwd,
timeout_ms: ctx.timeout_ms,
metadata: %{}
}
|> maybe_put(:model, ctx.model)

request = RunRequest.new!(attrs)

assert {:ok, stream} = Adapter.run(request, ctx.cli_opts)
events = Enum.to_list(stream)

assert events != []
assert Enum.all?(events, &(&1.provider == :opencode))
assert Enum.any?(events, &(&1.type == :session_started))

terminal =
Enum.find(events, fn event ->
event.type in [:session_completed, :session_failed]
end)

assert terminal

if ctx.require_success? do
assert terminal.type == :session_completed
end
end

defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
2 changes: 1 addition & 1 deletion test/jido_opencode/options_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule Jido.OpenCode.OptionsTest do

test "new/1 applies defaults" do
assert {:ok, options} = Options.new(%{})
assert options.model == "zai_custom/glm-4.5-air"
assert options.model == "zai-coding-plan/glm-4.5-air"
assert options.format == "json"
assert options.timeout_ms == 180_000
end
Expand Down
Loading
Loading