Skip to content
Open
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ working_directory: . # Execution CWD (resolved relative)
context_files: [] # Files passed as @-references
max_retries: 3 # Total attempts (1=no retry, -1=unlimited)
estimated_tokens: null # Optional hint
model: null # Optional Claude model ID (e.g. claude-haiku-4-5-20251001)
# Internal fields (managed by the queue, not user-edited):
status: queued
retry_count: 0
Expand All @@ -173,7 +174,7 @@ retry_not_before: null
| Command | Purpose | Needs `claude` binary? |
|---|---|---|
| `start [--verbose] [--no-skip-permissions]` | Run queue loop | Yes |
| `add <prompt> [-p priority]` | Quick-add prompt | No |
| `add <prompt> [-p priority] [-m model]` | Quick-add prompt | No |
| `template <name> [-p priority]` | Create template .md | No |
| `status [--json] [--detailed]` | Queue stats | No |
| `list [--status <s>] [--json]` | List prompts | No |
Expand Down
3 changes: 3 additions & 0 deletions src/claude_code_queue/claude_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ def execute_prompt(self, prompt: QueuedPrompt) -> ExecutionResult:
if context_refs:
full_prompt = f"{' '.join(context_refs)} {prompt.content}"

if prompt.model is not None:
cmd.extend(["--model", prompt.model])

cmd.append(full_prompt)

# E1 — Use cwd= instead of os.chdir() to set the subprocess working directory.
Expand Down
6 changes: 6 additions & 0 deletions src/claude_code_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ def main():
add_parser.add_argument(
"--estimated-tokens", "-t", type=int, help="Estimated token usage"
)
add_parser.add_argument(
"--model", "-m", default=None, help="Claude model ID (e.g. claude-haiku-4-5-20251001)"
)

template_parser = subparsers.add_parser(
"template", help="Create a prompt template file"
Expand Down Expand Up @@ -322,6 +325,7 @@ def cmd_add(args) -> int:
context_files=args.context_files,
max_retries=args.max_retries,
estimated_tokens=args.estimated_tokens,
model=args.model,
)
# Use _save_single_prompt directly rather than load_queue_state() +
# save_queue_state(). Loading the full queue state just to append one file
Expand Down Expand Up @@ -543,6 +547,8 @@ def cmd_bank_list(args) -> int:
print(f" Working directory: {template['working_directory']}")
if template['estimated_tokens']:
print(f" Estimated tokens: {template['estimated_tokens']}")
if template.get('model'):
print(f" Model: {template['model']}")
print(f" Modified: {template['modified'].strftime('%Y-%m-%d %H:%M:%S')}")
print()

Expand Down
1 change: 1 addition & 0 deletions src/claude_code_queue/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class QueuedPrompt:
status: PromptStatus = PromptStatus.QUEUED
execution_log: str = ""
estimated_tokens: Optional[int] = None
model: Optional[str] = None
last_executed: Optional[datetime] = None
rate_limited_at: Optional[datetime] = None
reset_time: Optional[datetime] = None
Expand Down
2 changes: 2 additions & 0 deletions src/claude_code_queue/skills/queue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ context_files:
- tests/test_relevant.py
max_retries: 3
estimated_tokens: null
model: null
---

# Task Title
Expand All @@ -88,6 +89,7 @@ What should be delivered when done.
| `context_files` | Paths relative to `working_directory`. Only include files that exist. |
| `max_retries` | Total attempts: `3` = 3 total, `-1` = unlimited, `1` = no retry. Rate-limit retries and failures share this counter. |
| `estimated_tokens` | Optional hint; set `null` if unknown. |
| `model` | Claude model ID (e.g. `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`). Omit or set `null` to use the default. |

### Priority Guidelines

Expand Down
11 changes: 11 additions & 0 deletions src/claude_code_queue/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ def parse_prompt_file(file_path: Path) -> Optional[QueuedPrompt]:
except (ValueError, TypeError):
retry_count = 0

