zephira 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d274d7a5e1cc6ee59747461f1c134e9de1530bf8c323411f497583a0fa474bea
4
- data.tar.gz: 6eb27eff2cc6241886e50336fa121caa4499ed35464d7e668a55de9ea38d12d0
3
+ metadata.gz: 513cf2754ac8c868ab2cb8452a480c8d9cf323b7fbefb629f929afe70aa3ffff
4
+ data.tar.gz: 6ed7ffcbbbda681d95eeec742c47da212e99ec325d3b72b8c29854e54a4a3dc1
5
5
  SHA512:
6
- metadata.gz: d41752ccd01be4bcf32df9bc282616784d90a59f6fe3392ebab49623d3c7d8c3a360b16e9c3766a9c5c300fb96deada6ca81f245b4962ded019dd33967464d16
7
- data.tar.gz: f5ecfa0bfcfbbe4dd6b260e7f2cf8027f732b4e79c0e8ffe6ed656536357f3e8a498b3586e806ce6afeb1dcb6e6c0cf97cbff1def31f67ded178ed936654b14d
6
+ metadata.gz: 2d2a968b4a48d35b0e1626e2779f782153acc76fc110887a58d53679b53550e0cb433cb27de3e2895c3a81c65a80bfeae049e3ac632a53393ba6592ba4089b58
7
+ data.tar.gz: 402f995425bff9f4d759d1184ba2618ef5c19599fc1b5c3983e142426256232c7dcd78255b2f0d20728faff7cf931447c6ea57ae1f92ebd53734121ec5858b9e
data/.dockerignore ADDED
@@ -0,0 +1,19 @@
1
+ .DS_Store
2
+
3
+ coverage
4
+ log
5
+ tmp
6
+
7
+ .zephira
8
+ .planning
9
+ .claude
10
+ .vendor
11
+ .config
12
+ .cache
13
+ .bundle
14
+ .gem
15
+ .local
16
+
17
+ .env.dev
18
+
19
+ *.gem
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Zephira Changelog
2
2
 
3
+ ## [0.1.4]
4
+
5
+ ### Added
6
+ - First-run onboarding wizard. On a fresh install, Zephira detects that no API key is configured and walks the user through a guided prompt that writes their OpenAI API key to `~/.zephira.yml` (with `0600` permissions). Existing config keys are preserved when merging. Setting `ZEPHIRA_API_KEY` in the environment or running in a non-TTY context bypasses the wizard without prompting.
7
+ - `/reload` slash command. Re-executes the running agent (via `Kernel.exec`) so that local code edits take effect without losing conversation history (which is persisted to `.zephira/history.jsonl`). Routes through `bundle exec` when launched under Bundler, otherwise execs Ruby directly.
8
+ - Podman support. When Docker is unavailable, the sandbox launcher auto-detects Podman and uses it instead.
9
+ - Containerized development helpers under `bin/`: `docker-build` builds the dev image, `docker-shell` runs an interactive bash (or arbitrary command) inside it, and `docker-zephira` launches Zephira against the live mounted workspace. The dev launcher runs the onboarding wizard as a host-side preflight so first-run users get the same experience they'd get from a normal `gem install zephira` invocation.
10
+
11
+ ### Changed
12
+ - README quickstart rewritten — first-run users no longer need to hand-edit `~/.zephira.yml`; the wizard handles it.
13
+ - Dockerfile now bakes Git and ripgrep into the runtime image so in-sandbox tool calls don't have to reinstall them on first use.
14
+ - `.dockerignore` added to trim the build context and stop committed gem artifacts from polluting the runtime image.
15
+ - `*.gem` is now gitignored so locally-built release artifacts are no longer accidentally committed.
16
+
17
+ ### Fixed
18
+ - `code_search` tool now resolves ripgrep correctly inside the sandbox image.
19
+
20
+ ### Tests
21
+ - Suite: 449 examples, 0 failures, ~96% line coverage.
22
+
3
23
  ## [0.1.3]
4
24
 
5
25
  ### Changed
data/Dockerfile CHANGED
@@ -8,6 +8,7 @@ RUN apt-get update -qq && \
8
8
  apt-get install -y --no-install-recommends \
9
9
  build-essential \
10
10
  git \
