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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ apc status
apc sync

# 4. Or sync to specific tools only
apc sync --tools cursor,gemini
apc sync --tools cursor,gemini-cli

# 5. Install skills from a GitHub repo
apc install owner/repo --skill my-skill
Expand Down Expand Up @@ -113,7 +113,7 @@ apc configure

| Flag | Description |
|------|-------------|
| `--tools <list>` | Comma-separated tool list (e.g., `cursor,gemini`) |
| `--tools <list>` | Comma-separated tool list (e.g., `cursor,gemini-cli`) |
| `--all` | Apply to all detected tools without prompting |
| `--no-memory` | Skip memory entries |
| `--override-mcp` | Replace existing MCP servers instead of merging |
Expand Down
14 changes: 3 additions & 11 deletions src/appliers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,16 @@
"openclaw": "appliers.openclaw:OpenClawApplier",
}

_ALIASES = {
"claude": "claude-code",
"gemini": "gemini-cli",
"copilot": "github-copilot",
}


def get_applier(tool_name: str) -> BaseApplier:
"""Get the applier for a supported tool.

Raises ValueError if the tool is not supported.
"""
resolved = _ALIASES.get(tool_name, tool_name)

if resolved not in _SPECIALIZED:
raise ValueError(f"Unsupported tool: {tool_name}")
if tool_name not in _SPECIALIZED:
raise ValueError(f"Unsupported tool: {tool_name!r}. Valid tools: {', '.join(_SPECIALIZED)}")

module_path, cls_name = _SPECIALIZED[resolved].split(":")
module_path, cls_name = _SPECIALIZED[tool_name].split(":")
mod = importlib.import_module(module_path)
cls = getattr(mod, cls_name)
return cls()
Expand Down
2 changes: 1 addition & 1 deletion src/appliers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

class ClaudeApplier(BaseApplier):
SKILL_DIR = CLAUDE_SKILLS_DIR
TOOL_NAME = "claude"
TOOL_NAME = "claude-code"
MEMORY_SCHEMA = CLAUDE_MEMORY_SCHEMA

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
Expand Down
2 changes: 1 addition & 1 deletion src/appliers/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@


class CopilotApplier(BaseApplier):
TOOL_NAME = "copilot"
TOOL_NAME = "github-copilot"
MEMORY_SCHEMA = COPILOT_MEMORY_SCHEMA

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
Expand Down
2 changes: 1 addition & 1 deletion src/appliers/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@


class GeminiApplier(BaseApplier):
TOOL_NAME = "gemini"
TOOL_NAME = "gemini-cli"
MEMORY_SCHEMA = GEMINI_MEMORY_SCHEMA

def apply_skills(self, skills: List[Dict], manifest: ToolManifest) -> int:
Expand Down
14 changes: 3 additions & 11 deletions src/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@
"openclaw": "extractors.openclaw:OpenClawExtractor",
}

_ALIASES = {
"claude": "claude-code",
"gemini": "gemini-cli",
"copilot": "github-copilot",
}

