From 67c945c88635b0f10cdd6ae1c367ccc45a990ea9 Mon Sep 17 00:00:00 2001 From: raiderrobert Date: Tue, 26 May 2026 01:18:29 +0000 Subject: [PATCH] feat: flip napoln add default scope from global to project Implement #69. - Add --global/-g flag to napoln add as explicit global opt-in - Default napoln add to project scope when inside a project - Error outside a project with helpful hint to use --global - Add project detection via .git/.napoln/.claude/.agents/.cursor markers - Resolve scope with priority: explicit flags > config > auto-detect - Always install bootstrap skill (napoln-manage) globally - Update default config default_scope from global to project --- src/napoln/cli.py | 31 ++++++++++++- src/napoln/commands/add.py | 17 ++++--- src/napoln/core/project.py | 76 ++++++++++++++++++++++++++++++++ tests/steps/conftest.py | 2 +- tests/steps/test_config.py | 2 +- tests/steps/test_first_run.py | 6 +-- tests/steps/test_install.py | 16 ++++--- tests/steps/test_remove.py | 2 +- tests/steps/test_setup.py | 2 +- tests/steps/test_upgrade.py | 4 +- tests/unit/test_add_logic.py | 4 +- tests/unit/test_project.py | 73 ++++++++++++++++++++++++++++++ tests/unit/test_resolve_scope.py | 61 +++++++++++++++++++++++++ 13 files changed, 268 insertions(+), 28 deletions(-) create mode 100644 src/napoln/core/project.py create mode 100644 tests/unit/test_project.py create mode 100644 tests/unit/test_resolve_scope.py diff --git a/src/napoln/cli.py b/src/napoln/cli.py index 8ac7859..dc421ee 100644 --- a/src/napoln/cli.py +++ b/src/napoln/cli.py @@ -58,6 +58,9 @@ def add( project: Annotated[ bool, typer.Option("--project", "-p", help="Install to the current project.") ] = False, + global_: Annotated[ + bool, typer.Option("--global", "-g", help="Install globally (every session).") + ] = False, agents: Annotated[ Optional[str], typer.Option("--agents", help="Override auto-detected agents (comma-separated)."), @@ -72,10 +75,34 @@ def add( ) -> None: """Install skills from a git repo or local path.""" from napoln.commands.add import run_add + from napoln.core.home import get_napoln_home + from napoln.core.project import is_inside_project, load_config_default_scope, resolve_scope agent_ids = [a.strip() for a in agents.split(",")] if agents else None - scope = "project" if project else "global" - project_root = Path.cwd() if project else None + + napoln_home = get_napoln_home() + config_default = load_config_default_scope(napoln_home) + scope = resolve_scope( + global_flag=global_, + project_flag=project, + config_default=config_default, + ) + + # add is strict: outside a project without an explicit flag, refuse + if scope == "global" and not global_ and not is_inside_project(): + typer.echo( + typer.style("Error: ", fg=typer.colors.RED, bold=True) + + "No project found in the current directory.\n", + err=True, + ) + typer.echo( + "Run inside a project, or use --global to install globally:\n" + f" napoln add {source} --global", + err=True, + ) + raise typer.Exit(code=1) + + project_root = Path.cwd() if scope == "project" else None # Map --all flag to skill_filter='*' skill_filter = "*" if all_skills else skill diff --git a/src/napoln/commands/add.py b/src/napoln/commands/add.py index 91cb06c..fed065e 100644 --- a/src/napoln/commands/add.py +++ b/src/napoln/commands/add.py @@ -121,8 +121,8 @@ def run_add( if dry_run: output.dry_run_header() - # Install bootstrap skill on first run - _install_bootstrap_skill(napoln_home, home, agent_configs, scope, project_root, dry_run) + # Install bootstrap skill on first run (always global) + _install_bootstrap_skill(napoln_home, home, agent_configs, dry_run) # Load manifest once manifest_path: Path = manifest.get_manifest_path(napoln_home, scope, project_root) @@ -177,7 +177,7 @@ def _ensure_initialized(napoln_home: Path) -> None: config = { "napoln": { "default_agents": [], - "default_scope": "global", + "default_scope": "project", }, "telemetry": { "enabled": False, @@ -191,12 +191,11 @@ def _install_bootstrap_skill( napoln_home: Path, home: Path, agent_configs: list[agents_mod.AgentConfig], - scope: str, - project_root: Path | None, dry_run: bool = False, ) -> None: """Install the napoln-manage bootstrap skill if not already installed.""" - manifest_path = manifest.get_manifest_path(napoln_home, scope, project_root) + # Bootstrap is always global — it teaches agents how to use napoln. + manifest_path = manifest.get_manifest_path(napoln_home) mf = manifest.read_manifest(manifest_path) if "napoln-manage" in mf.skills: @@ -214,9 +213,9 @@ def _install_bootstrap_skill( # Store it store_path, content_hash = store.store_skill(skill_dir, "napoln-manage", "0.1.0", napoln_home) - # Place it + # Place it globally placements_map = agents_mod.deduplicate_placements( - agent_configs, "napoln-manage", home, scope, project_root + agent_configs, "napoln-manage", home, "global", None ) agent_placements: dict[str, manifest.AgentPlacement] = {} @@ -227,7 +226,7 @@ def _install_bootstrap_skill( agent_placements[agent.id] = manifest.AgentPlacement( path=str(target_path), link_mode=link_mode, - scope=scope, + scope="global", ) mf = manifest.add_skill_to_manifest( diff --git a/src/napoln/core/project.py b/src/napoln/core/project.py new file mode 100644 index 0000000..ff01089 --- /dev/null +++ b/src/napoln/core/project.py @@ -0,0 +1,76 @@ +"""Project detection and scope resolution.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + + +PROJECT_MARKERS = frozenset({".git", ".napoln", ".claude", ".agents", ".cursor"}) + + +def is_inside_project(cwd: Path | None = None) -> bool: + """Return whether the current directory appears to be inside a project. + + Walks upward from *cwd* (or :func:`os.getcwd`) looking for common + project markers: ``.git``, ``.napoln``, ``.claude``, ``.agents``, + or ``.cursor``. + """ + if cwd is None: + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + # Don't cross filesystem root + if parent == parent.parent: + break + for marker in PROJECT_MARKERS: + if (parent / marker).exists(): + return True + return False + + +def load_config_default_scope(napoln_home: Path) -> str | None: + """Read the user's configured default_scope from config.toml. + + Returns ``None`` if the config does not exist or has no default_scope set. + """ + config_path = napoln_home / "config.toml" + if not config_path.exists(): + return None + try: + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return None + raw = data.get("napoln", {}).get("default_scope") + if isinstance(raw, str) and raw in ("global", "project"): + return raw + return None + + +def resolve_scope( + *, + global_flag: bool = False, + project_flag: bool = False, + cwd: Path | None = None, + config_default: str | None = None, +) -> str: + """Resolve the effective scope for a command. + + Resolution order: + 1. Explicit flags: ``--global`` > ``--project`` + 2. Config default: the user's configured default_scope + 3. Auto-detect: ``project`` if inside a project, else ``global`` + + If *config_default* is set and the user is outside a project, + falls back to ``global`` regardless of config (project scope + requires a project). + """ + if global_flag: + return "global" + if project_flag: + return "project" + if config_default: + if config_default == "global": + return "global" + if config_default == "project" and is_inside_project(cwd): + return "project" + return "project" if is_inside_project(cwd) else "global" diff --git a/tests/steps/conftest.py b/tests/steps/conftest.py index 0db49ba..a27d1fa 100644 --- a/tests/steps/conftest.py +++ b/tests/steps/conftest.py @@ -105,7 +105,7 @@ def claude_installed(napoln_env: NapolnTestEnv, monkeypatch): @given(parsers.parse('a skill "{name}" is installed')) def skill_installed(env: NapolnTestEnv, name: str, cli_runner: CliRunner): skill_path = env.create_local_skill(name) - result = cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + result = cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) assert result.exit_code == 0, result.output diff --git a/tests/steps/test_config.py b/tests/steps/test_config.py index 11b9511..c10c900 100644 --- a/tests/steps/test_config.py +++ b/tests/steps/test_config.py @@ -46,7 +46,7 @@ def test_config_doctor_json(): @given("napoln is initialized") def napoln_initialized(env: NapolnTestEnv, cli_runner: CliRunner): skill_path = env.create_local_skill("init-skill") - cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) # ─── When ──────────────────────────────────────────────────────────────────── diff --git a/tests/steps/test_first_run.py b/tests/steps/test_first_run.py index 016f479..d91156d 100644 --- a/tests/steps/test_first_run.py +++ b/tests/steps/test_first_run.py @@ -43,7 +43,7 @@ def fresh_napoln(napoln_env: NapolnTestEnv): @when("I run napoln add with a valid local skill", target_fixture="result_env") def run_add_local(env: NapolnTestEnv, cli_runner: CliRunner): skill_path = env.create_local_skill() - env.result = cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) return env @@ -51,7 +51,7 @@ def run_add_local(env: NapolnTestEnv, cli_runner: CliRunner): def run_add_no_agents(env: NapolnTestEnv, cli_runner: CliRunner, monkeypatch): skill_path = env.create_local_skill() monkeypatch.setattr("napoln.core.agents._check_on_path", lambda cmd: False) - env.result = cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) return env @@ -60,7 +60,7 @@ def run_add_no_agents(env: NapolnTestEnv, cli_runner: CliRunner, monkeypatch): target_fixture="result_env", ) def run_add_bare_name(env: NapolnTestEnv, name: str, cli_runner: CliRunner): - env.result = cli_runner.invoke(app, ["add", name], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", name, "--global"], env=env.env_vars) return env diff --git a/tests/steps/test_install.py b/tests/steps/test_install.py index a19ea63..818d73b 100644 --- a/tests/steps/test_install.py +++ b/tests/steps/test_install.py @@ -53,7 +53,7 @@ def local_skill_exists(env: NapolnTestEnv): @given(parsers.parse('a skill "{name}" is already installed')) def skill_already_installed(env: NapolnTestEnv, name: str, cli_runner: CliRunner): env.create_local_skill(name) - result = cli_runner.invoke(app, ["add", str(env.skill_dir)], env=env.env_vars) + result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--global"], env=env.env_vars) assert result.exit_code == 0, result.output @@ -62,33 +62,37 @@ def skill_already_installed(env: NapolnTestEnv, name: str, cli_runner: CliRunner @when("I run napoln add with the local skill", target_fixture="result_env") def run_add(env: NapolnTestEnv, cli_runner: CliRunner): - env.result = cli_runner.invoke(app, ["add", str(env.skill_dir)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--global"], env=env.env_vars) return env @when("I run napoln add with dry run", target_fixture="result_env") def run_add_dry(env: NapolnTestEnv, cli_runner: CliRunner): - env.result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--dry-run"], env=env.env_vars) + env.result = cli_runner.invoke( + app, ["add", str(env.skill_dir), "--dry-run", "--global"], env=env.env_vars + ) return env @when("I run napoln add with the same skill again", target_fixture="result_env") def run_add_again(env: NapolnTestEnv, cli_runner: CliRunner): - env.result = cli_runner.invoke(app, ["add", str(env.skill_dir)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--global"], env=env.env_vars) return env @when("I run napoln add with --agents claude-code", target_fixture="result_env") def run_add_explicit_agent(env: NapolnTestEnv, cli_runner: CliRunner): env.result = cli_runner.invoke( - app, ["add", str(env.skill_dir), "--agents", "claude-code"], env=env.env_vars + app, + ["add", str(env.skill_dir), "--agents", "claude-code", "--global"], + env=env.env_vars, ) return env @when(parsers.parse('I run napoln add with a bare name "{name}"'), target_fixture="result_env") def run_add_bare_name(env: NapolnTestEnv, name: str, cli_runner: CliRunner): - env.result = cli_runner.invoke(app, ["add", name], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", name, "--global"], env=env.env_vars) return env diff --git a/tests/steps/test_remove.py b/tests/steps/test_remove.py index fea59d7..96485d0 100644 --- a/tests/steps/test_remove.py +++ b/tests/steps/test_remove.py @@ -52,7 +52,7 @@ def no_skills(env: NapolnTestEnv): @given(parsers.parse('a skill "{name}" is installed from "{source}"')) def skill_installed_from_source(env: NapolnTestEnv, name: str, source: str, cli_runner: CliRunner): skill_path = env.create_local_skill(name) - result = cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + result = cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) assert result.exit_code == 0, result.output mf_path = env.napoln_home / "manifest.toml" diff --git a/tests/steps/test_setup.py b/tests/steps/test_setup.py index db54ca8..97632c1 100644 --- a/tests/steps/test_setup.py +++ b/tests/steps/test_setup.py @@ -93,7 +93,7 @@ def run_setup_noninteractive(env: NapolnTestEnv, cli_runner: CliRunner): @when("I run napoln add with a valid local skill", target_fixture="result_env") def run_add(env: NapolnTestEnv, cli_runner: CliRunner): skill_path = env.create_local_skill() - env.result = cli_runner.invoke(app, ["add", str(skill_path)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(skill_path), "--global"], env=env.env_vars) return env diff --git a/tests/steps/test_upgrade.py b/tests/steps/test_upgrade.py index 9c85e55..8dec895 100644 --- a/tests/steps/test_upgrade.py +++ b/tests/steps/test_upgrade.py @@ -64,7 +64,7 @@ def test_script_kept(): @given(parsers.parse('a skill "{name}" is installed at version "{version}"')) def skill_installed_at_version(env: NapolnTestEnv, name: str, version: str, cli_runner: CliRunner): env.create_local_skill(name, version, BASE_BODY) - env.result = cli_runner.invoke(app, ["add", str(env.skill_dir)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--global"], env=env.env_vars) assert env.result.exit_code == 0, env.result.output @@ -81,7 +81,7 @@ def skill_with_script_installed( BASE_BODY, extra_files={"scripts/run.sh": "#!/bin/bash\necho v1\n"}, ) - env.result = cli_runner.invoke(app, ["add", str(env.skill_dir)], env=env.env_vars) + env.result = cli_runner.invoke(app, ["add", str(env.skill_dir), "--global"], env=env.env_vars) assert env.result.exit_code == 0, env.result.output diff --git a/tests/unit/test_add_logic.py b/tests/unit/test_add_logic.py index af7f4fe..5b3ecff 100644 --- a/tests/unit/test_add_logic.py +++ b/tests/unit/test_add_logic.py @@ -16,7 +16,7 @@ def test_creates_directory_structure(self, tmp_path): assert (home / "store").is_dir() assert (home / "cache").is_dir() - def test_writes_default_config(self, tmp_path): + def test_writes_default_config_with_project_scope(self, tmp_path): home = tmp_path / ".napoln" _ensure_initialized(home) @@ -24,7 +24,7 @@ def test_writes_default_config(self, tmp_path): assert config_path.exists() data = tomllib.loads(config_path.read_text()) assert data["napoln"]["default_agents"] == [] - assert data["napoln"]["default_scope"] == "global" + assert data["napoln"]["default_scope"] == "project" assert data["telemetry"]["enabled"] is False def test_does_not_overwrite_existing_config(self, tmp_path): diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py new file mode 100644 index 0000000..635af24 --- /dev/null +++ b/tests/unit/test_project.py @@ -0,0 +1,73 @@ +"""Tests for napoln.core.project — project detection.""" + +from __future__ import annotations + + +from napoln.core.project import is_inside_project, load_config_default_scope + + +class TestIsInsideProject: + def test_inside_git_repo(self, tmp_path): + project = tmp_path / "repo" + project.mkdir() + (project / ".git").mkdir() + assert is_inside_project(project) is True + + def test_inside_git_submodule(self, tmp_path): + project = tmp_path / "repo" + project.mkdir() + (project / ".git").write_text("gitdir: ../.git/modules/repo") + assert is_inside_project(project) is True + + def test_inside_napoln_project(self, tmp_path): + project = tmp_path / "my-project" + project.mkdir() + (project / ".napoln").mkdir() + assert is_inside_project(project) is True + + def test_inside_claude_project(self, tmp_path): + project = tmp_path / "repo" + project.mkdir() + (project / ".claude").mkdir() + assert is_inside_project(project) is True + + def test_inside_nested_git_repo(self, tmp_path): + project = tmp_path / "repo" / "src" + project.mkdir(parents=True) + (tmp_path / "repo" / ".git").mkdir() + assert is_inside_project(project) is True + + def test_outside_any_project(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + assert is_inside_project(outside) is False + + def test_empty_directory(self, tmp_path): + empty = tmp_path / "empty" + empty.mkdir() + assert is_inside_project(empty) is False + + +class TestLoadConfigDefaultScope: + def test_reads_project_default(self, tmp_path): + napoln_home = tmp_path / ".napoln" + napoln_home.mkdir() + (napoln_home / "config.toml").write_text('[napoln]\ndefault_scope = "project"\n') + assert load_config_default_scope(napoln_home) == "project" + + def test_reads_global_default(self, tmp_path): + napoln_home = tmp_path / ".napoln" + napoln_home.mkdir() + (napoln_home / "config.toml").write_text('[napoln]\ndefault_scope = "global"\n') + assert load_config_default_scope(napoln_home) == "global" + + def test_returns_none_when_missing(self, tmp_path): + napoln_home = tmp_path / ".napoln" + napoln_home.mkdir() + assert load_config_default_scope(napoln_home) is None + + def test_returns_none_when_invalid(self, tmp_path): + napoln_home = tmp_path / ".napoln" + napoln_home.mkdir() + (napoln_home / "config.toml").write_text('[napoln]\ndefault_scope = "invalid"\n') + assert load_config_default_scope(napoln_home) is None diff --git a/tests/unit/test_resolve_scope.py b/tests/unit/test_resolve_scope.py new file mode 100644 index 0000000..f3e42f5 --- /dev/null +++ b/tests/unit/test_resolve_scope.py @@ -0,0 +1,61 @@ +"""Tests for scope resolution in napoln.core.project.""" + +from __future__ import annotations + + +from napoln.core.project import resolve_scope + + +class TestResolveScope: + """Scope resolution: explicit flags → config → project detection.""" + + def test_explicit_global_flag(self, tmp_path): + assert resolve_scope(global_flag=True, project_flag=False, cwd=tmp_path) == "global" + + def test_explicit_project_flag(self, tmp_path): + assert resolve_scope(global_flag=False, project_flag=True, cwd=tmp_path) == "project" + + def test_both_flags_explicit_global_wins(self, tmp_path): + """If both flags are passed, global takes precedence.""" + assert resolve_scope(global_flag=True, project_flag=True, cwd=tmp_path) == "global" + + def test_no_flags_inside_project(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + assert resolve_scope(global_flag=False, project_flag=False, cwd=repo) == "project" + + def test_no_flags_outside_project(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + assert resolve_scope(global_flag=False, project_flag=False, cwd=outside) == "global" + + def test_config_default_global_overrides_detection(self, tmp_path): + """If config says global and no explicit flags, use global even in a project.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + assert ( + resolve_scope(global_flag=False, project_flag=False, cwd=repo, config_default="global") + == "global" + ) + + def test_config_default_project_outside_project(self, tmp_path): + """If config says project but we're outside a project, fall back to global.""" + outside = tmp_path / "outside" + outside.mkdir() + assert ( + resolve_scope( + global_flag=False, project_flag=False, cwd=outside, config_default="project" + ) + == "global" + ) + + def test_explicit_flag_overrides_config(self, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + assert ( + resolve_scope(global_flag=False, project_flag=True, cwd=repo, config_default="global") + == "project" + )