# R7 — Type-safe coercion for model. YAML parses `model: true` as bool and
# `model: 42` as int; subprocess.Popen requires all cmd elements to be str.
_raw_model = metadata.get("model")
_model = str(_raw_model) if _raw_model is not None else None

prompt = QueuedPrompt(
id=prompt_id,
content=prompt_content,
Expand All @@ -117,6 +122,7 @@ def parse_prompt_file(file_path: Path) -> Optional[QueuedPrompt]:
max_retries=metadata.get("max_retries", 3),
retry_count=retry_count,
estimated_tokens=metadata.get("estimated_tokens"),
model=_model,
# R5 — Restore created_at from YAML; fall back to filesystem ctime.
# Using ctime alone causes created_at to drift when files are copied or
# their timestamps change. The YAML value is the authoritative source.
Expand Down Expand Up @@ -161,6 +167,8 @@ def write_prompt_file(prompt: QueuedPrompt, file_path: Path) -> bool:
metadata["context_files"] = prompt.context_files
if prompt.estimated_tokens:
metadata["estimated_tokens"] = prompt.estimated_tokens
if prompt.model is not None:
metadata["model"] = prompt.model
if prompt.last_executed:
metadata["last_executed"] = prompt.last_executed.isoformat()
if prompt.rate_limited_at:
Expand Down Expand Up @@ -453,6 +461,7 @@ def create_prompt_template(self, filename: str, priority: int = 0) -> Path:
context_files: []
max_retries: 3
estimated_tokens: null
model: null
---

# Prompt Title
Expand Down Expand Up @@ -504,6 +513,7 @@ def save_prompt_to_bank(self, template_name: str, priority: int = 0) -> Path:
context_files: []
max_retries: 3
estimated_tokens: null
model: null
---

# {safe_name.replace('-', ' ').replace('_', ' ').title()}
Expand Down Expand Up @@ -568,6 +578,7 @@ def list_bank_templates(self) -> List[dict]:
'priority': metadata.get('priority', 0),
'working_directory': metadata.get('working_directory', '.'),
'estimated_tokens': metadata.get('estimated_tokens'),
'model': metadata.get('model'),
'modified': datetime.fromtimestamp(file_path.stat().st_mtime)
})

Expand Down
36 changes: 36 additions & 0 deletions tests/test_claude_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,42 @@ def test_execute_prompt_includes_dangerously_skip_permissions(interface): # CLI
assert "--dangerously-skip-permissions" in args


def test_execute_prompt_includes_model_flag_when_set(interface): # CLI-060
"""When prompt.model is set, --model <value> appears in the subprocess command."""
mock_proc = make_mock_proc()
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
prompt = QueuedPrompt(content="task", model="claude-haiku-4-5-20251001")
interface.execute_prompt(prompt)
args = mock_popen.call_args[0][0]
assert "--model" in args
model_idx = args.index("--model")
assert args[model_idx + 1] == "claude-haiku-4-5-20251001"


def test_execute_prompt_omits_model_flag_when_none(interface): # CLI-061
"""When prompt.model is None, no --model flag is added."""
mock_proc = make_mock_proc()
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
prompt = QueuedPrompt(content="task", model=None)
interface.execute_prompt(prompt)
args = mock_popen.call_args[0][0]
assert "--model" not in args


def test_execute_prompt_model_flag_before_prompt_arg(interface): # CLI-062
"""--model flag is placed before the positional prompt argument."""
mock_proc = make_mock_proc()
with patch("subprocess.Popen", return_value=mock_proc) as mock_popen:
prompt = QueuedPrompt(content="my task", model="claude-opus-4-6")
interface.execute_prompt(prompt)
args = mock_popen.call_args[0][0]
model_idx = args.index("--model")
prompt_idx = args.index("my task")
assert model_idx < prompt_idx, (
f"--model at {model_idx} must precede prompt at {prompt_idx}"
)


