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
256 changes: 256 additions & 0 deletions .claude/skills/oo-learn-patterns/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
---
name: oo-learn-patterns
description: >-
Bootstrap project-specific oo output patterns for a repository. Scans the repo
to detect its toolchain and creates .oo/patterns/*.toml files that compress
verbose command output for AI agents. Use when you want to set up oo patterns
for a project, teach oo about a repo's commands, or create .oo/patterns.
---

# oo-learn-patterns

Create project-specific output patterns so `oo` can compress verbose command
output into terse summaries for AI coding agents.

## Workflow

### 1. Detect project root and toolchain

Find the git root (`git rev-parse --show-toplevel`) and scan for toolchain
markers to determine which commands the project uses:

| Marker file | Commands to pattern |
|-------------|-------------------|
| `Cargo.toml` | `cargo test`, `cargo build`, `cargo clippy`, `cargo fmt --check` |
| `package.json` | `npm test`, `npm run build`, `npx jest`, `npx eslint`, `npx tsc` |
| `pyproject.toml` / `setup.py` / `requirements.txt` | `pytest`, `ruff check`, `mypy`, `pip install` |
| `go.mod` | `go test ./...`, `go build ./...`, `go vet ./...` |
| `Makefile` / `CMakeLists.txt` | `make`, `cmake --build` |
| `Dockerfile` / `docker-compose.yml` | `docker build`, `docker compose up` |
| `terraform/` / `*.tf` | `terraform plan`, `terraform apply` |
| `.github/workflows/` | inspect YAML for additional commands |

Also check for CI config files (`.github/workflows/*.yml`, `.gitlab-ci.yml`,
`Jenkinsfile`) to discover commands actually used in the project.

### 2. Create the patterns directory

```bash
mkdir -p <git-root>/.oo/patterns
```

### 3. Author one `.toml` file per command

For each discovered command, create a pattern file in `.oo/patterns/`.
Name files descriptively: `cargo-test.toml`, `npm-build.toml`, etc.

Use the TOML format reference below. Key principles:

- `command_match` is a regex tested against the full command string
- `[success]` extracts a terse summary from passing output via named captures
- `[failure]` filters noisy failure output to show only actionable lines
- An empty `summary = ""` suppresses output entirely on success (quiet pass)
- Omit `[failure]` to show all output on failure (sensible default)

### 4. Validate patterns

After creating patterns, verify them:

```bash
oo patterns # lists all loaded patterns (project + user + builtins)
oo <command> # run a real command to test the pattern
```

### 5. Iterate

If a pattern doesn't match as expected, adjust `command_match` regex or
`[success].pattern` captures. Run the command again with `oo` to verify.

You can also use `oo learn <command>` to have an LLM generate a pattern
(saves to `~/.config/oo/patterns/`, not project-local), then move or
adapt the generated file into `.oo/patterns/`.

---

## Pattern TOML Format Reference

Patterns are `.toml` files — one per command. They are loaded from two locations
in order, with the first regex match winning:

1. **Project:** `<git-root>/.oo/patterns/` — repo-specific, checked in with the project
2. **User:** `~/.config/oo/patterns/` — personal patterns across all projects

Both layers are checked before built-in patterns, so custom patterns always override.

### TOML format

```toml
# Regex matched against the full command string (e.g. "make -j4 all")
command_match = "^make\\b"

[success]
# Regex with named captures run against stdout+stderr
pattern = '(?P<target>\S+) is up to date'
# Template: {name} is replaced with the capture of the same name
summary = "{target} up to date"

[failure]
# Strategy: tail | head | grep | between
strategy = "grep"
# For grep: lines matching this regex are kept
grep = "Error:|error\\["
```

### `[success]` section

| Field | Type | Description |
|-------|------|-------------|
| `pattern` | regex | Named captures become template variables |
| `summary` | string | Template; `{capture_name}` replaced at runtime |

An empty `summary = ""` suppresses output on success (quiet pass).

### `[failure]` section

`strategy` is optional and defaults to `"tail"`.

| `strategy` | Extra fields | Behaviour |
|------------|-------------|-----------|
| `tail` | `lines` (default 30) | Last N lines of output |
| `head` | `lines` (default 20) | First N lines of output |
| `grep` | `grep` (regex, required) | Lines matching regex |
| `between` | `start`, `end` (strings, required) | Lines from first `start` match to first `end` match (inclusive) |

Omit `[failure]` to show all output on failure.

> **Note:** `start` and `end` in the `between` strategy are plain substring
> matches, not regexes.

### Command Categories

oo categorizes commands to determine default behavior when no pattern matches:

| Category | Examples | Default Behavior |
|----------|----------|------------------|
| **Status** | `cargo test`, `pytest`, `eslint`, `cargo build` | Quiet success (empty summary) if output > 4 KB |
| **Content** | `git show`, `git diff`, `cat`, `bat` | Always pass through, never index |
| **Data** | `git log`, `git status`, `gh api`, `ls`, `find` | Index for recall if output > 4 KB and unpatterned |
| **Unknown** | Anything else (curl, docker, etc.) | Pass through (safe default) |

Patterns always take priority over category defaults. If a pattern matches, it
determines the output classification regardless of category.

---

## Example Patterns

### `docker build`

```toml
command_match = "\\bdocker\\s+build\\b"

[success]
pattern = 'Successfully built (?P<id>[0-9a-f]+)'
summary = "built {id}"

[failure]
strategy = "tail"
lines = 20
```

### `terraform plan`

```toml
command_match = "\\bterraform\\s+plan\\b"

[success]
pattern = 'Plan: (?P<add>\d+) to add, (?P<change>\d+) to change, (?P<destroy>\d+) to destroy'
summary = "+{add} ~{change} -{destroy}"

[failure]
strategy = "grep"
grep = "Error:|error:"
```

### `make`

```toml
command_match = "^make\\b"

[success]
pattern = '(?s).*' # always matches; empty summary = quiet
summary = ""

[failure]
strategy = "between"
start = "make["
end = "Makefile:"
```

---

## Example: Programmatic Pattern Usage (Rust)

This shows how patterns work at the library level, useful for understanding
the internal structure when authoring TOML patterns.

```rust
use double_o::pattern::parse_pattern_str;
use double_o::{CommandOutput, classify};

fn main() {
let toml_pattern = r#"
command_match = "^myapp test"

[success]
pattern = '(?P<passed>\d+) tests passed, (?P<failed>\d+) failed'
summary = "{passed} passed, {failed} failed"

[failure]
strategy = "tail"
lines = 20
"#;

let custom_pattern = parse_pattern_str(toml_pattern).unwrap();

let success_output = CommandOutput {
stdout: br"Running test suite...
Test 1... OK
Test 2... OK
Test 3... OK
Result: 42 tests passed, 0 failed
Total time: 2.5s"
.to_vec(),
stderr: Vec::new(),
exit_code: 0,
};

let patterns = vec![custom_pattern];
let classification = classify(&success_output, "myapp test --verbose", &patterns);

match &classification {
double_o::Classification::Success { label, summary } => {
println!(" Label: {}", label); // "myapp"
println!(" Summary: {}", summary); // "42 passed, 0 failed"
}
_ => println!(" Unexpected classification type"),
}
}
```

A grep-based failure strategy example:

```rust
let grep_pattern = r#"
command_match = "^myapp build"

[success]
pattern = 'Build complete'
summary = "build succeeded"

[failure]
strategy = "grep"
grep = "Error:"
"#;
// Only lines matching "Error:" are kept in the failure output.
```
9 changes: 7 additions & 2 deletions docs/patterns.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# Custom Patterns

Patterns live in `~/.config/oo/patterns/` as `.toml` files — one per command.
User patterns are checked before built-ins, so they override existing behaviour.
Patterns are `.toml` files — one per command. They are loaded from two locations
in order, with the first regex match winning:

1. **Project:** `<git-root>/.oo/patterns/` — repo-specific, checked in with the project
2. **User:** `~/.config/oo/patterns/` — personal patterns across all projects

Both layers are checked before built-in patterns, so custom patterns always override.

## TOML format

Expand Down
69 changes: 55 additions & 14 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,15 @@ pub fn cmd_run(args: &[String]) -> i32 {
return 1;
}

// Load patterns: user patterns first (override), then builtins
// Load patterns: project-local first, then user config, then builtins.
// First match wins, so project patterns override user patterns override builtins.
let project_patterns = load_project_patterns();
let user_patterns = pattern::load_user_patterns(&learn::patterns_dir());
let builtin_patterns = pattern::builtins();
let mut all_patterns: Vec<&pattern::Pattern> = Vec::new();
for p in &project_patterns {
all_patterns.push(p);
}
for p in &user_patterns {
all_patterns.push(p);
}
Expand Down Expand Up @@ -406,16 +411,13 @@ pub fn check_and_clear_learn_status(status_path: &Path) {
}
}

/// List learned pattern files from the given directory.
/// Print pattern entries from a single directory, returning true if any were found.
///
/// Extracted for testability — callers pass in the resolved patterns dir.
pub fn cmd_patterns_in(dir: &Path) -> i32 {
/// Each line is printed with a two-space indent so callers can add section headers.
pub fn list_patterns_in(dir: &Path) -> bool {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => {
println!("no learned patterns yet");
return 0;
}
Err(_) => return false,
};

let mut found = false;
Expand All @@ -424,7 +426,6 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 {
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
// Parse the file once, extract all three fields from the single Value
let parsed = std::fs::read_to_string(&path)
.ok()
.and_then(|s| toml::from_str::<toml::Value>(&s).ok());
Expand All @@ -435,7 +436,6 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 {
let has_success = parsed.as_ref().and_then(|v| v.get("success")).is_some();
let has_failure = parsed.as_ref().and_then(|v| v.get("failure")).is_some();

// Only mark found after a valid parse; skip corrupt files silently
if parsed.is_none() {
continue;
}
Expand All @@ -450,20 +450,50 @@ pub fn cmd_patterns_in(dir: &Path) -> i32 {
flags.push("failure");
}
if flags.is_empty() {
println!("{cmd_match}");
println!(" {cmd_match}");
} else {
println!("{cmd_match} [{}]", flags.join("] ["));
println!(" {cmd_match} [{}]", flags.join("] ["));
}
}
found
}

if !found {
/// List learned pattern files from a single directory (legacy test helper).
pub fn cmd_patterns_in(dir: &Path) -> i32 {
if !list_patterns_in(dir) {
println!("no learned patterns yet");
}
0
}

/// List patterns from both project-local and user config directories.
pub fn cmd_patterns() -> i32 {
cmd_patterns_in(&learn::patterns_dir())
let project_dir = std::env::current_dir()
.map(|cwd| init::project_patterns_dir(&cwd))
.ok();
let user_dir = learn::patterns_dir();

let mut total_found = false;

if let Some(ref pdir) = project_dir {
if pdir.exists() {
println!("Project ({}):", pdir.display());
if list_patterns_in(pdir) {
total_found = true;
}
println!();
}
}

println!("User ({}):", user_dir.display());
if list_patterns_in(&user_dir) {
total_found = true;
}

if !total_found {
println!("no patterns yet");
}
0
}

pub fn cmd_help(cmd: &str) -> i32 {
Expand All @@ -489,6 +519,17 @@ pub fn cmd_init(format: InitFormat) -> i32 {
}
}

/// Load project-local patterns from `<git-root>/.oo/patterns/`.
///
/// Returns an empty vec when cwd cannot be determined or the directory
/// does not exist (gracefully handled by `load_user_patterns`).
pub fn load_project_patterns() -> Vec<pattern::Pattern> {
let Ok(cwd) = std::env::current_dir() else {
return Vec::new();
};
pattern::load_user_patterns(&init::project_patterns_dir(&cwd))
}

#[cfg(test)]
#[path = "commands_tests.rs"]
mod tests;
Loading
Loading