11
+ ripgrep \
11
12
  && rm -rf /var/lib/apt/lists/*
12
13
 
13
14
  COPY Gemfile Gemfile.lock zephira.gemspec ./
@@ -29,9 +30,16 @@ LABEL org.opencontainers.image.licenses="MIT"
29
30
 
30
31
  RUN apt-get update -qq && \
31
32
  apt-get install -y --no-install-recommends \
33
+ git \
32
34
  libreadline-dev \
35
+ ripgrep \
33
36
  && rm -rf /var/lib/apt/lists/*
34
37
 
38
+ RUN groupadd --system zephira && \
39
+ useradd --system --gid zephira --create-home --shell /bin/bash zephira && \
40
+ mkdir -p /workspace && \
41
+ chown zephira:zephira /workspace
42
+
35
43
  COPY --from=deps /usr/local/bundle /usr/local/bundle
36
44
  COPY --from=deps /build/zephira-*.gem /tmp/
37
45
 
@@ -40,4 +48,6 @@ RUN gem install --local --no-document /tmp/zephira-*.gem && \
40
48
 
41
49
  WORKDIR /workspace
42
50
 
51
+ USER zephira
52
+
43
53
  CMD ["zephira"]
data/README.md CHANGED
@@ -1,13 +1,32 @@
1
1
  # Zephira
2
2
 
3
- Zephira is a command-line AI coding assistant written in Ruby.
3
+ A command-line AI coding assistant written in Ruby. Runs in your terminal, keeps per-project conversation history, and executes safely contained inside a Docker or Podman sandbox by default.
4
4
 
5
- It runs in your terminal, keeps per-project conversation history, calls a pluggable set of tools, and executes inside a Docker sandbox by default so the agent cannot touch the host system unless you opt out. The codebase is small, plugin-based, and intended to be read end-to-end.
5
+ ## Quickstart
6
+
7
+ 1. Install Docker or Podman — required for the sandbox.
8
+ - Docker: https://docs.docker.com/get-docker/
9
+ - Podman: https://podman.io/getting-started/installation
10
+
11
+ 2. Install the gem:
12
+
13
+ ```sh
14
+ gem install zephira
15
+ ```
16
+
17
+ 3. Run it from any project directory:
18
+
19
+ ```sh
20
+ zephira
21
+ ```
22
+
23
+ On first run, Zephira launches an onboarding wizard that prompts for your OpenAI API key and writes it to `~/.zephira.yml` (with `0600` permissions). You can also set `ZEPHIRA_API_KEY` in your environment to skip the wizard entirely. To target a different OpenAI-compatible endpoint, set `ZEPHIRA_BASE_URL` alongside your key.
6
24
 
7
25
  ## Features
8
26
 
9
27
  - Interactive terminal chat loop with per-session token-budget tracking and automatic history compaction
10
- - Built-in slash commands: `/help`, `/about`, `/model`, `/history`, `/compact`, `/clear`, `/bye`
28
+ - Built-in slash commands: `/help`, `/about`, `/model`, `/history`, `/compact`, `/clear`, `/reload`, `/bye`
29
+ - First-run onboarding wizard that captures your API key the first time you launch — no manual `~/.zephira.yml` editing required
11
30
  - Plugin-style tool system — drop a file in `lib/zephira/tools/` and it is auto-loaded:
12
31
  - file I/O: `read_file`, `update_file`, `delete_file`, `list_directory`
13
32
  - search: `code_search` (ripgrep-backed), `web_search` (Brave Search API)
@@ -16,25 +35,20 @@ It runs in your terminal, keeps per-project conversation history, calls a plugga
16
35
  - Concurrent execution of read-only tool calls in a single turn (mutating tools still run sequentially in declared order)
17
36
  - Pluggable model + backend layer — register a new model by dropping a file in `lib/zephira/models/`; backends bind per model class
18
37
  - OpenAI-compatible backend out of the box; structured to add provider-specific backends without forking the core loop
19
- - Docker sandbox enabled by default; `--dangerously-skip-sandbox` to opt out
38
+ - Docker or Podman sandbox enabled by default; `--dangerously-skip-sandbox` to opt out
20
39
  - Persistent session log + conversation history under `.zephira/` in each project
21
40
  - ~95% line coverage on a focused RSpec suite
22
41
 
23
- ## Installation
24
-
25
- Requirements:
26
-
27
- - Ruby 3.2+
28
- - Bundler
29
- - Docker, if you want sandboxed execution
30
-
31
- Install from RubyGems:
42
+ ## CLI
32
43
 
33
44
  ```sh
34
- gem install zephira
45
+ zephira # start in the current directory
46
+ zephira --help # CLI help
47
+ zephira --version # installed version
48
+ zephira --dangerously-skip-sandbox # run without container isolation (your filesystem is exposed)
35
49
  ```
36
50
 
37
- Or install locally for development:
51
+ ## Local development install
38
52
 
39
53
  ```sh
40
54
  git clone https://github.com/aarongough/zephira.git
@@ -42,52 +56,22 @@ cd zephira
42
56
  bundle install
43
57
  ```
44
58
 
45
- ## Quick start
46
-
47
- Start Zephira in the current project directory:
48
-
49
- ```sh
50
- zephira
51
- ```
52
-
53
- Show CLI help:
54
-
55
- ```sh
56
- zephira --help
57
- ```
58
-
59
- Print the installed version:
60
-
61
- ```sh
62
- zephira --version
63
- ```
64
-
65
- To run without Docker sandboxing:
66
-
67
- ```sh
68
- zephira --dangerously-skip-sandbox
69
- ```
70
-
71
- Warning: skipping the sandbox gives the agent direct access to your real filesystem.
59
+ Requirements: Ruby 3.2+, Bundler, Docker or Podman (for sandboxed execution).
72
60
 
73
61
  ## Configuration
74
62
 
75
- Zephira reads configuration from:
63
+ The first time you run Zephira without an API key configured, the onboarding wizard prompts for one and writes it to `~/.zephira.yml`. You can also configure Zephira manually:
76
64
 
77
65
  - environment variables
78
66
  - `.zephira.yml` in the current project
79
67
  - `~/.zephira.yml` in your home directory
80
68
 
81
- Environment variables take precedence.
69
+ Environment variables take precedence. Setting `ZEPHIRA_API_KEY` in your environment also skips the onboarding wizard, which is the recommended path for CI and other non-interactive contexts.
82
70
 
83
71
  Example configuration:
84
72
 
85
73
  ```yaml
86
- ZEPHIRA_API_KEY: "your_api_key_here"
87
- ZEPHIRA_MODEL: "gpt-4.1-mini"
88
- ZEPHIRA_BASE_URL: "https://api.openai.com/v1"
89
- ZEPHIRA_BACKEND: "openai_compatible"
90
- ZEPHIRA_BASE_IMAGE: "ruby:3.4-slim"
74
+ ZEPHIRA_API_KEY: "openai_API_KEY_HERE"
91
75
  ZEPHIRA_BRAVE_SEARCH_API_KEY: "your_brave_api_key_here"
92
76
  ```
93
77
 
@@ -97,17 +81,23 @@ ZEPHIRA_BRAVE_SEARCH_API_KEY: "your_brave_api_key_here"
97
81
  - `ZEPHIRA_MODEL` — model name to use
98
82
  - `ZEPHIRA_BASE_URL` — base URL for OpenAI-compatible APIs
99
83
  - `ZEPHIRA_BACKEND` — backend adapter identifier
100
- - `ZEPHIRA_BASE_IMAGE` — base Docker image for sandbox execution
84
+ - `ZEPHIRA_BASE_IMAGE` — base container image for sandbox execution
101
85
  - `ZEPHIRA_BRAVE_SEARCH_API_KEY` — required for the web search tool
102
86
  - `ZEPHIRA_SANDBOX` — internal/advanced flag to disable sandboxing
103
87
 
104
88
  ## Sandbox behavior
105
89
 
106
- By default, Zephira attempts to run inside Docker for safer execution.
90
+ By default, Zephira attempts to run inside a container for safer execution.
91
+
92
+ When sandboxing is enabled, Zephira re-executes itself inside Docker or Podman and mounts your current project into `/workspace`. This gives the agent access to the project while helping isolate it from the host system.
93
+
94
+ To keep files created or edited in `/workspace` owned by the host user instead of root, Zephira runs the sandboxed process as your current host UID/GID at container runtime.
107
95
 
108
- When sandboxing is enabled, Zephira re-executes itself inside a container and mounts your current project into `/workspace`. This gives the agent access to the project while helping isolate it from the host system.
96
+ Zephira also mounts your global config into a sandbox-specific home directory inside the container so the agent can read `~/.zephira.yml` and `~/.zephira/` without depending on `/root`.
109
97
 
110
- If Docker is unavailable, Zephira exits with an error and explains how to proceed.
98
+ Zephira prefers Docker when both Docker and Podman are available. If Docker is unavailable but Podman is available, Zephira uses Podman automatically.
99
+
100
+ If neither Docker nor Podman is available, Zephira exits with an error and explains how to proceed.
111
101
 
112
102
  You can bypass sandboxing with:
113
103
 
@@ -128,8 +118,11 @@ Inside Zephira, you can use slash commands:
128
118
  - `/history` — print conversation history
129
119
  - `/compact` — manually compact the conversation history
130
120
  - `/clear` — clear the screen
121
+ - `/reload` — re-execute the agent process to pick up local code changes (conversation history is preserved)
131
122
  - `/bye` — exit the session
132
123
 
124
+ To change your API key after onboarding, edit `~/.zephira.yml` directly on the host, or delete it and re-launch to trigger the wizard again.
125
+
133
126
  ## Available models
134
127
 
135
128
  This repository currently includes model definitions for:
@@ -196,6 +189,26 @@ Run linting:
196
189
  bundle exec standardrb --fix
197
190
  ```
198
191
 
192
+ ### Containerized development
193
+
194
+ For an isolated dev environment that mirrors the shipped sandbox image, the `bin/` directory provides helper scripts. All three rebuild the `zephira-dev` image first (Docker caches layers, so this is a no-op after the first run).
195
+
196
+ - `bin/docker-build` — build the `zephira-dev` image from the current working tree.
197
+ - `bin/docker-zephira` — launch Zephira inside the container, running against the mounted working tree (`bundle exec ruby exe/zephira`). Use this when iterating on the agent itself.
198
+ - `bin/docker-shell [command]` — start an interactive `bash` inside the container, or run an arbitrary command. Useful for running specs or linting against the containerized Ruby:
199
+
200
+ ```sh
201
+ bin/docker-shell # interactive shell
202
+ bin/docker-shell 'bundle exec rspec' # run the suite in-container
203
+ bin/docker-shell 'bundle exec standardrb --fix' # lint in-container
204
+ ```
205
+
206
+ Both runner scripts mount the current directory at `/workspace`, run as the host UID/GID, and mount `~/.zephira.yml` and `~/.zephira/` into the container so configuration and history persist across runs.
207
+
208
+ `bin/docker-zephira` runs the onboarding wizard as a host-side preflight before launching the container, so first-run users get the same prompt-and-write-to-`~/.zephira.yml` experience they'd get with a normal `gem install zephira` invocation.
209
+
210
+ While inside a running Zephira session started this way, the `/reload` slash command re-executes the agent process — picking up edits to `lib/zephira/**` without rebuilding the image or losing conversation history (which is persisted to `.zephira/history.jsonl`). This is the fastest inner loop for iterating on agent code.
211
+
199
212
  ## Design goals
200
213
 
201
214
  Zephira favors:
data/exe/zephira CHANGED
@@ -3,4 +3,5 @@
3
3
 
4
4
  require "zephira"
5
5
 
6
+ Zephira::ORIGINAL_ARGV = ARGV.dup.freeze
6
7
  Zephira::CLI.new(ARGV)
data/lib/zephira/agent.rb CHANGED
@@ -40,6 +40,19 @@ module Zephira
40
40
  You should not try to guess what the user is trying to do, or try to
41
41
  perform operations that are not explicitly requested by the user.
42
42
 
43
+ When investigating a bug, failure, or unexpected behavior:
44
+ - Do not speculate when the relevant code can be inspected.
45
+ - Read the implementation and the nearest calling code before answering.
46
+ - Prefer tracing the full execution path over making a local guess from one file.
47
+ - Reproduce the issue when feasible.
48
+ - Identify the most likely root cause from evidence, not intuition.
49
+ - If the issue appears to be caused by your recent changes, fix it immediately.
50
+ - After making a fix, run the narrowest useful test first, then the full spec suite and the linter before declaring completion.
51
+ - If you cannot complete validation, state exactly what blocked you and the next concrete command to run.
52
+ - Do not stop at partial diagnosis when the next investigative step is obvious and feasible.
53
+
54
+ Before replying on debugging or regression tasks, exhaust the obvious local investigation steps available in the repository and runtime environment. Prefer one evidence-backed answer over several speculative possibilities.
55
+
43
56
  Additional instructions provided by the user. The project-local instructions
44
57
  should overrule the global instructions:
45
58
 
@@ -55,7 +68,7 @@ module Zephira
55
68
  The user's current `ls -R` is: @@@LSR@@@
56
69
  PROMPT
57
70
 
58
- LOGO = <<~'LOGO'
71
+ LOGO = <<~LOGO
59
72
  ░▒▓████████▓▒░░▒▓████████▓▒░░▒▓███████▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓██████▓▒░
60
73
  ░▒▓██▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░░▒▓█▓▒░
61
74
  ░▒▓██▓▒░ ░▒▓██████▓▒░ ░▒▓███████▓▒░ ░▒▓████████▓▒░░▒▓█▓▒░░▒▓███████▓▒░ ░▒▓████████▓▒░
@@ -93,7 +106,7 @@ module Zephira
93
106
  def thinking(model_class)
94
107
  thinkmojis = %w[🤔 🧠 💭 🤯 🧐 ⏳ 🔄 🌀 🤨 💡 🧩 🔍 📚 ⚙️]
95
108
  token_count = Tokens.estimate(history.messages.to_json)
96
- update_status("Thinking... #{thinkmojis.shuffle.first} " + Formatter.color(:grey, "(#{model_class.model_name} - #{token_count} tokens)"))
109
+ update_status("Thinking... #{thinkmojis.sample} " + Formatter.color(:grey, "(#{model_class.model_name} - #{token_count} tokens)"))
97
110
  end
98
111
 
99
112
  def update_status(msg)
@@ -202,8 +215,8 @@ module Zephira
202
215
  print TTY::Cursor.move_to(0, screen_height - 3)
203
216
  puts Formatter.color(:grey, "-" * width)
204
217
 
205
- sandbox_label = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? "sandboxed" : "⚠ DANGER: NO SANDBOX"
206
- sandbox_color = ENV["ZEPHIRA_IN_SANDBOX"] == "1" ? :green : :red
218
+ sandbox_label = (ENV["ZEPHIRA_IN_SANDBOX"] == "1") ? "sandboxed" : "⚠ DANGER: NO SANDBOX"
219
+ sandbox_color = (ENV["ZEPHIRA_IN_SANDBOX"] == "1") ? :green : :red
207
220
  right_text = "ctrl+c to exit | '/help' + enter to see commands | #{context_pct}% context left"
208
221
  padding = [width - sandbox_label.length - right_text.length, 1].max
209
222
  puts Formatter.color(sandbox_color, sandbox_label) + " " * padding + Formatter.color(:grey, right_text)
data/lib/zephira/cli.rb CHANGED
@@ -8,8 +8,9 @@ module Zephira
8
8
 
9
9
  def initialize(argv)
10
10
  ENV["ZEPHIRA_SANDBOX"] = "false" if argv.include?(DANGEROUS_SKIP_SANDBOX_FLAG)
11
- Zephira::Sandbox.exec_if_needed!(argv)
12
11
  option_parser.parse!(argv)
12
+ Zephira::Onboarding.run_if_needed!
13
+ Zephira::Sandbox.exec_if_needed!(argv)
13
14
  Zephira::Agent.new.run_loop
14
15
  rescue OptionParser::InvalidOption
15
16
  puts option_parser
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zephira
4
+ class Commands
5
+ class Reload
6
+ class << self
7
+ def name
8
+ "reload"
9
+ end
10
+
11
+ def description
12
+ "Reload the agent by re-executing the process (picks up code changes)"
13
+ end
14
+
15
+ def run(agent:, args:)
16
+ puts "Reloading…"
17
+ argv = defined?(::Zephira::ORIGINAL_ARGV) ? ::Zephira::ORIGINAL_ARGV : []
18
+ if ENV["BUNDLE_GEMFILE"] && File.exist?(ENV["BUNDLE_GEMFILE"])
19
+ Kernel.exec("bundle", "exec", RbConfig.ruby, $PROGRAM_NAME, *argv)
20
+ else
21
+ Kernel.exec(RbConfig.ruby, $PROGRAM_NAME, *argv)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -14,7 +14,7 @@ module Zephira
14
14
  end
15
15
 
16
16
  Dir.glob(pattern).map do |path|
17
- "@#{path}#{File.directory?(path) ? "/" : ""}"
17
+ "@#{path}#{"/" if File.directory?(path)}"
18
18
  end
19
19
  end
20
20
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "yaml"
5
+
6
+ module Zephira
7
+ class Onboarding
8
+ GLOBAL_CONFIG_PATH = File.expand_path("~/.zephira.yml")
9
+
10
+ OUTER_TL = "╔"
11
+ OUTER_TR = "╗"
12
+ OUTER_BL = "╚"
13
+ OUTER_BR = "╝"
14
+ OUTER_H = "═"
15
+ OUTER_V = "║"
16
+
17
+ class << self
18
+ def run_if_needed!
19
+ return if ENV["ZEPHIRA_IN_SANDBOX"] == "1"
20
+ return unless Config.read("ZEPHIRA_API_KEY").to_s.empty?
21
+
22
+ unless $stdin.tty?
23
+ print_no_tty_error
24
+ exit(1)
25
+ end
26
+
27
+ print_welcome
28
+ key = prompt_key
29
+ if key.nil? || key.empty?
30
+ print_cancelled
31
+ exit(1)
32
+ end
33
+
34
+ write_config!(key)
35
+ print_success
36
+ end
37
+
38
+ private
39
+
40
+ def prompt_key
41
+ print " OpenAI API key: "
42
+ raw = $stdin.noecho(&:gets)
43
+ puts
44
+ return nil if raw.nil?
45
+ stripped = raw.strip
46
+ stripped.empty? ? nil : stripped
47
+ rescue Interrupt
48
+ puts
49
+ nil
50
+ end
51
+
52
+ def write_config!(key)
53
+ existing = File.exist?(GLOBAL_CONFIG_PATH) ? (YAML.load_file(GLOBAL_CONFIG_PATH) || {}) : {}
54
+ merged = existing.merge("ZEPHIRA_API_KEY" => key)
55
+ File.write(GLOBAL_CONFIG_PATH, YAML.dump(merged))
56
+ File.chmod(0o600, GLOBAL_CONFIG_PATH)
57
+ end
58
+
59
+ def print_welcome
60
+ lines = [
61
+ "",
62
+ " #{Formatter.color(:green, Formatter.style(:bold, "Welcome to Zephira"))}",
63
+ "",
64
+ " Zephira talks to OpenAI by default. Paste your OpenAI API key below",
65
+ " and we'll save it to ~/.zephira.yml for future runs.",
66
+ "",
67
+ " To target a different OpenAI-compatible endpoint instead, cancel and",
68
+ " set ZEPHIRA_BASE_URL alongside your key in ~/.zephira.yml.",
69
+ "",
70
+ " Input is hidden. Press Enter on an empty line to cancel.",
71
+ ""
72
+ ]
73
+ render_box(lines, :green)
74
+ puts
75
+ end
76
+
77
+ def print_success
78
+ puts
79
+ puts " #{Formatter.color(:green, "✓")} Saved API key to #{GLOBAL_CONFIG_PATH}"
80
+ puts
81
+ end
82
+
83
+ def print_cancelled
84
+ puts
85
+ puts " #{Formatter.color(:grey, "Cancelled.")} Set ZEPHIRA_API_KEY in your environment"
86
+ puts " or populate ~/.zephira.yml to skip onboarding."
87
+ puts
88
+ end
89
+
90
+ def print_no_tty_error
91
+ warn ""
92
+ warn " #{Formatter.color(:red, "ERROR:")} Zephira needs an OpenAI API key, but stdin is not a TTY."
93
+ warn ""
94
+ warn " Set ZEPHIRA_API_KEY in your environment, or populate"
95
+ warn " ~/.zephira.yml with:"
96
+ warn ""
97
+ warn " ZEPHIRA_API_KEY: \"sk-...\""
98
+ warn ""
99
+ end
100
+
101
+ def render_box(lines, color)
102
+ width = box_width(lines)
103
+ puts Formatter.color(color, OUTER_TL + OUTER_H * (width - 2) + OUTER_TR)
104
+ lines.each { |line| puts box_row(line, width, color) }
105
+ puts Formatter.color(color, OUTER_BL + OUTER_H * (width - 2) + OUTER_BR)
106
+ end
107
+
108
+ def box_row(text, width, color)
109
+ padding = " " * [width - 2 - visible_length(text), 0].max
110
+ Formatter.color(color, OUTER_V) + text + padding + Formatter.color(color, OUTER_V)
111
+ end
112
+
113
+ def box_width(lines)
114
+ content_max = lines.map { |line| visible_length(line) }.max || 0
115
+ desired = content_max + 4
116
+ [desired, terminal_width].min.then { |target| [target, content_max + 4].max }
117
+ end
118
+
119
+ def visible_length(str)
120
+ str.gsub(/\e\[[0-9;]*m/, "").length
121
+ end
122
+
123
+ def terminal_width
124
+ IO.console&.winsize&.last || 80
125
+ rescue
126
+ 80
127
+ end
128
+ end
129
+ end
130
+ end
@@ -5,8 +5,10 @@ require "io/console"
5
5
 
6
6
  module Zephira
7
7
  class Sandbox
8
- GHCR_IMAGE = "ghcr.io/aarongough/zephira"
8
+ GHCR_IMAGE = "ghcr.io/aarongough/zephira"
9
9
  DERIVED_IMAGE_PREFIX = "zephira-sandbox"
10
+ CONTAINER_RUNTIMES = %w[docker podman].freeze
11
+ SANDBOX_HOME = "/tmp/zephira-home"
10
12
 
11
13
  FORWARDED_ENV_PATTERNS = [/\AZEPHIRA_/].freeze
12
14
  FORWARDED_ENV_EXCLUDES = %w[ZEPHIRA_IN_SANDBOX ZEPHIRA_SANDBOX].freeze
@@ -15,15 +17,15 @@ module Zephira
15
17
  OUTER_TR = "╗"
16
18
  OUTER_BL = "╚"
17
19
  OUTER_BR = "╝"
18
- OUTER_H = "═"
19
- OUTER_V = "║"
20
+ OUTER_H = "═"
21
+ OUTER_V = "║"
20
22
 
21
23
  INNER_TL = "┌"
22
24
  INNER_TR = "┐"
23
25
  INNER_BL = "└"
24
26
  INNER_BR = "┘"
25
- INNER_H = "─"
26
- INNER_V = "│"
27
+ INNER_H = "─"
28
+ INNER_V = "│"
27
29
  INNER_PADDING = 3
28
30
 
29
31
  class << self
@@ -31,11 +33,12 @@ module Zephira
31
33
  return if ENV["ZEPHIRA_IN_SANDBOX"] == "1"
32
34
  return if ENV["ZEPHIRA_SANDBOX"] == "false"
33
35
 
34
- abort_with_sandbox_error unless docker_available?
36
+ runtime = container_runtime
37
+ abort_with_sandbox_error unless runtime
35
38
 
36
- target = resolve_image
37
- $stderr.puts "[Zephira] Launching in Docker sandbox (#{target})..."
38
- Kernel.exec(*build_docker_command(argv, target))
39
+ target = resolve_image(runtime)
40
+ warn "[Zephira] Launching in #{runtime.capitalize} sandbox (#{target})..."
41
+ Kernel.exec(*build_container_command(argv, target, runtime))
39
42
  end
40
43
 
41
44
  private
@@ -54,12 +57,15 @@ module Zephira
54
57
 
55
58
  instruction_lines = [
56
59
  "",
57
- " #{Formatter.color(:red, "ERROR:")} Zephira requires Docker to run safely in a sandboxed environment.",
60
+ " #{Formatter.color(:red, "ERROR:")} Zephira requires Docker or Podman to run safely in a sandboxed",
61
+ " environment.",
58
62
  "",
59
- " Docker was not found or is not currently running. To fix this:",
63
+ " Neither Docker nor Podman was found or currently running. To fix this:",
60
64
  "",
61
65
  " 1. Install Docker Desktop: https://docs.docker.com/get-docker/",
62
- " 2. Start Docker and confirm it is running: docker info",
66
+ " or install Podman: https://podman.io/getting-started/installation",
67
+ " 2. Start the runtime and confirm it is running:",
68
+ " docker info or podman info",
63
69
  "",
64
70
  " To bypass the sandbox (not recommended):",
65
71
  "",
@@ -67,9 +73,9 @@ module Zephira
67
73
  ""
68
74
  ]
69
75
 
70
- max_warn_width = warn_lines.map { |line| visible_length(line) }.max
76
+ max_warn_width = warn_lines.map { |line| visible_length(line) }.max
71
77
  max_content_width = instruction_lines.map { |line| visible_length(line) }.max
72
- inner_width = [max_warn_width, max_content_width - 10].max
78
+ inner_width = [max_warn_width, max_content_width - 10].max
73
79
 
74
80
  inner_box = [
75
81
  " " + inner_top(inner_width),
@@ -84,7 +90,7 @@ module Zephira
84
90
  *content.map { |line| outer_row(line, width) },
85
91
  outer_bottom(width),
86
92
  ""
87
- ].each { |line| $stderr.puts line }
93
+ ].each { |line| warn line }
88
94
  exit(1)
89
95
  end
90
96
 
@@ -121,22 +127,26 @@ module Zephira
121
127
 
122
128
  def terminal_width
123
129
  IO.console&.winsize&.last || 80
124
- rescue StandardError
130
+ rescue
125
131
  80
126
132
  end
127
133
 
128
- def docker_available?
129
- system("docker info > /dev/null 2>&1")
134
+ def runtime_available?(binary)
135
+ system("#{binary} info > /dev/null 2>&1")
130
136
  end
131
137
 
132
- def resolve_image
138
+ def container_runtime
139
+ CONTAINER_RUNTIMES.find { |runtime| runtime_available?(runtime) }
140
+ end
141
+
142
+ def resolve_image(runtime)
133
143
  base = Config.read("ZEPHIRA_BASE_IMAGE")
134
144
  return "#{GHCR_IMAGE}:#{VERSION}" unless base
135
145
 
136
146
  derived = derived_image_name(base)
137
- unless image_exists?(derived)
138
- $stderr.puts "[Zephira] Building sandbox image from #{base}..."
139
- build_derived_image(base, derived)
147
+ unless image_exists?(derived, runtime)
148
+ warn "[Zephira] Building sandbox image from #{base} with #{runtime}..."
149
+ build_derived_image(base, derived, runtime)
140
150
  end
141
151
  derived
142
152
  end
@@ -146,16 +156,16 @@ module Zephira
146
156
  "#{DERIVED_IMAGE_PREFIX}-#{sanitized}:#{VERSION}"
147
157
  end
148
158
 
149
- def image_exists?(name)
150
- system("docker image inspect #{name} > /dev/null 2>&1")
159
+ def image_exists?(name, runtime)
160
+ system("#{runtime} image inspect #{name} > /dev/null 2>&1")
151
161
  end
152
162
 
153
- def build_derived_image(base_image, target_name)
163
+ def build_derived_image(base_image, target_name, runtime)
154
164
  dockerfile = "FROM #{base_image}\nRUN gem install zephira:#{VERSION} --no-document\n"
155
165
  Tempfile.create(["zephira-sandbox", ".dockerfile"]) do |file|
156
166
  file.write(dockerfile)
157
167
  file.flush
158
- system("docker build -t #{target_name} -f #{file.path} .")
168
+ system("#{runtime} build -t #{target_name} -f #{file.path} .")
159
169
  end
160
170
  end
161
171
 
@@ -166,18 +176,21 @@ module Zephira
166
176
  .sort
167
177
  end
168
178
 
169
- def build_docker_command(argv, image)
170
- cmd = ["docker", "run", "--rm", "-i"]
179
+ def build_container_command(argv, image, runtime)
180
+ cmd = [runtime, "run", "--rm", "-i"]
171
181
  cmd << "-t" if $stdout.tty?
172
182
 
183
+ cmd += ["--user", "#{Process.uid}:#{Process.gid}"]
173
184
  cmd += ["-e", "ZEPHIRA_IN_SANDBOX=1"]
185
+ cmd += ["-e", "HOME=#{SANDBOX_HOME}"]
174
186
  cmd += ["-v", "#{Dir.pwd}:/workspace:rw"]
187
+ cmd += ["-v", "#{sandbox_home_mount(runtime)}:#{SANDBOX_HOME}:rw"]
175
188
 
176
189
  global_config = File.expand_path("~/.zephira.yml")
177
- cmd += ["-v", "#{global_config}:/root/.zephira.yml:ro"] if File.exist?(global_config)
190
+ cmd += ["-v", "#{global_config}:#{SANDBOX_HOME}/.zephira.yml:ro"] if File.exist?(global_config)
178
191
 
179
192
  global_dir = File.expand_path("~/.zephira")
180
- cmd += ["-v", "#{global_dir}:/root/.zephira:ro"] if File.exist?(global_dir) && File.directory?(global_dir)
193
+ cmd += ["-v", "#{global_dir}:#{SANDBOX_HOME}/.zephira:ro"] if File.exist?(global_dir) && File.directory?(global_dir)
181
194
 
182
195
  forwarded_env_keys.each do |key|
183
196
  cmd += ["-e", "#{key}=#{ENV[key]}"]
@@ -188,6 +201,12 @@ module Zephira
188
201
  cmd += ["zephira"] + argv
189
202
  cmd
190
203
  end
204
+
205
+ def sandbox_home_mount(runtime)
206
+ return "zephira-home-#{Process.uid}" if runtime == "docker"
207
+
208
+ File.expand_path("~/.zephira/sandbox-home")
209
+ end
191
210
  end
192
211
  end
193
212
  end
@@ -6,7 +6,7 @@ module Zephira
6
6
  # which lands within ~20% of real BPE tokenizers (GPT/Claude) for English
7
7
  # text — close enough for context-budget decisions, never trust for billing.
8
8
  module Tokens
9
- TOKEN_PATTERN = /\w+|[^\s\w]/.freeze
9
+ TOKEN_PATTERN = /\w+|[^\s\w]/
10
10
 
11
11
  def self.estimate(text)
12
12
  return 0 if text.nil? || text.empty?
@@ -127,8 +127,12 @@ module Zephira
127
127
  end
128
128
 
129
129
  def executable_available?(cmd)
130
- _, _, status = Open3.capture3("command", "-v", cmd)
131
- status.success?
130
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
131
+ next false if directory.nil? || directory.empty?
132
+
133
+ executable = File.join(directory, cmd)
134
+ File.file?(executable) && File.executable?(executable)
135
+ end
132
136
  end
133
137
  end
134
138
  end
@@ -77,7 +77,7 @@ module Zephira
77
77
  return error_result(message: "`query` must be a non-empty string")
78
78
  end
79
79
 
80
- if num_results && (!num_results.is_a?(Integer) || !(1..50).include?(num_results))
80
+ if num_results && (!num_results.is_a?(Integer) || !(1..50).cover?(num_results))
81
81
  return error_result(message: "`num_results` must be an integer between 1 and 50")
82
82
  end
83
83
 
data/lib/zephira/tools.rb CHANGED
@@ -45,8 +45,7 @@ module Zephira
45
45
  end
46
46
 
47
47
  def read_only?(name)
48
- tool = constants.find { |candidate| candidate.name == name }
49
- tool && tool.respond_to?(:read_only?) && tool.read_only?
48
+ constants.find { |candidate| candidate.name == name }&.read_only?
50
49
  end
51
50
 
52
51
  def find!(name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zephira
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
data/lib/zephira.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "zephira/version"
4
4
  require_relative "zephira/config"
5
5
  require_relative "zephira/sandbox"
6
6
  require_relative "zephira/formatter"
7
+ require_relative "zephira/onboarding"
7
8
  require_relative "zephira/tokens"
8
9
  require_relative "zephira/logger"
9
10
  require_relative "zephira/backends"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zephira
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Gough
@@ -158,6 +158,7 @@ executables:
158
158
  extensions: []
159
159
  extra_rdoc_files: []
160
160
  files:
161
+ - ".dockerignore"
161
162
  - ".rspec"
162
163
  - CHANGELOG.md
163
164
  - Dockerfile
@@ -178,6 +179,7 @@ files:
178
179
  - lib/zephira/commands/help.rb
179
180
  - lib/zephira/commands/history.rb
180
181
  - lib/zephira/commands/model.rb
182
+ - lib/zephira/commands/reload.rb
181
183
  - lib/zephira/completions.rb
182
184
  - lib/zephira/completions/file_names.rb
183
185
  - lib/zephira/completions/slash_commands.rb
@@ -194,6 +196,7 @@ files:
194
196
  - lib/zephira/models/gpt_5_5.rb
195
197
  - lib/zephira/models/gpt_o4_mini.rb
196
198
  - lib/zephira/models/llama4.rb
199
+ - lib/zephira/onboarding.rb
197
200
  - lib/zephira/sandbox.rb
198
201
  - lib/zephira/tokens.rb
199
202
  - lib/zephira/tools.rb
@@ -214,7 +217,6 @@ files:
214
217
  - lib/zephira/version.rb
215
218
  - license.txt
216
219
  - standard.yml
217
- - zephira-0.1.0.gem
218
220
  homepage: https://github.com/aarongough/zephira
219
221
  licenses:
220
222
  - MIT
data/zephira-0.1.0.gem DELETED
Binary file