Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
12 changes: 11 additions & 1 deletion Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ tasks:
COMMANDS["claude"]='MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='"'"''"'"' --safe-mode --setting-sources='"'"'user'"'"' --system-prompt='"'"''"'"''
COMMANDS["llm"]='llm -m claude-haiku-4.5'
COMMANDS["aichat"]='aichat -m claude:claude-haiku-4.5'
COMMANDS["codex"]='codex exec -m gpt-5.6-luna -c model_reasoning_effort='"'"'low'"'"' -c system_prompt='"'"''"'"' --sandbox=read-only --json - | jq -sr '"'"'[.[] | select(.item.type? == "agent_message")] | last.item.text'"'"''
COMMANDS["codex"]='codex exec -m gpt-6-luna -c model_reasoning_effort='"'"'none'"'"' -c project_doc_max_bytes=0 -c web_search=disabled -c '"'"'model_instructions_file="~/.codex/worktrunk-commit-instructions.txt"'"'"' --ephemeral --sandbox=read-only --json - | jq -sr '"'"'[.[] | select(.item.type? == "agent_message")] | last.item.text'"'"''
COMMANDS["opencode"]='opencode run -m anthropic/claude-haiku-4.5 --variant fast'

# Check which tools are available
Expand All @@ -381,10 +381,20 @@ tasks:
echo " ✗ $tool (not installed)"
fi
done
CODEX_READY=true
if command -v jq &>/dev/null; then
echo " ✓ jq (required for codex)"
else
echo " ✗ jq (required for codex)"
CODEX_READY=false
fi
if [ -s "$HOME/.codex/worktrunk-commit-instructions.txt" ]; then
echo " ✓ Codex commit instructions"
else
echo " ✗ Codex commit instructions (see llm-commits.md)"
CODEX_READY=false
fi
if [ "$CODEX_READY" = false ]; then
# Drop codex from the list. String substitution (${AVAILABLE[@]/codex})
# would rewrite the element to "" rather than remove it, leaving an
# empty tool name to iterate over later — rebuild the array instead.
Expand Down
4 changes: 3 additions & 1 deletion dev/config.example.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/src/content/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,11 @@ command = "MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haik

### Codex

Create `~/.codex/worktrunk-commit-instructions.txt` containing just `.` (no newline). Accepting Worktrunk's first-run Codex setup creates the file for you.

```toml
[commit.generation]
command = "codex exec -m gpt-5.6-luna -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
command = "codex exec -m gpt-6-luna -c model_reasoning_effort='none' -c project_doc_max_bytes=0 -c web_search=disabled -c 'model_instructions_file=\"~/.codex/worktrunk-commit-instructions.txt\"' --ephemeral --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
```

### OpenCode
Expand Down
6 changes: 4 additions & 2 deletions docs/src/content/docs/llm-commits.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ command = "MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haik

### Codex

Create `~/.codex/worktrunk-commit-instructions.txt` containing just `.` (no newline). Accepting Worktrunk's first-run Codex setup creates the file for you.

```toml
# ~/.config/worktrunk/config.toml
[commit.generation]
command = "codex exec -m gpt-5.6-luna -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
command = "codex exec -m gpt-6-luna -c model_reasoning_effort='none' -c project_doc_max_bytes=0 -c web_search=disabled -c 'model_instructions_file=\"~/.codex/worktrunk-commit-instructions.txt\"' --ephemeral --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
```

