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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ requires-python = ">=3.12"
dependencies = [
"anthropic>=0.76.0",
"mitmproxy",
"rich",
]

[project.scripts]
noot = "noot.cli:main"

[build-system]
requires = ["uv_build>=0.5.15"]
build-backend = "uv_build"
Expand Down
92 changes: 92 additions & 0 deletions src/noot/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""CLI for noot projects."""

import argparse
import re
import sys
from pathlib import Path


def validate_project_name(name: str) -> bool:
"""Validate project name: lowercase, underscores, starts with letter."""
return bool(re.match(r"^[a-z][a-z0-9_]*$", name))


def cmd_init(args):
"""Initialize a noot project in the current directory."""
from rich.console import Console
from rich.prompt import Prompt

from noot.init import init_project

console = Console()

# Use --name if provided, otherwise prompt interactively
if args.name:
project_name = args.name
if not validate_project_name(project_name):
console.print(
"[red]Invalid name. Use lowercase letters, numbers, underscores. "
"Must start with a letter.[/red]"
)
sys.exit(1)
else:
console.print("\n[bold blue]Noot Project Setup[/bold blue]\n")
while True:
project_name = Prompt.ask("[green]Project name[/green]")
if validate_project_name(project_name):
break
console.print(
"[red]Invalid name. Use lowercase letters, numbers, underscores. "
"Must start with a letter.[/red]"
)

try:
init_project(Path.cwd(), project_name)
msg = f"[bold green]Project '{project_name}' initialized![/bold green]"
console.print(f"\n{msg}")
console.print("\n[dim]Created:[/dim]")
console.print(f" src/{project_name}/__init__.py")
console.print(f" cli/{project_name}.py")
console.print(f" tests/test_{project_name}.py")
console.print(" .cassettes/cli/")
console.print(" .cassettes/http/")
console.print(" pyproject.toml")
console.print("\n[dim]Next steps:[/dim]")
console.print(f" 1. Edit cli/{project_name}.py with your CLI")
console.print(f" 2. Edit tests/test_{project_name}.py with your tests")
console.print(" 3. Set ANTHROPIC_API_KEY environment variable")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)


def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
prog="noot",
description="Noot - Test interactive CLIs with LLM-driven flows",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")

# init command
init_parser = subparsers.add_parser(
"init", help="Initialize a noot example project"
)
init_parser.add_argument(
"--name", "-n",
help="Project name (skips interactive prompt)"
)
init_parser.set_defaults(func=cmd_init)

args = parser.parse_args()

if not args.command:
parser.print_help()
sys.exit(0)

args.func(args)


if __name__ == "__main__":
main()
77 changes: 77 additions & 0 deletions src/noot/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Project initialization for noot."""

from pathlib import Path


def init_project(target_dir: Path, project_name: str) -> None:
"""
Initialize a noot project in the target directory.

Creates:
- pyproject.toml (project configuration)
- src/{project_name}/__init__.py (package)
- cli/{project_name}.py (sample CLI)
- tests/test_{project_name}.py (sample test)
- .cassettes/cli/ (for LLM recordings)
- .cassettes/http/ (for API recordings)

Args:
target_dir: Directory to initialize (usually cwd)
project_name: Name of the project (lowercase, underscores allowed)

Raises:
FileExistsError: If files would be overwritten
RuntimeError: If template files are missing
"""
# Define file paths
pyproject_file = target_dir / "pyproject.toml"
src_init_file = target_dir / "src" / project_name / "__init__.py"
cli_file = target_dir / "cli" / f"{project_name}.py"
test_file = target_dir / "tests" / f"test_{project_name}.py"

# Check for existing files
for filepath in [pyproject_file, src_init_file, cli_file, test_file]:
if filepath.exists():
raise FileExistsError(
f"File already exists: {filepath}\n"
"Remove it or run init in a different directory."
)

# Find template files
templates_dir = Path(__file__).parent / "templates"
pyproject_template = templates_dir / "pyproject.toml.template"
cli_template = templates_dir / "cli.py.template"
test_template = templates_dir / "test_cli.py.template"

for template in [pyproject_template, cli_template, test_template]:
if not template.exists():
raise RuntimeError(
f"Template not found: {template}\n"
"Package may be incorrectly installed."
)

# Create directory structure
(target_dir / "src" / project_name).mkdir(parents=True, exist_ok=True)
(target_dir / "cli").mkdir(exist_ok=True)
(target_dir / "tests").mkdir(exist_ok=True)

