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
148 changes: 134 additions & 14 deletions src/prism/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,42 @@

console = Console()

PERMISSION_TIERS = {
"yolo": {
"allow": [
"Bash(*)",
"Edit",
"Read",
"Write",
"WebSearch",
"WebFetch",
"mcp__*",
],
},
"recommended": {
"allow": [
"Bash(asp:*)",
"Bash(prism:*)",
"Bash(python:*)",
"Bash(pip:*)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git branch:*)",
"Bash(git checkout:*)",
"Bash(git switch:*)",
"Edit",
"WebSearch",
"WebFetch",
],
},
"minimal": {
"allow": ["Read"],
},
}


def _get_plugin_source_dir() -> Path | None:
"""Find the Prism plugin source directory.
Expand Down Expand Up @@ -57,7 +93,13 @@ def main() -> None:
@click.option("--no-git", is_flag=True, help="Don't initialize git repository")
@click.option("--no-venv", is_flag=True, help="Don't create Python virtual environment")
@click.option("--target", "-t", default=None, help="Default execution target (e.g., perlmutter)")
def init(directory: Path, no_git: bool, no_venv: bool, target: str | None) -> None:
@click.option(
"--permissions",
type=click.Choice(["yolo", "recommended", "minimal", "custom"]),
default=None,
help="Claude Code permission tier (default: prompt or saved default)",
)
def init(directory: Path, no_git: bool, no_venv: bool, target: str | None, permissions: str | None) -> None:
"""Create a new ASP analysis project with full agentic scaffolding.

Creates the project with ASP specification files, Claude Code plugin
Expand Down Expand Up @@ -127,8 +169,9 @@ def init(directory: Path, no_git: bool, no_venv: bool, target: str | None) -> No
# Create CLAUDE.md with project conventions
_create_claude_md(directory, target=target)

# Create Claude Code settings with local skills
_create_claude_settings(directory)
# Resolve permission tier and create Claude Code settings
tier = _resolve_permission_tier(permissions)
_create_claude_settings(directory, tier)

# Write prism.yaml project config and ensure target is configured
if target:
Expand Down Expand Up @@ -323,7 +366,83 @@ def _load_prism_config(project_path: Path) -> dict[str, Any]:
return {}


def _create_claude_settings(directory: Path) -> None:
def _get_global_config_path() -> Path:
"""Return the path to the global Prism config file (~/.prism/config.yaml)."""
return Path.home() / ".prism" / "config.yaml"


def _load_global_config() -> dict[str, Any]:
"""Load the global Prism config. Returns {} if absent."""
config_path = _get_global_config_path()
if not config_path.exists():
return {}
with open(config_path) as f:
return yaml.safe_load(f) or {}


def _save_global_config(config: dict[str, Any]) -> None:
"""Save the global Prism config, creating ~/.prism/ if needed."""
config_path = _get_global_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)


def _prompt_permission_tier() -> str:
"""Interactively prompt the user to choose a permission tier.

Returns one of: 'yolo', 'recommended', 'minimal', 'custom'.
Optionally saves the choice as the default for future projects.
"""
console.print("\n[bold]Claude Code permission level[/bold]")
console.print("Controls what Claude can do without asking.\n")
console.print(" 1) yolo — Everything auto-allowed. No prompts.")
console.print(" 2) recommended — Prism workflow auto-allowed. Prompts for the rest.")
console.print(" 3) minimal — Only file reading. Everything else prompts.")
console.print(" 4) custom — Start from recommended, edit settings.json yourself.\n")

choice_map = {"1": "yolo", "2": "recommended", "3": "minimal", "4": "custom"}
raw = click.prompt("Choose [1/2/3/4]", default="2").strip()
tier = choice_map.get(raw, "recommended")

save = click.confirm(
f"Save '{tier}' as your default for future projects?",
default=True,
)
if save:
global_config = _load_global_config()
global_config["default_permission_tier"] = tier
_save_global_config(global_config)
console.print(f" Saved to {_get_global_config_path()}")

return tier


def _resolve_permission_tier(flag_value: str | None) -> str:
"""Resolve which permission tier to use.

Priority:
1. --permissions flag (explicit override)
2. Saved default in ~/.prism/config.yaml
3. Interactive prompt (first time only)
"""
# 1. Explicit flag
if flag_value is not None:
console.print(f" Permissions: [cyan]{flag_value}[/cyan] (--permissions flag)")
return flag_value

# 2. Saved default
global_config = _load_global_config()
saved = global_config.get("default_permission_tier")
if saved:
console.print(f" Permissions: [cyan]{saved}[/cyan] (saved default)")
return saved