Uses the fast, low-cost variant of the current Codex model family with low reasoning effort and an empty system prompt for faster output. Requires `jq` for JSON parsing. See [Codex CLI docs](https://developers.openai.com/codex/cli/).
`model_instructions_file` replaces Codex's built-in instructions with that one character. `web_search=disabled` removes the search tool; the command also keeps user provider settings and authentication, limits project instructions, and skips session persistence. Codex can still load global `AGENTS.md` and tool context, so a short commit prompt can use thousands of input tokens. Requires `jq` for JSON parsing. See [Codex CLI docs](https://developers.openai.com/codex/cli/).

### Other tools

Expand Down
4 changes: 3 additions & 1 deletion plugins/worktrunk/skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions plugins/worktrunk/skills/worktrunk/reference/llm-commits.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion skills/worktrunk/reference/config.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions skills/worktrunk/reference/llm-commits.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2090,9 +2090,11 @@ command = "MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haik

### Codex

Create `~/.codex/worktrunk-commit-instructions.txt` containing just `.` (no newline). Accepting Worktrunk's first-run Codex setup creates the file for you.

```toml
[commit.generation]
command = "codex exec -m gpt-5.6-luna -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
command = "codex exec -m gpt-6-luna -c model_reasoning_effort='none' -c project_doc_max_bytes=0 -c web_search=disabled -c 'model_instructions_file=\"~/.codex/worktrunk-commit-instructions.txt\"' --ephemeral --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
```

### OpenCode
Expand Down
71 changes: 69 additions & 2 deletions src/output/commit_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use color_print::cformat;
use worktrunk::config::{UserConfig, require_config_path};
use worktrunk::path::{format_path_for_display, home_dir};
use worktrunk::styling::{eprintln, format_toml, hint_message, info_message, success_message};

use super::prompt::{PromptResponse, prompt_yes_no_preview};
Expand All @@ -25,6 +26,32 @@ const CONFIG_EXAMPLE: &str = include_str!("../../dev/config.example.toml");
static RECOMMENDED_COMMANDS: LazyLock<HashMap<String, String>> =
LazyLock::new(|| parse_recommended_commands(CONFIG_EXAMPLE));

/// Codex expands `~` in `model_instructions_file`, including when the path is
/// passed through `-c`. Keep the file outside the current repository so every
/// commit uses the same short instructions.
const CODEX_COMMIT_INSTRUCTIONS_FILE: &str = "worktrunk-commit-instructions.txt";

fn codex_instructions_path() -> anyhow::Result<PathBuf> {
let home = home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
Ok(home.join(".codex").join(CODEX_COMMIT_INSTRUCTIONS_FILE))
}

fn ensure_codex_instructions_file(path: &Path) -> anyhow::Result<()> {
if path.exists() {
anyhow::ensure!(
path.is_file() && path.metadata()?.len() > 0,
"Codex instructions file must be a nonempty file: {}",
path.display()
);
return Ok(());
}

let parent = path.parent().expect("Codex instructions file has a parent");
std::fs::create_dir_all(parent)?;
worktrunk::utils::write_new_atomically(path, ".")?;
Comment thread
worktrunk-bot marked this conversation as resolved.
Resolved
Ok(())
}

/// Detected LLM tool available on the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmTool {
Expand Down Expand Up @@ -189,6 +216,9 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result<bool>
let command = tool.recommended_config();
let formatted_command = format_command_for_display(command);
let config_preview = format!("[commit.generation]\ncommand = {formatted_command}");
let codex_instructions = (tool == LlmTool::Codex)
.then(codex_instructions_path)
.transpose()?;

// Point at the config file wt actually writes to — respecting --config,
// WORKTRUNK_CONFIG_PATH, and $XDG_CONFIG_HOME — rather than a hardcoded
Expand All @@ -204,12 +234,30 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result<bool>
info_message(cformat!("Would add to <bold>{config_path}</>:"))
);
eprintln!("{}", format_toml(&config_preview));
if let Some(path) = &codex_instructions {
eprintln!(
"{}",
info_message(format!(
"Uses {}; creates a one-character file there if absent",
Comment thread
worktrunk-bot marked this conversation as resolved.
Resolved
format_path_for_display(path)
))
);
}
eprintln!();
},
)?;