def test_execute_prompt_success_returns_success_result(interface): # CLI-026
"""returncode=0 with no rate-limit output → success=True."""
mock_proc = make_mock_proc(returncode=0, stdout="All done", stderr="")
Expand Down
25 changes: 25 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def _make_template(
priority=0,
working_directory=".",
estimated_tokens=None,
model=None,
modified=None,
):
"""Build a bank-template dict like QueueStorage returns."""
Expand All @@ -66,6 +67,7 @@ def _make_template(
"priority": priority,
"working_directory": working_directory,
"estimated_tokens": estimated_tokens,
"model": model,
"modified": modified,
}

Expand Down Expand Up @@ -233,6 +235,21 @@ def test_add_default_estimated_tokens_none(self):
prompt = storage._save_single_prompt.call_args[0][0]
assert prompt.estimated_tokens is None

def test_add_model_long_flag(self):
_, storage = self._run_add("--model", "claude-haiku-4-5-20251001")
prompt = storage._save_single_prompt.call_args[0][0]
assert prompt.model == "claude-haiku-4-5-20251001"

def test_add_model_short_flag(self):
_, storage = self._run_add("-m", "claude-sonnet-4-6")
prompt = storage._save_single_prompt.call_args[0][0]
assert prompt.model == "claude-sonnet-4-6"

def test_add_default_model_none(self):
_, storage = self._run_add()
prompt = storage._save_single_prompt.call_args[0][0]
assert prompt.model is None

def test_add_returns_zero_on_success(self):
code, _ = self._run_add(success=True)
assert code == 0
Expand Down Expand Up @@ -704,6 +721,14 @@ def test_bank_list_omits_estimated_tokens_when_none(self, capsys):
self._run_bank_list(templates=[_make_template(estimated_tokens=None)])
assert "Estimated tokens" not in capsys.readouterr().out

def test_bank_list_shows_model_when_set(self, capsys):
self._run_bank_list(templates=[_make_template(model="claude-haiku-4-5-20251001")])
assert "claude-haiku-4-5-20251001" in capsys.readouterr().out

def test_bank_list_omits_model_when_none(self, capsys):
self._run_bank_list(templates=[_make_template(model=None)])
assert "Model:" not in capsys.readouterr().out

def test_bank_list_shows_modified_timestamp(self, capsys):
mod = datetime(2026, 3, 1, 10, 30, 0)
self._run_bank_list(templates=[_make_template(modified=mod)])
Expand Down
17 changes: 17 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ def test_add_log_is_cumulative(): # MOD-003
assert "third" in p.execution_log


# ===========================================================================
# QueuedPrompt — model field
# ===========================================================================


def test_prompt_model_default_is_none(): # MOD-029
"""QueuedPrompt defaults model to None."""
p = QueuedPrompt(content="test")
assert p.model is None


def test_prompt_model_accepts_string(): # MOD-030
"""model field stores an arbitrary string model ID."""
p = QueuedPrompt(content="test", model="claude-haiku-4-5-20251001")
assert p.model == "claude-haiku-4-5-20251001"


# ===========================================================================
# QueuedPrompt — should_execute_now()
# ===========================================================================
Expand Down
103 changes: 103 additions & 0 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,109 @@ def test_parse_estimated_tokens_null(tmp_path): # STO-023
assert prompt.estimated_tokens is None


def test_parse_reads_model(tmp_path): # STO-065
"""model: claude-haiku-4-5-20251001 in frontmatter → prompt.model == that string."""
storage = QueueStorage(str(tmp_path))
file_path = storage.queue_dir / "abc12345-task.md"
file_path.write_text(
"---\npriority: 0\nworking_directory: .\nmax_retries: 3\n"
"model: claude-haiku-4-5-20251001\n"
"status: queued\nretry_count: 0\ncreated_at: 2025-01-01T00:00:00\n---\n\ncontent"
)
prompt = storage.parser.parse_prompt_file(file_path)
assert prompt is not None
assert prompt.model == "claude-haiku-4-5-20251001"


