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
31 changes: 29 additions & 2 deletions src/napoln/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."),
Expand All @@ -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
Expand Down
17 changes: 8 additions & 9 deletions src/napoln/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -177,7 +177,7 @@ def _ensure_initialized(napoln_home: Path) -> None:
config = {
"napoln": {
"default_agents": [],
"default_scope": "global",
"default_scope": "project",
},
"telemetry": {
"enabled": False,
Expand All @@ -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:
Expand All @@ -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] = {}

Expand All @@ -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(
Expand Down
76 changes: 76 additions & 0 deletions src/napoln/core/project.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion tests/steps/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/steps/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions tests/steps/test_first_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ 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


@when("I run napoln add with a valid local skill and no agents", target_fixture="result_env")
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


Expand All @@ -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


Expand Down
16 changes: 10 additions & 6 deletions tests/steps/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/steps/test_remove.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/steps/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions tests/steps/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down
4 changes: 2 additions & 2 deletions tests/unit/test_add_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ 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)

config_path = home / "config.toml"
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):
Expand Down
Loading
Loading