match response {
PromptResponse::Accepted => {
if let Some(path) = &codex_instructions {
let setup_result = ensure_codex_instructions_file(path);
if let Err(e) = setup_result {
tracing::error!(error = %e, "Failed to prepare Codex instructions");
eprintln!("{}", hint_message(format!("Codex setup failed: {e}")));
return Ok(false);
}
}

// Set the configuration
let command = command.to_string();
let save_result = require_config_path()
Expand Down Expand Up @@ -262,10 +310,29 @@ mod tests {
#[test]
fn test_llm_tool_recommended_config() {
assert_snapshot!(LlmTool::Claude.recommended_config(), @"MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='' --safe-mode --setting-sources='user' --system-prompt=''");
assert_snapshot!(LlmTool::Codex.recommended_config(), @r#"codex exec -m gpt-5.6-luna -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == "agent_message")] | last.item.text'"#);
assert_snapshot!(LlmTool::Codex.recommended_config(), @r#"codex exec -m gpt-6-luna -c model_reasoning_effort='none' -c project_doc_max_bytes=0 -c web_search=disabled -c 'model_instructions_file="~/.codex/worktrunk-commit-instructions.txt"' --ephemeral --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == "agent_message")] | last.item.text'"#);
assert_snapshot!(LlmTool::OpenCode.recommended_config(), @"opencode run -m anthropic/claude-haiku-4.5 --variant fast");
}

#[test]
fn test_codex_instructions_file_is_minimal_and_preserves_existing_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".codex/worktrunk-commit-instructions.txt");

ensure_codex_instructions_file(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b".");

std::fs::write(&path, "Use my instructions").unwrap();
ensure_codex_instructions_file(&path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"Use my instructions"
);

std::fs::write(&path, "").unwrap();
assert!(ensure_codex_instructions_file(&path).is_err());
}

#[test]
fn test_parse_recommended_commands() {
let config = "\
Expand Down
56 changes: 46 additions & 10 deletions tests/integration_tests/shell_integration_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -694,16 +694,16 @@ mod commit_generation_prompt_tests {
use std::path::{Path, PathBuf};
use tempfile::TempDir;

fn setup_fake_claude(temp_home: &Path) -> PathBuf {
// Create a fake claude executable that does nothing
fn setup_fake_tool(temp_home: &Path, tool: &str) -> PathBuf {
// Create a fake LLM executable that does nothing
let bin_dir = temp_home.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let claude_path = bin_dir.join("claude");
fs::write(&claude_path, "#!/bin/sh\nexit 0\n").unwrap();
let tool_path = bin_dir.join(tool);
fs::write(&tool_path, "#!/bin/sh\nexit 0\n").unwrap();
// Make executable
let mut perms = fs::metadata(&claude_path).unwrap().permissions();
let mut perms = fs::metadata(&tool_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&claude_path, perms).unwrap();
fs::set_permissions(&tool_path, perms).unwrap();
bin_dir
}

Expand Down Expand Up @@ -747,7 +747,7 @@ mod commit_generation_prompt_tests {
#[rstest]
fn test_user_declines_llm_prompt(repo: TestRepo) {
let temp_home = TempDir::new().unwrap();
let bin_dir = setup_fake_claude(temp_home.path());
let bin_dir = setup_fake_tool(temp_home.path(), "claude");

// Stage a change
let test_file = repo.root_path().join("test.txt");
Expand Down Expand Up @@ -788,7 +788,7 @@ mod commit_generation_prompt_tests {
#[rstest]
fn test_user_accepts_llm_prompt(repo: TestRepo) {
let temp_home = TempDir::new().unwrap();
let bin_dir = setup_fake_claude(temp_home.path());
let bin_dir = setup_fake_tool(temp_home.path(), "claude");

// Stage a change
let test_file = repo.root_path().join("test.txt");
Expand Down Expand Up @@ -825,11 +825,47 @@ mod commit_generation_prompt_tests {
);
}

#[rstest]
fn test_user_accepts_codex_prompt(repo: TestRepo) {
let temp_home = TempDir::new().unwrap();
let bin_dir = setup_fake_tool(temp_home.path(), "codex");

let test_file = repo.root_path().join("test.txt");
fs::write(&test_file, "test content\n").unwrap();
repo.run_git(&["add", "test.txt"]);

let mut env_vars = repo.test_env_vars();
let path = crate::common::setup_minimal_path_with_git(&bin_dir);
env_vars.push(("PATH".to_string(), path));

let cmd = build_pty_command(
wt_bin().to_str().unwrap(),
&["step", "commit"],
repo.root_path(),
&env_vars,
Some(temp_home.path()),
);
let (output, _exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");

assert!(output.contains("Added to user config"), "{output}");
let config_content = fs::read_to_string(repo.test_config_path()).unwrap();
assert!(config_content.contains("model_instructions_file"));
assert_eq!(
fs::read(
temp_home
.path()
.join(".codex/worktrunk-commit-instructions.txt")
)
.unwrap(),
b"."
);
}

/// Test: User requests preview (?)
#[rstest]
fn test_user_requests_preview(repo: TestRepo) {
let temp_home = TempDir::new().unwrap();
let bin_dir = setup_fake_claude(temp_home.path());
let bin_dir = setup_fake_tool(temp_home.path(), "claude");

// Stage a change
let test_file = repo.root_path().join("test.txt");
Expand Down Expand Up @@ -867,7 +903,7 @@ mod commit_generation_prompt_tests {
#[rstest]
fn test_user_accepts_but_save_fails_shows_manual_hint(repo: TestRepo) {
let temp_home = TempDir::new().unwrap();
let bin_dir = setup_fake_claude(temp_home.path());
let bin_dir = setup_fake_tool(temp_home.path(), "claude");

// Stage a change
let test_file = repo.root_path().join("test.txt");
Expand Down
Loading
Loading