# Create cassettes directories
(target_dir / ".cassettes" / "cli").mkdir(parents=True, exist_ok=True)
(target_dir / ".cassettes" / "http").mkdir(parents=True, exist_ok=True)

# Read templates and substitute project_name
def sub(template: Path) -> str:
return template.read_text().replace("{{project_name}}", project_name)

pyproject_content = sub(pyproject_template)
cli_content = sub(cli_template)
test_content = sub(test_template)

# Write files
pyproject_file.write_text(pyproject_content)
cli_file.write_text(cli_content)
test_file.write_text(test_content)
src_init_file.touch()

# Make CLI executable
cli_file.chmod(0o755)
19 changes: 19 additions & 0 deletions src/noot/templates/cli.py.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""
{{project_name}} - Sample CLI for noot testing.

Run directly: python cli/{{project_name}}.py
Test with noot: NOOT_CACHE=record python -m pytest tests/ -v -s
"""


def main():
"""Main entry point."""
name = input("What is your name? ")
if not name.strip():
name = "friend"
print(f"Hello, {name}! Welcome to {{project_name}}.")


if __name__ == "__main__":
main()
12 changes: 12 additions & 0 deletions src/noot/templates/pyproject.toml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "{{project_name}}"
version = "0.1.0"
description = "{{project_name}} - A noot-tested CLI project"
requires-python = ">=3.12"
dependencies = []

[dependency-groups]
dev = ["noot", "pytest"]

[tool.pytest.ini_options]
testpaths = ["tests"]
32 changes: 32 additions & 0 deletions src/noot/templates/test_cli.py.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Tests for {{project_name}} CLI.

Record a cassette (requires ANTHROPIC_API_KEY):
NOOT_CACHE=record python -m pytest tests/ -v -s

Replay from cassette:
NOOT_CACHE=replay python -m pytest tests/ -v -s
"""

from pathlib import Path

from noot import Flow

CLI_PATH = Path(__file__).parent.parent / "cli" / "{{project_name}}.py"


def test_greeting():
"""Test that the CLI greets the user by name."""
with Flow.spawn(f"python {CLI_PATH}") as flow:
print("\n=== Initial screen ===")
print(flow.screen())

flow.expect("What is your name")

flow.step("Type 'Alice' and press Enter")
print("\n=== After typing Alice ===")
print(flow.screen())

flow.expect("Hello, Alice")
print("\n=== Final screen ===")
print(flow.screen())
77 changes: 77 additions & 0 deletions tests/test_init_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Integration test for noot init command."""

import os
import subprocess
import tempfile
from pathlib import Path


def test_init_command_creates_project():
"""Test that 'noot init --name' creates all required files and directories."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)

# Run noot init with --name flag
result = subprocess.run(
["uv", "run", "noot", "init", "--name", "myproject"],
cwd=tmpdir,
capture_output=True,
text=True,
)

assert result.returncode == 0, f"Command failed: {result.stderr}"
assert "initialized" in result.stdout.lower()

# Verify pyproject.toml at root
assert (tmpdir / "pyproject.toml").exists()

# Verify new structure
assert (tmpdir / "src" / "myproject" / "__init__.py").exists()
assert (tmpdir / "cli" / "myproject.py").exists()
assert (tmpdir / "tests" / "test_myproject.py").exists()

# Verify .cassettes/ at project root
assert (tmpdir / ".cassettes" / "cli").is_dir()
assert (tmpdir / ".cassettes" / "http").is_dir()

# Verify CLI is executable
cli_file = tmpdir / "cli" / "myproject.py"
assert os.access(cli_file, os.X_OK)


def test_init_command_fails_if_files_exist():
"""Test that 'noot init' fails gracefully if files already exist."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)

# Create conflicting file
(tmpdir / "cli").mkdir(parents=True)
(tmpdir / "cli" / "myproject.py").write_text("existing content")

# Run noot init
result = subprocess.run(
["uv", "run", "noot", "init", "--name", "myproject"],
cwd=tmpdir,
capture_output=True,
text=True,
)

assert result.returncode != 0
assert "already exists" in result.stdout.lower()


def test_init_command_rejects_invalid_name():
"""Test that 'noot init' rejects invalid project names."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)

# Try with invalid name (uppercase)
result = subprocess.run(
["uv", "run", "noot", "init", "--name", "MyProject"],
cwd=tmpdir,
capture_output=True,
text=True,
)

assert result.returncode != 0
assert "invalid" in result.stdout.lower()
Loading