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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,36 @@ modules:
| `lola install <module>` | Install skills and commands to all assistants |
| `lola install <module> -a <assistant>` | Install to specific assistant |
| `lola install <module> <path>` | Install to a specific project directory |
| `lola install <module> --scope user` | Install globally to user configuration directories |
| `lola uninstall <module>` | Uninstall skills and commands |
| `lola uninstall <module> --scope user` | Uninstall from user configuration directories |
| `lola installed` | List all installations |
| `lola update` | Regenerate assistant files |

#### Installation Scopes

Lola supports two installation scopes:

- **Project scope** (default): Installs to project directories (`.claude/`, `.cursor/`, `.opencode/`, etc.)
- **User scope**: Installs globally to user configuration directories

**User scope locations:**
- **Linux/Unix**: `~/.config/opencode/`, `~/.claude/`, `~/.cursor/`
- **macOS**: `~/Library/Application Support/opencode/`, `~/.claude/`, `~/.cursor/`
- **Windows**: `%APPDATA%\opencode\`, `~/.claude/`, `~/.cursor/`

Examples:
```bash
# Install to current project (default)
lola install my-module

# Install globally for your user
lola install my-module --scope user

# Install to specific project
lola install my-module /path/to/project
```

## Creating a Module

### 1. Initialize
Expand Down Expand Up @@ -381,7 +407,9 @@ Commands are automatically converted to each assistant's format:
3. **Registry**: Modules are stored in `~/.lola/modules/`
4. **Installation**: Skills and commands are converted to each assistant's native format
5. **Prefixing**: Skills and commands are prefixed with module name to avoid conflicts (e.g., `mymodule-skill`)
6. **Project scope**: Copies modules to `.lola/modules/` within the project
6. **Scopes**:
- **Project scope** (default): Copies modules to `.lola/modules/` within the project
- **User scope**: Installs globally to platform-appropriate user config directories
7. **Updates**: `lola mod update` re-fetches from original source; `lola update` regenerates files; `lola market update` refreshes marketplace caches

## Contributing
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"python-frontmatter>=1.1.0",
"inquirerpy>=0.3.4",
"packaging>=24.0",
"platformdirs>=4.0.0",
]

# Dev dependencies are in [dependency-groups] below (PEP 735)
Expand All @@ -30,7 +31,10 @@ lola = "lola.__main__:main"

[tool.hatch.version]
source = "vcs"
raw-options = { local_scheme = "no-local-version" }

[tool.hatch.version.raw-options]
local_scheme = "no-local-version"
git_describe_command = "git describe --dirty --tags --long --match v*.*.*"

[tool.hatch.build.hooks.vcs]
version-file = "src/lola/_version.py"
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ platformdirs==4.9.2 \
--hash=sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd \
--hash=sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291
# via
# lola-ai
# python-discovery
# virtualenv
pluggy==1.6.0 \
Expand Down
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ pfzy==0.3.4 \
--hash=sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96 \
--hash=sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1
# via inquirerpy
platformdirs==4.9.2 \
--hash=sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd \
--hash=sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291
# via lola-ai
Comment thread
coderabbitai[bot] marked this conversation as resolved.
prompt-toolkit==3.0.52 \
--hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \
--hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955
Expand Down
2 changes: 1 addition & 1 deletion src/lola/cli/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ def remove_module(module_name: str | None, force: bool):
continue

target = get_target(inst.assistant)
skill_dest = target.get_skill_path(inst.project_path)
skill_dest = target.get_skill_path(inst.project_path, inst.scope)

# Remove generated skill files
if target.uses_managed_section:
Expand Down
15 changes: 15 additions & 0 deletions src/lola/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from pathlib import Path
import os
from platformdirs import PlatformDirs

# Base lola directory
LOLA_HOME = Path(os.environ.get("LOLA_HOME", Path.home() / ".lola"))
Expand All @@ -24,3 +25,17 @@

# MCP servers definition filename
MCPS_FILE = "mcps.json"

# Platform-specific directories for user-scope installations
_PLATFORM_DIRS = PlatformDirs("opencode", appauthor=False)


def get_user_config_dir() -> Path:
"""Get user configuration directory using platform conventions.

Returns platform-appropriate config directory:
- Linux/Unix: ~/.config
- macOS: ~/Library/Application Support
- Windows: %APPDATA%
Comment on lines +34 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Correct the return-path description in this docstring.

The function returns an app-specific directory (includes opencode), but the examples currently describe only parent config roots.

Suggested docstring fix
 def get_user_config_dir() -> Path:
     """Get user configuration directory using platform conventions.
-    
-    Returns platform-appropriate config directory:
-    - Linux/Unix: ~/.config
-    - macOS: ~/Library/Application Support  
-    - Windows: %APPDATA%
+
+    Returns platform-appropriate app config directory for OpenCode:
+    - Linux/Unix: ~/.config/opencode
+    - macOS: ~/Library/Application Support/opencode
+    - Windows: %APPDATA%\\opencode
     """
     return Path(_PLATFORM_DIRS.user_config_dir)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lola/config.py` around lines 34 - 39, Update the docstring for the
