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
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,25 @@ echo "*.log" > ~/.config/scry/templates/default/.gitignore

```bash
scry templates # List available templates
scry "ACME audit" # Creates directory with default template
scry "security-review" --template audit # Apply audit template instead
scry "ACME audit" # Prompts to pick a template
scry "security-review" --template audit # Apply audit template, skip the picker
```

The `default` template is applied automatically when creating new directories. All files from the template directory are copied to the new scry directory.
When creating a new directory, scry shows an interactive template picker. If a
`default` template exists it is preselected, so pressing Enter accepts it. Pass
`--template NAME` to skip the picker and apply a specific template. All files
from the chosen template directory are copied into the new scry directory.

## Harnesses

The command launched after `cd` is the *harness*. Configure named harnesses and
switch between them per invocation:

```bash
scry --harness codex "spike an idea" # Launch the "codex" harness
```

See [Configuration](#configuration) for defining harnesses.

## Cleanup

Expand All @@ -87,15 +101,26 @@ Optional configuration via `~/.config/scry/config.json` or environment variables
```json
{
"path": "~/scries",
"agent": "claude"
"default_harness": "claude",
"harnesses": {
"claude": "claude --dangerously-skip-permissions",
"codex": "codex"
},
"continue_flag": "--continue"
}
```

- `default_harness` — name of the harness launched when `--harness` is omitted.
- `harnesses` — named commands selectable with `--harness NAME`.
- `agent` — legacy single-command harness; still honored when no `harnesses`
map is configured.

Environment variables override config file settings:

```bash
export SCRY_PATH=~/experiments # Custom directory location
export SCRY_AGENT=opencode # Different AI agent command
export SCRY_HARNESS=codex # Launch a configured harness by name
export SCRY_AGENT=opencode # Raw harness command (legacy override)
```

## License
Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: scry
version: 0.8.1
version: 0.9.0

authors:
- Stephen Dolan
Expand Down
44 changes: 44 additions & 0 deletions spec/config_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,48 @@ describe Config do
config.agent.should eq("codex")
end
end

describe "#resolve_harness" do
it "resolves a named harness" do
json = %({ "harnesses": { "codex": "codex --yolo" } })
config = Config.from_json(json)
config.resolve_harness("codex").should eq("codex --yolo")
end

it "returns nil for an unknown named harness" do
config = Config.from_json(%({ "harnesses": { "codex": "codex" } }))
config.resolve_harness("nope").should be_nil
end

it "falls back to the legacy agent when no harness is requested" do
prev_agent = ENV["SCRY_AGENT"]?
prev_harness = ENV["SCRY_HARNESS"]?
ENV.delete("SCRY_AGENT")
ENV.delete("SCRY_HARNESS")

begin
config = Config.from_json(%({ "agent": "claude" }))
config.resolve_harness(nil).should eq("claude")
ensure
ENV["SCRY_AGENT"] = prev_agent if prev_agent
ENV["SCRY_HARNESS"] = prev_harness if prev_harness
end
end

it "uses default_harness when no harness is requested" do
prev_agent = ENV["SCRY_AGENT"]?
prev_harness = ENV["SCRY_HARNESS"]?
ENV.delete("SCRY_AGENT")
ENV.delete("SCRY_HARNESS")

begin
json = %({ "default_harness": "codex", "harnesses": { "codex": "codex" } })
config = Config.from_json(json)
config.resolve_harness(nil).should eq("codex")
ensure
ENV["SCRY_AGENT"] = prev_agent if prev_agent
ENV["SCRY_HARNESS"] = prev_harness if prev_harness
end
end
end
end
128 changes: 121 additions & 7 deletions spec/selector_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -59,32 +59,34 @@ describe ScrySelector do
FileUtils.rm_rf(test_dir)
end

it "does not go below 0 when pressing up at top" do
it "wraps to the last item when pressing up at top" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)
FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-project-one"))

keyboard = MockKeyboard.new(["\e[A", "\e[A", "\e[A", "\e"])
# One scry + "Create new" = 2 items. Up from index 0 wraps to index 1.
keyboard = MockKeyboard.new(["\e[A", "\e"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false)

selector.run
selector.cursor_pos.should eq(0)
selector.cursor_pos.should eq(1)

FileUtils.rm_rf(test_dir)
end

it "does not go past last item when pressing down at bottom" do
it "wraps to the first item when pressing down at bottom" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)
FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-project-one"))

keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e[B", "\e[B", "\e"])
# From index 0: down -> 1 (last), down -> wraps to 0.
keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false)

selector.run
selector.cursor_pos.should eq(1)
selector.cursor_pos.should eq(0)

FileUtils.rm_rf(test_dir)
end
Expand Down Expand Up @@ -379,6 +381,114 @@ describe ScrySelector do

FileUtils.rm_rf(test_dir)
end

it "creates without a template when none are available" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

keyboard = MockKeyboard.new(["x", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false)

result = selector.run
result.should_not be_nil
if r = result
r[:type].should eq(:mkdir)
r[:template].should be_nil
end

FileUtils.rm_rf(test_dir)
end
end

describe "template selection" do
it "picks the preselected default template on Enter" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

# "demo" + Enter opens the picker, Enter again accepts the default.
keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"])

result = selector.run
result.should_not be_nil
if r = result
r[:type].should eq(:mkdir)
r[:template].should eq("default")
end

FileUtils.rm_rf(test_dir)
end

it "navigates to a different template" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\e[B", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"])

result = selector.run
if r = result
r[:template].should eq("security")
end

FileUtils.rm_rf(test_dir)
end

it "can choose no template" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

# Options are default, security, (no template); move down twice.
keyboard = MockKeyboard.new(["x", "\r", "\e[B", "\e[B", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"])

result = selector.run
if r = result
r[:template].should be_nil
end

FileUtils.rm_rf(test_dir)
end

it "wraps from the top of the picker to (no template)" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

# Picker opens on "default" (index 0); Up wraps to the trailing
# "(no template)" option.
keyboard = MockKeyboard.new(["d", "e", "m", "o", "\r", "\e[A", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"])

result = selector.run
if r = result
r[:template].should be_nil
end

FileUtils.rm_rf(test_dir)
end

it "skips the picker when a template is forced" do
test_dir = File.tempname("scry-test")
FileUtils.mkdir_p(test_dir)

keyboard = MockKeyboard.new(["x", "\r"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false, templates: ["default", "security"], force_template: "security")

result = selector.run
result.should_not be_nil
if r = result
r[:type].should eq(:mkdir)
r[:template].should eq("security")
end

FileUtils.rm_rf(test_dir)
end
end

describe "rendering" do
Expand Down Expand Up @@ -455,12 +565,16 @@ describe ScrySelector do
FileUtils.mkdir_p(File.join(test_dir, ".hidden"))
FileUtils.mkdir_p(File.join(test_dir, "2024-01-01-visible"))

keyboard = MockKeyboard.new(["\e[B", "\e[B", "\e"])
keyboard = MockKeyboard.new(["\e[B", "\e"])
output = IO::Memory.new
selector = ScrySelector.new("", base_path: test_dir, keyboard: keyboard, output: output, interactive: false)

selector.run
# Only the visible scry + "Create new" are navigable, so one Down lands
# on "Create new" (index 1) and the hidden dir never appears.
selector.cursor_pos.should eq(1)
output.to_s.should contain("visible")
output.to_s.should_not contain(".hidden")

FileUtils.rm_rf(test_dir)
end
Expand Down
Loading