def test_parse_model_null(tmp_path): # STO-066
"""model: null in frontmatter → prompt.model is None."""
storage = QueueStorage(str(tmp_path))
file_path = storage.queue_dir / "abc12345-task.md"
file_path.write_text(
"---\npriority: 0\nworking_directory: .\nmax_retries: 3\n"
"model: null\n"
"status: queued\nretry_count: 0\ncreated_at: 2025-01-01T00:00:00\n---\n\ncontent"
)
prompt = storage.parser.parse_prompt_file(file_path)
assert prompt is not None
assert prompt.model is None


def test_parse_model_coerces_bool_to_string(tmp_path): # STO-067
"""model: true in YAML → str coercion → prompt.model == 'True' (R7)."""
storage = QueueStorage(str(tmp_path))
file_path = storage.queue_dir / "abc12345-task.md"
file_path.write_text(
"---\npriority: 0\nworking_directory: .\nmax_retries: 3\n"
"model: true\n"
"status: queued\nretry_count: 0\ncreated_at: 2025-01-01T00:00:00\n---\n\ncontent"
)
prompt = storage.parser.parse_prompt_file(file_path)
assert prompt is not None
assert prompt.model == "True"


def test_parse_model_coerces_int_to_string(tmp_path): # STO-068
"""model: 42 in YAML → str coercion → prompt.model == '42' (R7)."""
storage = QueueStorage(str(tmp_path))
file_path = storage.queue_dir / "abc12345-task.md"
file_path.write_text(
"---\npriority: 0\nworking_directory: .\nmax_retries: 3\n"
"model: 42\n"
"status: queued\nretry_count: 0\ncreated_at: 2025-01-01T00:00:00\n---\n\ncontent"
)
prompt = storage.parser.parse_prompt_file(file_path)
assert prompt is not None
assert prompt.model == "42"


def test_model_roundtrip_write_then_parse(tmp_path): # STO-069
"""model survives write_prompt_file → parse_prompt_file round-trip."""
storage = QueueStorage(str(tmp_path))
prompt = QueuedPrompt(id="abc12345", content="task", model="claude-opus-4-6")
file_path = storage.queue_dir / "abc12345-task.md"
storage.parser.write_prompt_file(prompt, file_path)
parsed = storage.parser.parse_prompt_file(file_path)
assert parsed is not None
assert parsed.model == "claude-opus-4-6"


def test_model_none_roundtrip_write_then_parse(tmp_path): # STO-070
"""model=None survives write → parse round-trip (field omitted from YAML)."""
storage = QueueStorage(str(tmp_path))
prompt = QueuedPrompt(id="abc12345", content="task", model=None)
file_path = storage.queue_dir / "abc12345-task.md"
storage.parser.write_prompt_file(prompt, file_path)
parsed = storage.parser.parse_prompt_file(file_path)
assert parsed is not None
assert parsed.model is None


def test_create_prompt_template_includes_model_field(tmp_path): # STO-071
"""create_prompt_template() output includes 'model: null' in frontmatter."""
storage = QueueStorage(str(tmp_path))
file_path = storage.create_prompt_template("my-task")
content = file_path.read_text()
assert "model: null" in content


def test_save_prompt_to_bank_includes_model_field(tmp_path): # STO-072
"""save_prompt_to_bank() output includes 'model: null' in frontmatter."""
storage = QueueStorage(str(tmp_path))
file_path = storage.save_prompt_to_bank("my-template")
content = file_path.read_text()
assert "model: null" in content


def test_bank_list_includes_model_key(tmp_path): # STO-073
"""list_bank_templates() dicts include a 'model' key."""
storage = QueueStorage(str(tmp_path))
storage.save_prompt_to_bank("my-template")
templates = storage.list_bank_templates()
assert len(templates) == 1
assert "model" in templates[0]


def test_parse_defaults_when_keys_missing(tmp_path): # STO-024
"""Minimal frontmatter → defaults: priority=0, max_retries=3, context_files=[],
estimated_tokens=None.
Expand Down