function that returns the user configuration directory (the one whose docstring
begins "Get user configuration directory using platform conventions") to state
that the function returns an application-specific config path (it appends the
app folder "opencode" to the platform config root), and replace the generic
platform root examples with app-specific examples (e.g., Linux:
~/.config/opencode, macOS: ~/Library/Application Support/opencode, Windows:
%APPDATA%\\opencode) so the description matches the actual return value.

"""
return Path(_PLATFORM_DIRS.user_config_dir)
24 changes: 15 additions & 9 deletions src/lola/targets/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,24 +176,30 @@ class OpenCodeTarget(ManagedInstructionsTarget, BaseAssistantTarget):
supports_agents = True
INSTRUCTIONS_FILE = "AGENTS.md"

def get_skill_path(self, project_path: str, scope: str = "project") -> Path: # noqa: ARG002
def get_skill_path(self, project_path: str, scope: str = "project") -> Path:
if scope == "user":
return config.get_user_config_dir() / "skills"
return Path(project_path) / ".opencode" / "skills"

def get_command_path(self, project_path: str, scope: str = "project") -> Path:
base = Path.home() if scope == "user" else Path(project_path)
return base / ".opencode" / "commands"
if scope == "user":
return config.get_user_config_dir() / "commands"
return Path(project_path) / ".opencode" / "commands"

def get_agent_path(self, project_path: str, scope: str = "project") -> Path:
base = Path.home() if scope == "user" else Path(project_path)
return base / ".opencode" / "agents"
if scope == "user":
return config.get_user_config_dir() / "agents"
return Path(project_path) / ".opencode" / "agents"

def get_instructions_path(self, project_path: str, scope: str = "project") -> Path:
base = Path.home() if scope == "user" else Path(project_path)
return base / self.INSTRUCTIONS_FILE
if scope == "user":
return config.get_user_config_dir() / self.INSTRUCTIONS_FILE
return Path(project_path) / self.INSTRUCTIONS_FILE

def get_mcp_path(self, project_path: str, scope: str = "project") -> Path:
base = Path.home() if scope == "user" else Path(project_path)
return base / "opencode.json"
if scope == "user":
return config.get_user_config_dir() / "opencode.json"
return Path(project_path) / "opencode.json"

def generate_skill(
self,
Expand Down
48 changes: 48 additions & 0 deletions tests/test_cli_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,54 @@ def test_rm_cancelled(self, cli_runner, sample_module, tmp_path):
assert "Cancelled" in result.output
assert dest.exists() # Module should still exist

def test_rm_passes_scope_to_get_skill_path(self, cli_runner, tmp_path):
"""Test that mod rm passes the installation scope to get_skill_path."""
from unittest.mock import MagicMock
from lola.models import Installation, InstallationRegistry

modules_dir = tmp_path / ".lola" / "modules"
modules_dir.mkdir(parents=True)
installed_file = tmp_path / ".lola" / "installed.yml"

# Create a fake module
module_dir = modules_dir / "test-module"
module_dir.mkdir()
skills_dir = module_dir / "skills" / "test-skill"
skills_dir.mkdir(parents=True)
(skills_dir / "SKILL.md").write_text("---\ndescription: Test\n---\nContent")

# Create installation record with USER scope
registry = InstallationRegistry(installed_file)
registry.add(
Installation(
module_name="test-module",
assistant="opencode",
scope="user", # User scope - critical for this test
project_path="/some/project",
skills=["test-skill"],
)
)

# Create mock target
mock_target = MagicMock()
mock_target.uses_managed_section = False
mock_target.remove_skill.return_value = True

# Run the command
with (
patch("lola.cli.mod.MODULES_DIR", modules_dir),
patch("lola.cli.mod.INSTALLED_FILE", installed_file),
patch("lola.cli.mod.get_target", return_value=mock_target),
patch("lola.cli.mod.ensure_lola_dirs"),
):
result = cli_runner.invoke(mod, ["rm", "test-module", "-f"])

# Assert the command succeeded
assert result.exit_code == 0

# CRITICAL: Verify get_skill_path was called with the correct scope
mock_target.get_skill_path.assert_called_once_with("/some/project", "user")


class TestModRmInteractive:
"""Tests for mod rm interactive picker (no argument)."""
Expand Down
156 changes: 147 additions & 9 deletions tests/test_opencode_target.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,173 @@
"""Tests for OpenCodeTarget scope-aware path resolution."""

from pathlib import Path
import pytest
from unittest.mock import Mock, patch

from lola.targets.opencode import OpenCodeTarget
from lola.config import get_user_config_dir


# --- User scope tests ---
# --- User config directory tests ---


def test_opencode_command_path_user_scope():
def test_get_user_config_dir_with_xdg_env_set(monkeypatch):
"""Test get_user_config_dir when XDG_CONFIG_HOME is set."""
monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
# Force reload the platformdirs instance
import importlib
import lola.config

importlib.reload(lola.config)
assert get_user_config_dir() == Path("/custom/config/opencode")


def test_get_user_config_dir_without_env(monkeypatch):
"""Test get_user_config_dir falls back to platform defaults."""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
# Force reload the platformdirs instance
import importlib
import lola.config

importlib.reload(lola.config)
result = get_user_config_dir()
# On Unix systems, this should be ~/.config
# On other platforms, platformdirs will return appropriate paths
assert result.is_absolute()


# --- User scope tests with platformdirs ---


@pytest.fixture
def reload_config():
"""Fixture to reload config module after environment changes."""

def _reload():
import importlib
import lola.config

importlib.reload(lola.config)

return _reload


def test_opencode_command_path_user_scope_custom_config(monkeypatch, reload_config):
"""Test command path uses custom XDG_CONFIG_HOME when set."""
monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
reload_config()
target = OpenCodeTarget()
path = target.get_command_path("/home/user/project", "user")
assert path == Path("/custom/config/opencode/commands")


def test_opencode_agent_path_user_scope_custom_config(monkeypatch, reload_config):
"""Test agent path uses custom XDG_CONFIG_HOME when set."""
monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
reload_config()
target = OpenCodeTarget()
path = target.get_agent_path("/home/user/project", "user")
assert path == Path("/custom/config/opencode/agents")


def test_opencode_instructions_path_user_scope_custom_config(
monkeypatch, reload_config
):
"""Test instructions path uses custom XDG_CONFIG_HOME when set."""
monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
reload_config()
target = OpenCodeTarget()
path = target.get_instructions_path("/home/user/project", "user")
assert path == Path("/custom/config/opencode/AGENTS.md")


def test_opencode_mcp_path_user_scope_custom_config(monkeypatch, reload_config):
"""Test MCP path uses custom XDG_CONFIG_HOME when set."""
monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
reload_config()
target = OpenCodeTarget()
path = target.get_mcp_path("/home/user/project", "user")
assert path == Path("/custom/config/opencode/opencode.json")


def test_opencode_command_path_user_scope_platform_default(monkeypatch, reload_config):
"""Test command path uses platform defaults when XDG_CONFIG_HOME unset."""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
reload_config()
target = OpenCodeTarget()
path = target.get_command_path("/home/user/project", "user")
assert path == Path.home() / ".opencode" / "commands"
# Should use platformdirs default - ends with opencode/commands
assert str(path).endswith("opencode/commands")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make platform-default path assertions separator-agnostic.

Using str(path).endswith("opencode/...") is fragile on Windows. Line 100, Line 111, Line 124, and Line 135 should assert with Path semantics instead of hardcoded /.

Suggested test-safe assertion update
-    assert str(path).endswith("opencode/commands")
+    assert path.parts[-2:] == ("opencode", "commands")
@@
-    assert str(path).endswith("opencode/agents")
+    assert path.parts[-2:] == ("opencode", "agents")
@@
-    assert str(path).endswith("opencode/AGENTS.md")
+    assert path.parts[-2:] == ("opencode", "AGENTS.md")
@@
-    assert str(path).endswith("opencode/opencode.json")
+    assert path.parts[-2:] == ("opencode", "opencode.json")

Also applies to: 111-111, 124-124, 135-135

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_opencode_target.py` at line 100, The tests use string endswith
with forward slashes (str(path).endswith("opencode/commands")), which breaks on
Windows; update the assertions to be path-separator-agnostic by using Path
semantics (e.g., compare tail segments via path.parts or construct a Path for
the suffix). Replace the current endswith assertion on the variable path with
something like tuple(path.parts[-2:]) == ("opencode", "commands") (and similarly
for the other three occurrences) so the tests pass on all platforms.

assert path.is_absolute()


def test_opencode_agent_path_user_scope():
def test_opencode_agent_path_user_scope_platform_default(monkeypatch, reload_config):
"""Test agent path uses platform defaults when XDG_CONFIG_HOME unset."""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
reload_config()
target = OpenCodeTarget()
path = target.get_agent_path("/home/user/project", "user")
assert path == Path.home() / ".opencode" / "agents"
# Should use platformdirs default - ends with opencode/agents
assert str(path).endswith("opencode/agents")
assert path.is_absolute()


def test_opencode_instructions_path_user_scope():
def test_opencode_instructions_path_user_scope_platform_default(
monkeypatch, reload_config
):
"""Test instructions path uses platform defaults when XDG_CONFIG_HOME unset."""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
reload_config()
target = OpenCodeTarget()
path = target.get_instructions_path("/home/user/project", "user")
assert path == Path.home() / "AGENTS.md"
# Should use platformdirs default - ends with opencode/AGENTS.md
assert str(path).endswith("opencode/AGENTS.md")
assert path.is_absolute()


def test_opencode_mcp_path_user_scope():
def test_opencode_mcp_path_user_scope_platform_default(monkeypatch, reload_config):
"""Test MCP path uses platform defaults when XDG_CONFIG_HOME unset."""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
reload_config()
target = OpenCodeTarget()
path = target.get_mcp_path("/home/user/project", "user")
assert path == Path.home() / "opencode.json"
# Should use platformdirs default - ends with opencode/opencode.json
assert str(path).endswith("opencode/opencode.json")
assert path.is_absolute()


# --- Cross-platform path tests with mocked platformdirs ---


@pytest.mark.parametrize(
"platform_config_dir,platform_name",
[
("/home/user/.config/opencode", "Linux/Unix"),
("/Users/user/Library/Application Support/opencode", "macOS"),
(r"C:\Users\user\AppData\Roaming\opencode", "Windows"),
],
)
def test_opencode_paths_cross_platform(platform_config_dir, platform_name):
"""Test OpenCode paths work correctly on different platforms."""
mock_platform_dirs = Mock()
mock_platform_dirs.user_config_dir = platform_config_dir

with patch("lola.config._PLATFORM_DIRS", mock_platform_dirs):
target = OpenCodeTarget()

# Test all path types
command_path = target.get_command_path("/project", "user")
agent_path = target.get_agent_path("/project", "user")
instructions_path = target.get_instructions_path("/project", "user")
mcp_path = target.get_mcp_path("/project", "user")

# Expected base path (platformdirs already includes opencode)
base = Path(platform_config_dir)

assert command_path == base / "commands"
assert agent_path == base / "agents"
assert instructions_path == base / "AGENTS.md"
assert mcp_path == base / "opencode.json"

Comment thread
coderabbitai[bot] marked this conversation as resolved.

def test_opencode_skill_path_user_scope():
Expand Down
Loading
Loading