# Filesystem paths used to detect if a tool is installed
_DETECT_PATHS = {
"claude-code": [Path.home() / ".claude", Path.home() / ".claude.json"],
Expand All @@ -46,12 +40,10 @@ def get_extractor(tool_name: str) -> BaseExtractor:

Raises ValueError if the tool is not supported.
"""
resolved = _ALIASES.get(tool_name, tool_name)

if resolved not in _SPECIALIZED:
raise ValueError(f"Unsupported tool: {tool_name}")
if tool_name not in _SPECIALIZED:
raise ValueError(f"Unsupported tool: {tool_name!r}. Valid tools: {', '.join(_SPECIALIZED)}")

module_path, cls_name = _SPECIALIZED[resolved].split(":")
module_path, cls_name = _SPECIALIZED[tool_name].split(":")
mod = importlib.import_module(module_path)
cls = getattr(mod, cls_name)
return cls()
8 changes: 4 additions & 4 deletions src/extractors/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def extract_skills(self) -> List[Dict]:
"tags": metadata.get("tags", []),
"targets": [],
"version": metadata.get("version", "1.0.0"),
"source_tool": "claude",
"source_tool": "claude-code",
"source_path": str(md_file),
"checksum": checksum,
}
Expand All @@ -70,7 +70,7 @@ def extract_mcp_servers(self) -> List[Dict]:
"command": cfg.get("command"),
"args": cfg.get("args", []),
"env": cfg.get("env", {}),
"source_tool": "claude",
"source_tool": "claude-code",
"targets": [],
}
)
Expand All @@ -92,8 +92,8 @@ def extract_memory(self) -> List[Dict]:
continue
entries.append(
{
"id": _content_hash_id("claude", path.name, content),
"source_tool": "claude",
"id": _content_hash_id("claude-code", path.name, content),
"source_tool": "claude-code",
"source_file": path.name,
"source_path": str(path),
"label": mf["label"],
Expand Down
4 changes: 2 additions & 2 deletions src/extractors/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def extract_skills(self) -> List[Dict]:
"tags": ["copilot", "instructions"],
"targets": [],
"version": "1.0.0",
"source_tool": "copilot",
"source_tool": "github-copilot",
"source_path": str(COPILOT_INSTRUCTIONS),
"checksum": checksum,
}
Expand All @@ -53,7 +53,7 @@ def extract_mcp_servers(self) -> List[Dict]:
"command": cfg.get("command"),
"args": cfg.get("args", []),
"env": cfg.get("env", {}),
"source_tool": "copilot",
"source_tool": "github-copilot",
"targets": [],
}
)
Expand Down
2 changes: 1 addition & 1 deletion src/extractors/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def extract_mcp_servers(self) -> List[Dict]:
"command": cfg.get("command"),
"args": cfg.get("args", []),
"env": cfg.get("env", {}),
"source_tool": "gemini",
"source_tool": "gemini-cli",
"targets": [],
}
)
Expand Down
61 changes: 46 additions & 15 deletions src/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
No login required. No network calls.
"""

from pathlib import Path

import click

from appliers.manifest import ToolManifest
from cache import load_local_bundle
from extractors import detect_installed_tools
from ui import (
Expand All @@ -17,20 +20,48 @@
)


def _build_tools_status(tool_list, bundle):
"""Build tool status list with basic sync detection."""
tools = []
for name in tool_list:
has_skills = any(s.get("source_tool") == name for s in bundle["skills"])
has_mcp = any(s.get("source_tool") == name for s in bundle["mcp_servers"])
has_data = has_skills or has_mcp
tools.append(
{
"name": name,
"status": "synced" if has_data else "not synced",
}
)
return tools
def _tool_sync_status(name: str) -> str:
"""Return sync status for a tool by comparing manifest records to disk.

- "not synced" — apc has never synced to this tool
- "synced" — all manifest-recorded files exist on disk
- "out of sync" — manifest exists but one or more recorded files are missing

Manifests are keyed by the detected tool name (e.g. "claude-code"),
matching TOOL_NAME on every applier.
"""
manifest = ToolManifest(name)

if manifest.is_first_sync:
return "not synced"

# Gather all file paths APC last wrote for this tool
recorded_paths: list[str] = []

for info_dict in manifest._data.get("skills", {}).values():
if fp := info_dict.get("file_path"):
recorded_paths.append(fp)

for info_dict in manifest._data.get("linked_skills", {}).values():
if fp := info_dict.get("link_path"):
recorded_paths.append(fp)

for info_dict in manifest._data.get("memory", {}).values():
if fp := info_dict.get("file_path"):
recorded_paths.append(fp)

# If nothing was recorded (e.g. only MCP servers were synced), trust the timestamp
if not recorded_paths:
return "synced"

# Check every recorded file still exists on disk
all_present = all(Path(fp).exists() for fp in recorded_paths)
return "synced" if all_present else "out of sync"


def _build_tools_status(tool_list):
"""Build tool status list with real consistency check against disk."""
return [{"name": name, "status": _tool_sync_status(name)} for name in tool_list]


@click.command()
Expand All @@ -43,7 +74,7 @@ def status():
bundle = load_local_bundle()

if tool_list:
tools = _build_tools_status(tool_list, bundle)
tools = _build_tools_status(tool_list)
tools_status_table(tools)
else:
warning("No AI tools detected on this machine.")
Expand Down
2 changes: 1 addition & 1 deletion src/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def cache_summary_table(skills: int, mcp: int, memory: int, title: str = "Cache
def tools_status_table(tools: List[Dict[str, str]]) -> None:
"""Display tool status with sync badges.

tools: [{"name": "claude", "status": "synced"}, ...]
tools: [{"name": "claude-code", "status": "synced"}, ...]
"""
table = Table(title="Detected Tools", show_lines=False)
table.add_column("Tool", style="cyan", no_wrap=True)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_appliers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def setUp(self):
self.manifest_path = Path(self.tmpdir) / "manifest.json"

def _manifest(self) -> ToolManifest:
return ToolManifest("claude", path=self.manifest_path)
return ToolManifest("claude-code", path=self.manifest_path)

def test_apply_skills(self):
skills = [
Expand Down Expand Up @@ -248,7 +248,7 @@ def test_apply_mcp_prunes_orphaned_server(self):
manifest.save()

# Second sync: only "fs" remains
manifest2 = ToolManifest("claude", path=self.manifest_path)
manifest2 = ToolManifest("claude-code", path=self.manifest_path)
servers_v2 = [
{
"name": "fs",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_docker_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,14 @@ def test_mcp_servers_have_correct_source_tools(self, runner, cli):
runner.invoke(cli, ["collect", "--yes"])
data = json.loads((HOME / ".apc" / "cache" / "mcp_servers.json").read_text())
sources = {s.get("source_tool") for s in data}
expected = {"claude", "cursor", "gemini", "copilot", "windsurf"}
expected = {"claude-code", "cursor", "gemini-cli", "github-copilot", "windsurf"}
assert expected.issubset(sources), f"Missing sources: {expected - sources}"

def test_memory_has_claude_entry(self, runner, cli):
runner.invoke(cli, ["collect", "--yes"])
data = json.loads((HOME / ".apc" / "cache" / "memory.json").read_text())
sources = {e.get("source_tool") for e in data}
assert "claude" in sources
assert "claude-code" in sources

def test_memory_has_openclaw_entry(self, runner, cli):
runner.invoke(cli, ["collect", "--yes"])
Expand Down
4 changes: 2 additions & 2 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_llm_memory_sync_writes_claude_md(self):
claude_dir = Path(tmpdir) / ".claude"
claude_dir.mkdir()
claude_md = claude_dir / "CLAUDE.md"
manifest = ToolManifest("claude", path=Path(tmpdir) / "manifest.json")
manifest = ToolManifest("claude-code", path=Path(tmpdir) / "manifest.json")

collected = [
{
Expand Down Expand Up @@ -60,7 +60,7 @@ def test_no_llm_configured_shows_warning(self):
from appliers.claude import ClaudeApplier

tmpdir = tempfile.mkdtemp()
manifest = ToolManifest("claude", path=Path(tmpdir) / "manifest.json")
manifest = ToolManifest("claude-code", path=Path(tmpdir) / "manifest.json")

collected = [{"id": "abc", "source_tool": "test", "content": "test"}]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def test_extract_memory_raw_file_format(self):
entry = entries[0]
# New format fields
self.assertIn("id", entry)
self.assertEqual(entry["source_tool"], "claude")
self.assertEqual(entry["source_tool"], "claude-code")
self.assertEqual(entry["source_file"], "CLAUDE.md")
self.assertIn("content", entry)
self.assertIn("TypeScript", entry["content"])
Expand Down
14 changes: 7 additions & 7 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
class TestToolManifest(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.manifest_path = Path(self.tmpdir) / "claude.json"
self.manifest_path = Path(self.tmpdir) / "claude-code.json"

def _make_manifest(self) -> ToolManifest:
return ToolManifest("claude", path=self.manifest_path)
return ToolManifest("claude-code", path=self.manifest_path)

# -- empty / first sync ---------------------------------------------------

def test_empty_manifest_creation(self):
m = self._make_manifest()
self.assertEqual(m.tool, "claude")
self.assertEqual(m.tool, "claude-code")
self.assertEqual(m.managed_skill_names(), [])
self.assertEqual(m.managed_linked_skill_names(), [])
self.assertEqual(m.managed_mcp_names(), [])
Expand Down Expand Up @@ -112,15 +112,15 @@ def test_save_and_reload(self):

self.assertTrue(self.manifest_path.exists())

m2 = ToolManifest("claude", path=self.manifest_path)
m2 = ToolManifest("claude-code", path=self.manifest_path)
self.assertEqual(m2.managed_skill_names(), ["pdf"])
self.assertEqual(m2.managed_mcp_names(), ["fs"])
self.assertEqual(m2.memory_entry_ids(), ["e1"])
self.assertFalse(m2.is_first_sync) # has last_sync_at now

def test_reload_corrupt_json_creates_empty(self):
self.manifest_path.write_text("NOT JSON", encoding="utf-8")
m = ToolManifest("claude", path=self.manifest_path)
m = ToolManifest("claude-code", path=self.manifest_path)
self.assertEqual(m.managed_skill_names(), [])
self.assertTrue(m.is_first_sync)

Expand All @@ -129,7 +129,7 @@ def test_reload_wrong_schema_version_creates_empty(self):
json.dumps({"schema_version": 999, "tool": "claude"}),
encoding="utf-8",
)
m = ToolManifest("claude", path=self.manifest_path)
m = ToolManifest("claude-code", path=self.manifest_path)
self.assertEqual(m.managed_skill_names(), [])

# -- is_first_sync --------------------------------------------------------
Expand All @@ -139,7 +139,7 @@ def test_is_first_sync_false_after_save(self):
self.assertTrue(m.is_first_sync)
m.save()

m2 = ToolManifest("claude", path=self.manifest_path)
m2 = ToolManifest("claude-code", path=self.manifest_path)
self.assertFalse(m2.is_first_sync)


Expand Down