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
1 change: 1 addition & 0 deletions agr/commands/_tool_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ def sync_dependencies_to_tools(config: AgrConfig, tool_names: list[str]) -> int:
overwrite=False,
resolver=resolver,
source=source_name,
default_repo=config.default_repo,
)

tool_list = ", ".join(t.name for t in tools_needing_install)
Expand Down
1 change: 1 addition & 0 deletions agr/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def run_add(
resolver=resolver,
source=source,
skills_dirs=skills_dirs,
default_repo=config.default_repo,
)
installed_paths = [
f"{name}: {path}" for name, path in installed_paths_dict.items()
Expand Down
35 changes: 30 additions & 5 deletions agr/commands/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def _sync_individual_entries(
repo_root: Path | None,
tools: list[ToolConfig],
resolver: SourceResolver,
default_repo: str | None = None,
) -> None:
"""Sync entries one at a time, each downloading its own repo if needed.

Expand All @@ -211,6 +212,7 @@ def _sync_individual_entries(
tools,
resolver,
tools_needing_install=entry.tools_needing_install,
default_repo=default_repo,
)
except INSTALL_ERROR_TYPES as e:
results[entry.index] = SyncResult.from_error(e)
Expand All @@ -223,6 +225,7 @@ def _sync_batched_repo_entries(
tools: list[ToolConfig],
resolver: SourceResolver,
default_source: str,
default_repo: str | None = None,
) -> None:
"""Sync remote entries grouped by repo, downloading each repo only once.

Expand All @@ -235,7 +238,7 @@ def _sync_batched_repo_entries(
for entry in entries:
handle = entry.handle
source_name = entry.source_name or default_source
owner, repo_name = handle.get_github_repo()
owner, repo_name = handle.get_github_repo(default_repo=default_repo)
key = (source_name, owner, repo_name)
grouped.setdefault(key, []).append(entry)

Expand Down Expand Up @@ -324,6 +327,7 @@ def _sync_one_dependency(
resolver: SourceResolver,
skills_dirs: dict[str, Path] | None = None,
tools_needing_install: list[ToolConfig] | None = None,
default_repo: str | None = None,
) -> SyncResult:
"""Sync a single dependency: check install status and install if needed.

Expand All @@ -346,6 +350,7 @@ def _sync_one_dependency(
resolver=resolver,
source=source_name,
skills_dirs=skills_dirs,
default_repo=default_repo,
)
return SyncResult.installed(
commit=install_result.commit,
Expand Down Expand Up @@ -382,7 +387,13 @@ def _run_global_sync() -> None:
config.default_source, config.default_owner
)
result = _sync_one_dependency(
handle, source_name, None, tools, resolver, skills_dirs
handle,
source_name,
None,
tools,
resolver,
skills_dirs,
default_repo=config.default_repo,
)
except INSTALL_ERROR_TYPES as e:
result = SyncResult.from_error(e)
Expand Down Expand Up @@ -517,7 +528,14 @@ def run_sync(
# Three categories are processed separately for efficiency:
#
# 1. Local skills — no git download, just copy from the local path.
_sync_individual_entries(pending_local, results, repo_root, tools, resolver)
_sync_individual_entries(
pending_local,
results,
repo_root,
tools,
resolver,
default_repo=config.default_repo,
)

# 2. Default-repo remotes (two-part handles like "user/skill") — the
# repo name is unknown and must be discovered by trying candidates
Expand All @@ -526,7 +544,12 @@ def run_sync(
pending_remote_specific = [e for e in pending_remote if e.handle.repo is not None]

_sync_individual_entries(
pending_remote_default, results, repo_root, tools, resolver
pending_remote_default,
results,
repo_root,
tools,
resolver,
default_repo=config.default_repo,
)

# 3. Specific-repo remotes (three-part handles like "user/repo/skill") —
Expand All @@ -539,6 +562,7 @@ def run_sync(
tools,
resolver,
config.default_source,
default_repo=config.default_repo,
)

# --- Phase 3: Update lockfile ---
Expand Down Expand Up @@ -591,6 +615,7 @@ def _sync_from_lockfile(
overwrite=False,
resolver=resolver,
source=source_name,
default_repo=config.default_repo,
)
results.append((dep.identifier, SyncResult.installed()))
continue
Expand All @@ -603,7 +628,7 @@ def _sync_from_lockfile(

# Clone the repo and checkout the pinned commit
source_config = resolver.get(source_name or config.default_source)
owner, repo_name = handle.get_github_repo()
owner, repo_name = handle.get_github_repo(default_repo=config.default_repo)
with downloaded_repo(source_config, owner, repo_name) as repo_dir:
fetch_and_checkout_commit(repo_dir, locked_skill.commit)
install_skill_from_repo_to_tools(
Expand Down
96 changes: 84 additions & 12 deletions agr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from agr.exceptions import ConfigError
from agr.handle import (
DEFAULT_OWNER,
DEFAULT_REPO_NAME,
INSTALLED_NAME_SEPARATOR,
ParsedHandle,
parse_handle,
Expand Down Expand Up @@ -288,6 +289,7 @@ class AgrConfig:
default_source: str = DEFAULT_SOURCE_NAME
default_tool: str | None = None
default_owner: str | None = DEFAULT_OWNER
default_repo: str | None = DEFAULT_REPO_NAME
sync_instructions: bool | None = None
canonical_instructions: str | None = None
_path: Path | None = field(default=None, repr=False)
Expand Down Expand Up @@ -358,6 +360,27 @@ def load(cls, path: Path) -> "AgrConfig":
)
config.default_owner = default_owner

default_repo = doc.get("default_repo")
if default_repo is not None:
default_repo = str(default_repo).strip()
if not default_repo:
raise ConfigError(
"default_repo cannot be empty in agr.toml. "
"Remove the key to use the default, or set a valid repo name."
)
if "/" in default_repo:
raise ConfigError(
f"default_repo cannot contain '/': got '{default_repo}'. "
"Use a plain GitHub repository name."
)
if INSTALLED_NAME_SEPARATOR in default_repo:
raise ConfigError(
f"default_repo cannot contain '{INSTALLED_NAME_SEPARATOR}': "
f"got '{default_repo}'. "
"Use a plain GitHub repository name."
)
config.default_repo = default_repo

sync_instructions = doc.get("sync_instructions")
if sync_instructions is not None:
config.sync_instructions = bool(sync_instructions)
Expand All @@ -377,6 +400,10 @@ def load(cls, path: Path) -> "AgrConfig":
def save(self, path: Path | None = None) -> None:
"""Save configuration to agr.toml.

All configurable options are written with explanatory comments so
users can discover and edit them. Options that are unset (None) are
written as commented-out examples.

Args:
path: Path to save to (uses original path if not specified)

Expand All @@ -389,34 +416,77 @@ def save(self, path: Path | None = None) -> None:

doc: TOMLDocument = tomlkit.document()

# Always write default source and sources for clarity
# --- default_source ---
default_source = self.default_source or DEFAULT_SOURCE_NAME
sources = self.sources or default_sources()
_validate_default_source(default_source, sources)
doc.add(tomlkit.comment("Source to fetch skills from"))
doc["default_source"] = default_source
doc.add(tomlkit.nl())

# Save tools array if not default
if self.tools != list(DEFAULT_TOOL_NAMES):
tools_array = tomlkit.array()
for tool in self.tools:
tools_array.append(tool)
doc["tools"] = tools_array

# --- tools (always written) ---
doc.add(
tomlkit.comment(
'Tools to install skills to (e.g. "claude", "cursor", "codex")'
)
)
tools_array = tomlkit.array()
for tool in self.tools:
tools_array.append(tool)
doc["tools"] = tools_array
doc.add(tomlkit.nl())

# --- default_tool ---
doc.add(
tomlkit.comment(
"Primary tool for instruction sync (must be listed in tools)"
)
)
if self.default_tool:
if self.default_tool not in self.tools:
raise ValueError("default_tool must be listed in tools")
doc["default_tool"] = self.default_tool
else:
doc.add(tomlkit.comment('default_tool = "claude"'))
doc.add(tomlkit.nl())

# --- default_owner (always written) ---
doc.add(
tomlkit.comment(
"Default GitHub owner for short handles"
' (e.g. "skill" resolves to "computerlovetech/skill")'
)
)
doc["default_owner"] = self.default_owner or DEFAULT_OWNER
doc.add(tomlkit.nl())

# --- default_repo (always written) ---
doc.add(
tomlkit.comment(
"Default GitHub repo for two-part handles"
' (e.g. "owner/skill" resolves to "owner/skills/skill")'
)
)
doc["default_repo"] = self.default_repo or DEFAULT_REPO_NAME
doc.add(tomlkit.nl())

if self.default_owner is not None and self.default_owner != DEFAULT_OWNER:
doc["default_owner"] = self.default_owner

# --- sync_instructions ---
doc.add(tomlkit.comment("Sync instruction files across tools"))
if self.sync_instructions is not None:
doc["sync_instructions"] = bool(self.sync_instructions)
else:
doc.add(tomlkit.comment("sync_instructions = false"))
doc.add(tomlkit.nl())

# --- canonical_instructions ---
doc.add(tomlkit.comment("Which tool's instruction file is the source of truth"))
if self.canonical_instructions:
doc["canonical_instructions"] = self.canonical_instructions
else:
doc.add(tomlkit.comment('canonical_instructions = "claude"'))
doc.add(tomlkit.nl())

# Build dependencies array
# --- dependencies ---
deps_array = tomlkit.array()
deps_array.multiline(True)

Expand All @@ -432,6 +502,8 @@ def save(self, path: Path | None = None) -> None:
deps_array.append(item)

doc["dependencies"] = deps_array

# --- sources ---
sources_array = tomlkit.aot()
for source in sources:
table = tomlkit.table()
Expand Down
6 changes: 4 additions & 2 deletions agr/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ def _locate_remote_skill(
handle: ParsedHandle,
resolver: SourceResolver | None = None,
source: str | None = None,
default_repo: str | None = None,
) -> Generator[_RemoteSkillLocation, None, None]:
"""Search for a remote skill across sources and repo candidates.

Expand All @@ -527,7 +528,7 @@ def _locate_remote_skill(
# "agent-resources") against each configured source (e.g. "github").
# First match wins. The outer loop is repo candidates so we prefer
# the primary repo name across all sources before trying fallbacks.
for repo_name, is_legacy in iter_repo_candidates(handle.repo):
for repo_name, is_legacy in iter_repo_candidates(handle.repo, default_repo):
for source_config in resolver.ordered(source):
try:
with downloaded_repo(source_config, owner, repo_name) as repo_dir:
Expand Down Expand Up @@ -660,6 +661,7 @@ def fetch_and_install_to_tools(
resolver: SourceResolver | None = None,
source: str | None = None,
skills_dirs: dict[str, Path] | None = None,
default_repo: str | None = None,
) -> tuple[dict[str, Path], InstallResult]:
"""Fetch skill once and install to multiple tools.

Expand Down Expand Up @@ -704,7 +706,7 @@ def fetch_and_install_to_tools(
install_result = InstallResult()
with (
_rollback_on_failure() as installed,
_locate_remote_skill(handle, resolver, source) as loc,
_locate_remote_skill(handle, resolver, source, default_repo) as loc,
):
for tool in tools:
skills_dir = _resolve_skills_dir(
Expand Down
26 changes: 18 additions & 8 deletions agr/handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,27 @@ def is_local_path_ref(ref: str) -> bool:
return ref.startswith(LOCAL_PATH_PREFIXES)


def iter_repo_candidates(repo: str | None) -> list[tuple[str, bool]]:
def iter_repo_candidates(
repo: str | None, default_repo: str | None = None
) -> list[tuple[str, bool]]:
"""Return repo candidates for owner-only handles.

Args:
repo: Explicit repo name or None for defaults.
default_repo: Configured default repo name. Falls back to
``DEFAULT_REPO_NAME`` ("skills") when *None*.

Returns:
List of (repo_name, is_legacy) candidates in priority order.
"""
if repo:
return [(repo, False)]
return [
(DEFAULT_REPO_NAME, False),
(LEGACY_DEFAULT_REPO_NAME, True),
]
effective_default = default_repo or DEFAULT_REPO_NAME
candidates: list[tuple[str, bool]] = [(effective_default, False)]
# Only try legacy fallback when using the standard default repo
if effective_default == DEFAULT_REPO_NAME:
candidates.append((LEGACY_DEFAULT_REPO_NAME, True))
return candidates


@dataclass
Expand Down Expand Up @@ -129,11 +135,15 @@ def to_installed_name(self) -> str:
return f"{self.username}{sep}{self.repo}{sep}{self.name}"
return f"{self.username}{sep}{self.name}"

def get_github_repo(self) -> tuple[str, str]:
def get_github_repo(self, default_repo: str | None = None) -> tuple[str, str]:
"""Get (owner, repo_name) for git download.

Args:
default_repo: Configured default repo name. Falls back to
``DEFAULT_REPO_NAME`` ("skills") when *None*.

Returns:
Tuple of (owner, repo_name). repo_name defaults to "skills".
Tuple of (owner, repo_name).

Raises:
InvalidHandleError: If this is a local handle.
Expand All @@ -142,7 +152,7 @@ def get_github_repo(self) -> tuple[str, str]:
raise InvalidHandleError("Cannot get GitHub repo for local handle")
if not self.username:
raise InvalidHandleError("No username in handle")
return (self.username, self.repo or DEFAULT_REPO_NAME)
return (self.username, self.repo or default_repo or DEFAULT_REPO_NAME)

def to_skill_path(self, tool: "ToolConfig") -> Path:
"""Get default skill installation path based on tool capabilities.
Expand Down
Loading
Loading