# 3. Interactive prompt
return _prompt_permission_tier()


def _create_claude_settings(directory: Path, tier: str = "recommended") -> None:
"""Create Claude Code settings with Prism skills and agents."""
claude_dir = directory / ".claude"
claude_dir.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -356,18 +475,13 @@ def _create_claude_settings(directory: Path) -> None:
shutil.rmtree(skills_dst)
shutil.copytree(skills_src, skills_dst)

# Look up permissions for the chosen tier (custom starts from recommended)
lookup = tier if tier != "custom" else "recommended"
permissions = PERMISSION_TIERS[lookup]

# Create settings.json with hooks configured directly
settings: dict[str, Any] = {
"permissions": {
"allow": [
"Bash(asp:*)",
"Bash(prism:*)",
"Bash(python:*)",
"Edit",
"WebSearch",
"WebFetch",
],
},
"permissions": permissions,
"hooks": {
"SessionStart": [
{
Expand Down Expand Up @@ -413,6 +527,12 @@ def _create_claude_settings(directory: Path) -> None:
settings_file = claude_dir / "settings.json"
settings_file.write_text(json.dumps(settings, indent=2) + "\n")

if tier == "custom":
console.print(
f" [yellow]Custom:[/yellow] Edit [cyan]{settings_file}[/cyan] "
"to adjust permissions."
)


def _init_git_repo(directory: Path, no_git: bool) -> None:
"""Initialize git repository if requested."""
Expand Down
20 changes: 10 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_init_creates_project_structure(self, runner: CliRunner, tmp_path: Path)
project_dir = tmp_path / "my-analysis"
result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
)
assert result.exit_code == 0
assert "Created ASP analysis project" in result.output
Expand All @@ -40,7 +40,7 @@ def test_init_asp_yaml_content(self, runner: CliRunner, tmp_path: Path):
project_dir = tmp_path / "content-test"
result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
)
assert result.exit_code == 0

Expand All @@ -56,7 +56,7 @@ def test_init_gitignore_content(self, runner: CliRunner, tmp_path: Path):
project_dir = tmp_path / "gitignore-test"
result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
)
assert result.exit_code == 0

Expand All @@ -67,10 +67,10 @@ def test_init_gitignore_content(self, runner: CliRunner, tmp_path: Path):
def test_init_refuses_if_asp_yaml_exists(self, runner: CliRunner, tmp_path: Path):
"""Test that init refuses to run in an existing ASP project."""
project_dir = tmp_path / "already-init"
runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv"])
runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"])
assert (project_dir / "asp.yaml").exists()

result = runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv"])
result = runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"])
assert result.exit_code == 1
assert "already an ASP project" in result.output

Expand All @@ -82,7 +82,7 @@ def test_init_existing_nonempty_dir_decline(self, runner: CliRunner, tmp_path: P

result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
input="n\n",
)
assert result.exit_code == 0
Expand All @@ -96,7 +96,7 @@ def test_init_existing_nonempty_dir_confirm(self, runner: CliRunner, tmp_path: P

result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
input="y\n",
)
assert result.exit_code == 0
Expand All @@ -105,7 +105,7 @@ def test_init_existing_nonempty_dir_confirm(self, runner: CliRunner, tmp_path: P
def test_init_creates_dagster_yaml(self, runner: CliRunner, tmp_path: Path):
"""Test that init creates dagster.yaml."""
project_dir = tmp_path / "dagster-test"
result = runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv"])
result = runner.invoke(main, ["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"])
assert result.exit_code == 0
assert (project_dir / "dagster.yaml").exists()

Expand All @@ -118,7 +118,7 @@ def test_init_with_target_creates_prism_yaml(self, runner: CliRunner, tmp_path:
with patch("prism.dagster.targets.load_target", return_value={"name": "perlmutter"}):
result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv", "--target", "perlmutter"],
["init", str(project_dir), "--no-git", "--no-venv", "--target", "perlmutter", "--permissions", "recommended"],
)
assert result.exit_code == 0
assert (project_dir / "prism.yaml").exists()
Expand All @@ -132,7 +132,7 @@ def test_init_without_target_no_prism_yaml(self, runner: CliRunner, tmp_path: Pa
project_dir = tmp_path / "no-target-test"
result = runner.invoke(
main,
["init", str(project_dir), "--no-git", "--no-venv"],
["init", str(project_dir), "--no-git", "--no-venv", "--permissions", "recommended"],
)
assert result.exit_code == 0
assert not (project_dir / "prism.yaml").exists()
Expand Down