diff --git a/CLAUDE.md b/CLAUDE.md index c154483..b06fc1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -173,7 +174,7 @@ retry_not_before: null | Command | Purpose | Needs `claude` binary? | |---|---|---| | `start [--verbose] [--no-skip-permissions]` | Run queue loop | Yes | -| `add [-p priority]` | Quick-add prompt | No | +| `add [-p priority] [-m model]` | Quick-add prompt | No | | `template [-p priority]` | Create template .md | No | | `status [--json] [--detailed]` | Queue stats | No | | `list [--status ] [--json]` | List prompts | No | diff --git a/src/claude_code_queue/claude_interface.py b/src/claude_code_queue/claude_interface.py index 19d8b3f..ebaad5c 100644 --- a/src/claude_code_queue/claude_interface.py +++ b/src/claude_code_queue/claude_interface.py @@ -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. diff --git a/src/claude_code_queue/cli.py b/src/claude_code_queue/cli.py index 489fe42..b83539d 100644 --- a/src/claude_code_queue/cli.py +++ b/src/claude_code_queue/cli.py @@ -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" @@ -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 @@ -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() diff --git a/src/claude_code_queue/models.py b/src/claude_code_queue/models.py index 2282715..e4817e1 100644 --- a/src/claude_code_queue/models.py +++ b/src/claude_code_queue/models.py @@ -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 diff --git a/src/claude_code_queue/skills/queue/SKILL.md b/src/claude_code_queue/skills/queue/SKILL.md index 0fbec7f..b04245f 100644 --- a/src/claude_code_queue/skills/queue/SKILL.md +++ b/src/claude_code_queue/skills/queue/SKILL.md @@ -63,6 +63,7 @@ context_files: - tests/test_relevant.py max_retries: 3 estimated_tokens: null +model: null --- # Task Title @@ -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 diff --git a/src/claude_code_queue/storage.py b/src/claude_code_queue/storage.py index 44ce89f..50839f7 100644 --- a/src/claude_code_queue/storage.py +++ b/src/claude_code_queue/storage.py @@ -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, @@ -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. @@ -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: @@ -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 @@ -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()} @@ -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) }) diff --git a/tests/test_claude_interface.py b/tests/test_claude_interface.py index 181ba24..6832d2e 100644 --- a/tests/test_claude_interface.py +++ b/tests/test_claude_interface.py @@ -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 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="") diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a6cd06..02be5d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.""" @@ -66,6 +67,7 @@ def _make_template( "priority": priority, "working_directory": working_directory, "estimated_tokens": estimated_tokens, + "model": model, "modified": modified, } @@ -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 @@ -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)]) diff --git a/tests/test_models.py b/tests/test_models.py index eb5974a..5c83f75 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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() # =========================================================================== diff --git a/tests/test_storage.py b/tests/test_storage.py index cb80804..fedcbbc 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -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.