agent-catalog-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-catalog-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Scan a directory for AI-assistant skills, rules and subagents and copy selected ones into a chosen assistant's folder layout.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -38,37 +38,48 @@ Names and descriptions come from YAML frontmatter (`name`, `description`); rules
38
38
  uv run agent-catalog init /path/to/source
39
39
  ```
40
40
 
41
- ### `index-skills <target_dir>` / `index-plugins <target_dir>`
41
+ ### `add skills <target_dir>` / `add agents <target_dir>` / `add rules <target_dir>`
42
42
 
43
- Additive scans for patterned skill collections, discovered recursively anywhere under `target_dir`. Each immediate `<skill>/` subfolder containing a `SKILL.md` is indexed and **appended** to the existing `.agent-catalog.json` (deduped by absolute path, so re-runs and overlap with `init` are safe).
43
+ Additive scans, discovered recursively anywhere under `target_dir`, **appended** to the existing `.agent-catalog.json` (deduped by absolute path, so re-runs and overlap with `init` are safe). `skip` dirs (`.git`, `.venv`, `node_modules`, …) are excluded.
44
44
 
45
- - `index-skills` — folders named `skills/` (`skills/<skill>/`).
46
- - `index-plugins` — folders named `claude-plugin/` and `cursor-plugin/` (`claude-plugin/<skill>/`, `cursor-plugin/<skill>/`).
45
+ - `add skills` — `<skill>/` packages (containing `SKILL.md`) under folders named `skills/`.
46
+ - `add agents` — `*.md` files directly under any `agents/` folder.
47
+ - `add rules` — `*.md` / `*.mdc` under any `rules/` or `instructions/` folder, plus `CLAUDE.md` / `AGENTS.md` memory files.
48
+
49
+ > `add plugins` (folders named `claude-plugin/` / `cursor-plugin/`) is temporarily suspended.
47
50
 
48
51
  ```bash
49
- uv run agent-catalog index-skills /path/to/source
50
- uv run agent-catalog index-plugins /path/to/source
52
+ uv run agent-catalog add skills /path/to/source
53
+ uv run agent-catalog add agents /path/to/source
54
+ uv run agent-catalog add rules /path/to/source
51
55
  ```
52
56
 
53
- ### `pick-skills` / `pick-agents` / `pick-rules`
57
+ ### `pick skills [TERM]` / `pick agents [TERM]` / `pick rules [TERM]`
58
+
59
+ Loads the cache and opens an interactive multi-select (Arrows to move, type to filter live, `<Space>` to toggle, `Enter` to confirm), asks which coding assistant to target, and copies the selected artifacts into that assistant's folder.
54
60
 
55
- Loads the cache and opens on the full catalog as an interactive multi-select (Arrows to move, type to filter live, `<Space>` to toggle, `Enter` to confirm), asks which coding assistant to target, and copies the selected artifacts into that assistant's folder.
61
+ `TERM` is **optional**. When provided, the catalog is pre-narrowed by a case-insensitive substring matched against each artifact's **name and description** before the picker opens handy for large catalogs. Live type-to-filter still applies on top. Omit `TERM` to list everything.
56
62
 
57
63
  ```bash
58
- uv run agent-catalog pick-skills # copy into ./ (cwd)
59
- uv run agent-catalog pick-rules --target ./proj # copy into another project root
60
- uv run agent-catalog pick-agents --assistant .claude --overwrite
64
+ uv run agent-catalog pick skills # copy into ./ (cwd)
65
+ uv run agent-catalog pick skills wiki # pre-filter to name/description containing "wiki"
66
+ uv run agent-catalog pick rules --target ./proj # copy into another project root
67
+ uv run agent-catalog pick agents --assistant .claude --overwrite
61
68
  ```
62
69
 
63
- ### `pick-skills:filter` / `pick-agents:filter` / `pick-rules:filter`
70
+ ### `install-skill`
64
71
 
65
- Same as the base pickers, but pre-narrow the catalog by a filter TERM (case-insensitive substring on the artifact **name**) before the picker opens — handy for large catalogs. If TERM is omitted you are prompted for it. Live type-to-filter still applies on top.
72
+ Installs the bundled `agent-catalog` management skill (shipped inside the package at `agents_catalog/skills/agent-catalog/`) into a chosen root. Non-interactive.
66
73
 
67
74
  ```bash
68
- uv run agent-catalog pick-skills:filter wiki # only skills whose name contains "wiki"
69
- uv run agent-catalog pick-rules:filter # prompts for a filter term
75
+ uv run agent-catalog install-skill --target . --assistant .cursor # ./.cursor/skills/agent-catalog/
76
+ uv run agent-catalog install-skill --target ~ --assistant .cursor # ~/.cursor/skills/agent-catalog/ (global)
70
77
  ```
71
78
 
79
+ - `--target, -t` — root to install into (default: cwd; `~` for global).
80
+ - `--assistant, -a` — assistant folder convention (default: `.cursor`).
81
+ - `--overwrite` — refresh an existing copy.
82
+
72
83
  Options (all pick commands):
73
84
 
74
85
  - `--target, -t` — project root to copy into (default: cwd).
@@ -27,37 +27,48 @@ Names and descriptions come from YAML frontmatter (`name`, `description`); rules
27
27
  uv run agent-catalog init /path/to/source
28
28
  ```
29
29
 
30
- ### `index-skills <target_dir>` / `index-plugins <target_dir>`
30
+ ### `add skills <target_dir>` / `add agents <target_dir>` / `add rules <target_dir>`
31
31
 
32
- Additive scans for patterned skill collections, discovered recursively anywhere under `target_dir`. Each immediate `<skill>/` subfolder containing a `SKILL.md` is indexed and **appended** to the existing `.agent-catalog.json` (deduped by absolute path, so re-runs and overlap with `init` are safe).
32
+ Additive scans, discovered recursively anywhere under `target_dir`, **appended** to the existing `.agent-catalog.json` (deduped by absolute path, so re-runs and overlap with `init` are safe). `skip` dirs (`.git`, `.venv`, `node_modules`, …) are excluded.
33
33
 
34
- - `index-skills` — folders named `skills/` (`skills/<skill>/`).
35
- - `index-plugins` — folders named `claude-plugin/` and `cursor-plugin/` (`claude-plugin/<skill>/`, `cursor-plugin/<skill>/`).
34
+ - `add skills` — `<skill>/` packages (containing `SKILL.md`) under folders named `skills/`.
35
+ - `add agents` — `*.md` files directly under any `agents/` folder.
36
+ - `add rules` — `*.md` / `*.mdc` under any `rules/` or `instructions/` folder, plus `CLAUDE.md` / `AGENTS.md` memory files.
37
+
38
+ > `add plugins` (folders named `claude-plugin/` / `cursor-plugin/`) is temporarily suspended.
36
39
 
37
40
  ```bash
38
- uv run agent-catalog index-skills /path/to/source
39
- uv run agent-catalog index-plugins /path/to/source
41
+ uv run agent-catalog add skills /path/to/source
42
+ uv run agent-catalog add agents /path/to/source
43
+ uv run agent-catalog add rules /path/to/source
40
44
  ```
41
45
 
42
- ### `pick-skills` / `pick-agents` / `pick-rules`
46
+ ### `pick skills [TERM]` / `pick agents [TERM]` / `pick rules [TERM]`
47
+
48
+ Loads the cache and opens an interactive multi-select (Arrows to move, type to filter live, `<Space>` to toggle, `Enter` to confirm), asks which coding assistant to target, and copies the selected artifacts into that assistant's folder.
43
49
 
44
- Loads the cache and opens on the full catalog as an interactive multi-select (Arrows to move, type to filter live, `<Space>` to toggle, `Enter` to confirm), asks which coding assistant to target, and copies the selected artifacts into that assistant's folder.
50
+ `TERM` is **optional**. When provided, the catalog is pre-narrowed by a case-insensitive substring matched against each artifact's **name and description** before the picker opens handy for large catalogs. Live type-to-filter still applies on top. Omit `TERM` to list everything.
45
51
 
46
52
  ```bash
47
- uv run agent-catalog pick-skills # copy into ./ (cwd)
48
- uv run agent-catalog pick-rules --target ./proj # copy into another project root
49
- uv run agent-catalog pick-agents --assistant .claude --overwrite
53
+ uv run agent-catalog pick skills # copy into ./ (cwd)
54
+ uv run agent-catalog pick skills wiki # pre-filter to name/description containing "wiki"
55
+ uv run agent-catalog pick rules --target ./proj # copy into another project root
56
+ uv run agent-catalog pick agents --assistant .claude --overwrite
50
57
  ```
51
58
 
52
- ### `pick-skills:filter` / `pick-agents:filter` / `pick-rules:filter`
59
+ ### `install-skill`
53
60
 
54
- Same as the base pickers, but pre-narrow the catalog by a filter TERM (case-insensitive substring on the artifact **name**) before the picker opens — handy for large catalogs. If TERM is omitted you are prompted for it. Live type-to-filter still applies on top.
61
+ Installs the bundled `agent-catalog` management skill (shipped inside the package at `agents_catalog/skills/agent-catalog/`) into a chosen root. Non-interactive.
55
62
 
56
63
  ```bash
57
- uv run agent-catalog pick-skills:filter wiki # only skills whose name contains "wiki"
58
- uv run agent-catalog pick-rules:filter # prompts for a filter term
64
+ uv run agent-catalog install-skill --target . --assistant .cursor # ./.cursor/skills/agent-catalog/
65
+ uv run agent-catalog install-skill --target ~ --assistant .cursor # ~/.cursor/skills/agent-catalog/ (global)
59
66
  ```
60
67
 
68
+ - `--target, -t` — root to install into (default: cwd; `~` for global).
69
+ - `--assistant, -a` — assistant folder convention (default: `.cursor`).
70
+ - `--overwrite` — refresh an existing copy.
71
+
61
72
  Options (all pick commands):
62
73
 
63
74
  - `--target, -t` — project root to copy into (default: cwd).
@@ -0,0 +1,289 @@
1
+ """Typer CLI: index AI-assistant artifacts and copy selected ones into a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import questionary
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from . import cache as cache_mod
14
+ from . import destinations as dest_mod
15
+ from . import scanner
16
+ from .installer import CopyResult, install
17
+ from .models import Artifact, Catalog, Kind
18
+
19
+ # The agent-catalog management skill ships inside the package so the CLI can install it.
20
+ SELF_SKILL_DIR = Path(__file__).resolve().parent / "skills" / "agent-catalog"
21
+
22
+ app = typer.Typer(
23
+ help="Scan a directory for AI-assistant skills, rules and subagents, then copy selected ones into a project.",
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ )
27
+ console = Console()
28
+
29
+ _KIND_LABEL = {"skill": "skills", "agent": "agents", "rule": "rules"}
30
+
31
+ add_app = typer.Typer(
32
+ help="Additively index skill, plugin, agent and rule collections into the cache.",
33
+ no_args_is_help=True,
34
+ )
35
+ pick_app = typer.Typer(
36
+ help="Interactively select indexed artifacts and copy them into a project.",
37
+ no_args_is_help=True,
38
+ )
39
+ app.add_typer(add_app, name="add")
40
+ app.add_typer(pick_app, name="pick")
41
+
42
+ # Shared parameter defaults reused across the `add` / `pick` subcommands (DRY).
43
+ TARGET_DIR_ARG = typer.Argument(
44
+ ...,
45
+ exists=True,
46
+ file_okay=False,
47
+ dir_okay=True,
48
+ readable=True,
49
+ resolve_path=True,
50
+ help="Directory to scan for skill/plugin/agent/rule collections.",
51
+ )
52
+ TERM_ARG = typer.Argument(
53
+ None,
54
+ metavar="[TERM]",
55
+ help=(
56
+ "Optional case-insensitive substring matched against artifact name and "
57
+ "description to pre-filter the picker. Omit to list everything."
58
+ ),
59
+ )
60
+ TARGET_OPT = typer.Option(
61
+ Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
62
+ )
63
+ ASSISTANT_OPT = typer.Option(
64
+ None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
65
+ )
66
+ OVERWRITE_OPT = typer.Option(
67
+ False,
68
+ "--overwrite",
69
+ help=(
70
+ "Replace artifacts that already exist at the destination (skills merge over the "
71
+ "folder, overwriting overlapping files; single files are replaced). Without it, "
72
+ "existing targets are skipped."
73
+ ),
74
+ )
75
+
76
+
77
+ @app.command()
78
+ def init(
79
+ target_dir: Path = typer.Argument(
80
+ ...,
81
+ exists=True,
82
+ file_okay=False,
83
+ dir_okay=True,
84
+ readable=True,
85
+ resolve_path=True,
86
+ help="Directory to scan for .agents/.cursor/.claude/.github/.codex configs.",
87
+ ),
88
+ ) -> None:
89
+ """Scan TARGET_DIR and cache discovered skills, subagents and rules."""
90
+ catalog = scanner.scan(target_dir)
91
+ path = cache_mod.write_cache(catalog)
92
+
93
+ table = Table(title="Indexed artifacts")
94
+ table.add_column("Kind")
95
+ table.add_column("Count", justify="right")
96
+ table.add_row("Skills", str(len(catalog.skills)))
97
+ table.add_row("Agents", str(len(catalog.agents)))
98
+ table.add_row("Rules", str(len(catalog.rules)))
99
+ console.print(table)
100
+ console.print(f"Cache written to [bold]{path}[/bold]")
101
+
102
+
103
+ def _add(new: list[Artifact], kind: Kind) -> None:
104
+ catalog = cache_mod.append_artifacts(new)
105
+ total = len(catalog.by_kind(kind))
106
+ console.print(
107
+ f"Added [bold]{len(new)}[/bold] {kind}(s); cache now has {total} {kind}(s)."
108
+ )
109
+
110
+
111
+ @add_app.command("skills")
112
+ def add_skills(target_dir: Path = TARGET_DIR_ARG) -> None:
113
+ """Find skills/<skill> packages under TARGET_DIR and add them to the cache."""
114
+ _add(scanner.scan_collections(target_dir, scanner.SKILL_COLLECTION_DIRS), "skill")
115
+
116
+
117
+ # Suspended for now: re-enable by uncommenting to restore `add plugins`.
118
+ # @add_app.command("plugins")
119
+ # def add_plugins(target_dir: Path = TARGET_DIR_ARG) -> None:
120
+ # """Find claude-plugin/<skill> and cursor-plugin/<skill> packages under TARGET_DIR and add them."""
121
+ # _add(scanner.scan_collections(target_dir, scanner.PLUGIN_COLLECTION_DIRS), "skill")
122
+
123
+
124
+ @add_app.command("agents")
125
+ def add_agents(target_dir: Path = TARGET_DIR_ARG) -> None:
126
+ """Find *.md under agents/ folders anywhere under TARGET_DIR and add them to the cache."""
127
+ _add(scanner.scan_agent_collections(target_dir), "agent")
128
+
129
+
130
+ @add_app.command("rules")
131
+ def add_rules(target_dir: Path = TARGET_DIR_ARG) -> None:
132
+ """Find rules under rules//instructions/ folders (plus memory files) under TARGET_DIR and add them."""
133
+ _add(scanner.scan_rule_collections(target_dir), "rule")
134
+
135
+
136
+ def _load_catalog() -> Catalog:
137
+ try:
138
+ return cache_mod.read_cache()
139
+ except FileNotFoundError:
140
+ console.print(
141
+ "[red]No cache found.[/red] Run [bold]agent-catalog init <target_dir>[/bold] first."
142
+ )
143
+ raise typer.Exit(code=1)
144
+
145
+
146
+ def _choice_label(a: Artifact) -> str:
147
+ desc = a.description.strip().replace("\n", " ")
148
+ if len(desc) > 100:
149
+ desc = desc[:97] + "..."
150
+ suffix = f" - {desc}" if desc else ""
151
+ return f"[{a.source_root}] {a.name}{suffix}"
152
+
153
+
154
+ def _filter(artifacts: list[Artifact], term: Optional[str]) -> list[Artifact]:
155
+ if not term:
156
+ return artifacts
157
+ t = term.lower()
158
+ return [a for a in artifacts if t in a.name.lower() or t in a.description.lower()]
159
+
160
+
161
+ def _pick(
162
+ kind: Kind,
163
+ target: Path,
164
+ assistant: Optional[str],
165
+ overwrite: bool,
166
+ term: Optional[str] = None,
167
+ ) -> None:
168
+ catalog = _load_catalog()
169
+ artifacts = _filter(catalog.by_kind(kind), term)
170
+ label = _KIND_LABEL[kind]
171
+
172
+ if not artifacts:
173
+ suffix = f" matching '{term}'" if term else ""
174
+ console.print(f"[yellow]No {label}{suffix} indexed.[/yellow]")
175
+ raise typer.Exit(code=0)
176
+
177
+ choices = [questionary.Choice(title=_choice_label(a), value=a) for a in artifacts]
178
+ selected: list[Artifact] = (
179
+ questionary.checkbox(
180
+ f"Select {label} to copy (type to filter, Space to toggle, Enter to confirm):",
181
+ choices=choices,
182
+ use_search_filter=True,
183
+ use_jk_keys=False,
184
+ ).ask()
185
+ or []
186
+ )
187
+ if not selected:
188
+ console.print("[yellow]Nothing selected.[/yellow]")
189
+ raise typer.Exit(code=0)
190
+
191
+ if assistant is None:
192
+ assistant = questionary.select(
193
+ "Copy into which coding assistant?",
194
+ choices=dest_mod.ASSISTANTS,
195
+ ).ask()
196
+ if assistant is None:
197
+ console.print("[yellow]No assistant chosen.[/yellow]")
198
+ raise typer.Exit(code=0)
199
+ if assistant not in dest_mod.ASSISTANTS:
200
+ console.print(f"[red]Unknown assistant '{assistant}'.[/red]")
201
+ raise typer.Exit(code=1)
202
+
203
+ dest_dir = dest_mod.destination_dir(target, assistant, kind)
204
+ results = install(selected, dest_dir, overwrite=overwrite)
205
+ _print_results(results)
206
+
207
+
208
+ def _print_results(results: list[CopyResult]) -> None:
209
+ for r in results:
210
+ if r.status == "copied":
211
+ console.print(f"[green]copied[/green] {r.artifact.name} -> {r.destination}")
212
+ elif r.status == "skipped":
213
+ console.print(
214
+ f"[yellow]skipped[/yellow] {r.artifact.name} (exists; use --overwrite) -> {r.destination}"
215
+ )
216
+ else:
217
+ console.print(f"[red]missing[/red] {r.artifact.name} (source gone: {r.artifact.path})")
218
+
219
+
220
+ @pick_app.command("skills")
221
+ def pick_skills(
222
+ term: Optional[str] = TERM_ARG,
223
+ target: Path = TARGET_OPT,
224
+ assistant: Optional[str] = ASSISTANT_OPT,
225
+ overwrite: bool = OVERWRITE_OPT,
226
+ ) -> None:
227
+ """Select indexed skills and copy them into a project. TERM is optional; when given it pre-filters by name/description."""
228
+ _pick("skill", target, assistant, overwrite, term=term)
229
+
230
+
231
+ @pick_app.command("agents")
232
+ def pick_agents(
233
+ term: Optional[str] = TERM_ARG,
234
+ target: Path = TARGET_OPT,
235
+ assistant: Optional[str] = ASSISTANT_OPT,
236
+ overwrite: bool = OVERWRITE_OPT,
237
+ ) -> None:
238
+ """Select indexed subagents and copy them into a project. TERM is optional; when given it pre-filters by name/description."""
239
+ _pick("agent", target, assistant, overwrite, term=term)
240
+
241
+
242
+ @pick_app.command("rules")
243
+ def pick_rules(
244
+ term: Optional[str] = TERM_ARG,
245
+ target: Path = TARGET_OPT,
246
+ assistant: Optional[str] = ASSISTANT_OPT,
247
+ overwrite: bool = OVERWRITE_OPT,
248
+ ) -> None:
249
+ """Select indexed rules and copy them into a project. TERM is optional; when given it pre-filters by name/description."""
250
+ _pick("rule", target, assistant, overwrite, term=term)
251
+
252
+
253
+ @app.command("install-skill")
254
+ def install_skill(
255
+ target: Path = typer.Option(
256
+ Path.cwd(),
257
+ "--target",
258
+ "-t",
259
+ resolve_path=True,
260
+ help="Root to install into ('.' for the current workspace, '~' for global).",
261
+ ),
262
+ assistant: str = typer.Option(
263
+ ".cursor", "--assistant", "-a", help="Assistant folder convention to install under."
264
+ ),
265
+ overwrite: bool = typer.Option(
266
+ False, "--overwrite", help="Replace the skill if it already exists at the destination."
267
+ ),
268
+ ) -> None:
269
+ """Install the bundled agent-catalog management skill into TARGET/<assistant>/skills/."""
270
+ if assistant not in dest_mod.ASSISTANTS:
271
+ console.print(f"[red]Unknown assistant '{assistant}'.[/red]")
272
+ raise typer.Exit(code=1)
273
+ if not (SELF_SKILL_DIR / "SKILL.md").is_file():
274
+ console.print(f"[red]Bundled skill not found at {SELF_SKILL_DIR}.[/red]")
275
+ raise typer.Exit(code=1)
276
+
277
+ artifact = Artifact(
278
+ kind="skill",
279
+ name=SELF_SKILL_DIR.name,
280
+ description="agent-catalog management skill",
281
+ path=str(SELF_SKILL_DIR),
282
+ source_root="agents_catalog",
283
+ )
284
+ dest_dir = dest_mod.destination_dir(target, assistant, "skill")
285
+ _print_results(install([artifact], dest_dir, overwrite=overwrite))
286
+
287
+
288
+ if __name__ == "__main__":
289
+ app()
@@ -29,12 +29,16 @@ def read_cache(directory: Path | None = None) -> Catalog:
29
29
  return Catalog.from_dict(data)
30
30
 
31
31
 
32
- def append_skills(new_skills: list[Artifact], directory: Path | None = None) -> Catalog:
33
- """Additively merge new skills into the cache (deduped by absolute path)."""
32
+ def append_artifacts(new: list[Artifact], directory: Path | None = None) -> Catalog:
33
+ """Additively merge new artifacts into the cache by kind (deduped by path)."""
34
34
  try:
35
35
  catalog = read_cache(directory)
36
36
  except FileNotFoundError:
37
37
  catalog = Catalog(source=str((directory or Path.cwd()).resolve()))
38
- catalog.skills = scanner.dedup(catalog.skills + new_skills)
38
+ for artifact in new:
39
+ catalog.by_kind(artifact.kind).append(artifact)
40
+ catalog.skills = scanner.dedup(catalog.skills)
41
+ catalog.agents = scanner.dedup(catalog.agents)
42
+ catalog.rules = scanner.dedup(catalog.rules)
39
43
  write_cache(catalog, directory)
40
44
  return catalog
@@ -20,9 +20,11 @@ ROOT_RULE_FILES = {".github": ("custom-instructions.md",)}
20
20
  # Directories skipped while searching for memory files.
21
21
  SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".obsidian"}
22
22
 
23
- # Patterned skill-collection folder names indexed by the additive index-* commands.
23
+ # Patterned collection folder names indexed by the additive `add` subcommands.
24
24
  SKILL_COLLECTION_DIRS = {"skills"}
25
25
  PLUGIN_COLLECTION_DIRS = {"claude-plugin", "cursor-plugin"}
26
+ AGENT_COLLECTION_DIRS = {"agents"}
27
+ RULE_COLLECTION_DIRS = {"rules", "instructions"}
26
28
 
27
29
 
28
30
  def _read_text(path: Path) -> str:
@@ -126,35 +128,80 @@ def _rule_artifact(file: Path, root_name: str) -> Artifact:
126
128
  )
127
129
 
128
130
 
129
- def _scan_root(root: Path, root_name: str) -> tuple[list[Artifact], list[Artifact], list[Artifact]]:
130
- skills: list[Artifact] = []
131
- agents: list[Artifact] = []
132
- rules: list[Artifact] = []
131
+ def _find_skill_dirs(base: Path) -> list[Path]:
132
+ """Resolved package dirs of every SKILL.md under ``base``."""
133
+ return [m.parent.resolve() for m in base.rglob(SKILL_FILE) if m.is_file()]
134
+
133
135
 
134
- skill_dirs: list[Path] = []
135
- for skill_md in root.rglob(SKILL_FILE):
136
- if skill_md.is_file():
137
- skills.append(_skill_artifact(skill_md, root_name))
138
- skill_dirs.append(skill_md.parent.resolve())
136
+ def _inside_skill(path: Path, skill_dirs: list[Path]) -> bool:
137
+ rp = path.resolve()
138
+ return any(rp == d or d in rp.parents for d in skill_dirs)
139
139
 
140
- def inside_skill(p: Path) -> bool:
141
- rp = p.resolve()
142
- return any(rp == d or d in rp.parents for d in skill_dirs)
143
140
 
144
- for agents_dir in root.rglob("agents"):
145
- if not agents_dir.is_dir() or inside_skill(agents_dir):
141
+ def _is_skipped(path: Path, base: Path) -> bool:
142
+ try:
143
+ rel = path.relative_to(base)
144
+ except ValueError:
145
+ return False
146
+ return any(part in SKIP_DIRS for part in rel.parts)
147
+
148
+
149
+ def _collect_agents(
150
+ base: Path,
151
+ skill_dirs: list[Path],
152
+ source_root: str | None = None,
153
+ exclude_skip: bool = False,
154
+ ) -> list[Artifact]:
155
+ """``*.md`` directly under any ``agents/`` dir, ignoring skill-internal ones.
156
+
157
+ ``source_root`` overrides the recorded root; when ``None`` the containing
158
+ folder name is used (the collection-add convention)."""
159
+ out: list[Artifact] = []
160
+ for agents_dir in base.rglob("agents"):
161
+ if not agents_dir.is_dir() or _inside_skill(agents_dir, skill_dirs):
162
+ continue
163
+ if exclude_skip and _is_skipped(agents_dir, base):
146
164
  continue
165
+ sr = agents_dir.name if source_root is None else source_root
147
166
  for file in sorted(agents_dir.glob("*.md")):
148
167
  if file.is_file():
149
- agents.append(_agent_artifact(file, root_name))
168
+ out.append(_agent_artifact(file, sr))
169
+ return out
170
+
150
171
 
151
- rule_dirs = list(root.rglob("rules")) + list(root.rglob("instructions"))
172
+ def _collect_rules(
173
+ base: Path,
174
+ skill_dirs: list[Path],
175
+ source_root: str | None = None,
176
+ exclude_skip: bool = False,
177
+ ) -> list[Artifact]:
178
+ """``*.md`` / ``*.mdc`` recursively under any ``rules/`` or ``instructions/`` dir."""
179
+ out: list[Artifact] = []
180
+ rule_dirs = sorted(
181
+ (d for name in RULE_COLLECTION_DIRS for d in base.rglob(name)),
182
+ key=str,
183
+ )
152
184
  for rules_dir in rule_dirs:
153
- if not rules_dir.is_dir() or inside_skill(rules_dir):
185
+ if not rules_dir.is_dir() or _inside_skill(rules_dir, skill_dirs):
186
+ continue
187
+ if exclude_skip and _is_skipped(rules_dir, base):
154
188
  continue
189
+ sr = rules_dir.name if source_root is None else source_root
155
190
  for file in sorted(rules_dir.rglob("*")):
156
- if file.is_file() and file.suffix in RULE_SUFFIXES and not inside_skill(file):
157
- rules.append(_rule_artifact(file, root_name))
191
+ if (
192
+ file.is_file()
193
+ and file.suffix in RULE_SUFFIXES
194
+ and not _inside_skill(file, skill_dirs)
195
+ ):
196
+ out.append(_rule_artifact(file, sr))
197
+ return out
198
+
199
+
200
+ def _scan_root(root: Path, root_name: str) -> tuple[list[Artifact], list[Artifact], list[Artifact]]:
201
+ skill_dirs = _find_skill_dirs(root)
202
+ skills = [_skill_artifact(d / SKILL_FILE, root_name) for d in skill_dirs]
203
+ agents = _collect_agents(root, skill_dirs, source_root=root_name)
204
+ rules = _collect_rules(root, skill_dirs, source_root=root_name)
158
205
 
159
206
  for filename in ROOT_RULE_FILES.get(root_name, ()):
160
207
  candidate = root / filename
@@ -193,6 +240,23 @@ def scan_collections(target_dir: Path, folder_names: set[str]) -> list[Artifact]
193
240
  return out
194
241
 
195
242
 
243
+ def scan_agent_collections(target_dir: Path) -> list[Artifact]:
244
+ """Index ``*.md`` under any ``agents/`` folder anywhere under target_dir."""
245
+ target_dir = target_dir.resolve()
246
+ skill_dirs = _find_skill_dirs(target_dir)
247
+ return _collect_agents(target_dir, skill_dirs, exclude_skip=True)
248
+
249
+
250
+ def scan_rule_collections(target_dir: Path) -> list[Artifact]:
251
+ """Index rules under any ``rules/`` / ``instructions/`` folder anywhere under
252
+ target_dir, plus CLAUDE.md / AGENTS.md memory files."""
253
+ target_dir = target_dir.resolve()
254
+ skill_dirs = _find_skill_dirs(target_dir)
255
+ rules = _collect_rules(target_dir, skill_dirs, exclude_skip=True)
256
+ rules.extend(_scan_memory_files(target_dir))
257
+ return rules
258
+
259
+
196
260
  def dedup(artifacts: list[Artifact]) -> list[Artifact]:
197
261
  seen: set[str] = set()
198
262
  out: list[Artifact] = []
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: agent-catalog
3
+ description: Manage a catalog of AI-assistant skills, subagents and rules with the agent-catalog CLI, and install selected ones into the current workspace or the global agent skills. Use when the user wants to catalog, index, search, find, add, or install skills/subagents/rules, or set up reusable agent assets in a workspace.
4
+ ---
5
+
6
+ # agent-catalog
7
+
8
+ Drive the `agent-catalog` CLI to build a catalog of reusable AI-assistant assets (skills, subagents, rules) and install the right ones into the workspace you operate in or into the global agent skills.
9
+
10
+ ## Prerequisite
11
+
12
+ The `agent-catalog` command. From the CLI repo: `uv run agent-catalog ...`. Installed globally: `pip install agent-catalog-cli`, then `agent-catalog ...`. Examples below use the bare command.
13
+
14
+ ## Build / refresh the catalog (non-interactive)
15
+
16
+ These commands take a directory and write/append `.agent-catalog.json` in the current directory. Safe to run unattended.
17
+
18
+ - `agent-catalog init <dir>` — scan the config roots `.agents/ .cursor/ .claude/ .github/ .codex/` under `<dir>` (overwrites the cache).
19
+ - `agent-catalog add skills <dir>` — additively index `skills/<skill>/` packages found anywhere under `<dir>`.
20
+ - `agent-catalog add plugins <dir>` — additively index `claude-plugin/<skill>/` and `cursor-plugin/<skill>/` packages.
21
+ - `agent-catalog add agents <dir>` — additively index `*.md` under any `agents/` folder.
22
+ - `agent-catalog add rules <dir>` — additively index `*.md` / `*.mdc` under any `rules/` or `instructions/` folder, plus `CLAUDE.md` / `AGENTS.md` memory files.
23
+
24
+ `add ...` append and dedupe by absolute path, so re-runs and overlap with `init` are safe.
25
+
26
+ ## Discover / search (read the cache directly)
27
+
28
+ To list or filter what is available before installing, read `.agent-catalog.json`. It has `skills`, `agents`, `rules` arrays; each entry carries `name`, `description`, `path`, `source_root`. Filter on `name`/`description` to pick targets — no extra tooling needed.
29
+
30
+ ## Install into the workspace or globally (via CLI)
31
+
32
+ Installation is done with the `pick` commands. `--target` chooses the root and `--assistant` the folder convention; together they decide where files land (`<target>/<assistant>/skills/<name>/`).
33
+
34
+ - Current workspace: `agent-catalog pick skills --target . --assistant .cursor` → `./.cursor/skills/<name>/`
35
+ - Global agent skills: `agent-catalog pick skills --target ~ --assistant .cursor` → `~/.cursor/skills/<name>/`
36
+ - Pre-filter a large catalog: `agent-catalog pick skills <term> --target . --assistant .cursor` (optional `<term>`, case-insensitive over name/description).
37
+ - Subagents and rules: same flags with `pick agents` / `pick rules`.
38
+ - Add `--overwrite` to replace assets that already exist at the destination.
39
+
40
+ `pick ...` opens an interactive checkbox (requires a TTY): run the command, then the human selects entries (type to filter, Space to toggle, Enter to confirm) and chooses the assistant if `--assistant` was omitted. `init` and `add ...` need no interaction.
41
+
42
+ ## Install this skill
43
+
44
+ This management skill ships inside the package. Install it directly (non-interactive):
45
+
46
+ - Workspace: `agent-catalog install-skill --target . --assistant .cursor` → `./.cursor/skills/agent-catalog/`
47
+ - Global: `agent-catalog install-skill --target ~ --assistant .cursor` → `~/.cursor/skills/agent-catalog/`
48
+
49
+ Add `--overwrite` to refresh an existing copy.
50
+
51
+ ## When not to use
52
+
53
+ - One-off file copies of a single known path — just copy it.
54
+ - Authoring or editing skill contents — use `create-skill`. This skill catalogs and installs existing assets; it does not write them.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-catalog-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Scan a directory for AI-assistant skills, rules and subagents and copy selected ones into a chosen assistant's folder layout."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -4,7 +4,7 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "agent-catalog-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "questionary" },
@@ -1,292 +0,0 @@
1
- """Typer CLI: index AI-assistant artifacts and copy selected ones into a project."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- import questionary
9
- import typer
10
- from rich.console import Console
11
- from rich.table import Table
12
-
13
- from . import cache as cache_mod
14
- from . import destinations as dest_mod
15
- from . import scanner
16
- from .installer import install
17
- from .models import Artifact, Catalog, Kind
18
-
19
- app = typer.Typer(
20
- help="Scan a directory for AI-assistant skills, rules and subagents, then copy selected ones into a project.",
21
- no_args_is_help=True,
22
- add_completion=False,
23
- )
24
- console = Console()
25
-
26
- _KIND_LABEL = {"skill": "skills", "agent": "agents", "rule": "rules"}
27
-
28
-
29
- @app.command()
30
- def init(
31
- target_dir: Path = typer.Argument(
32
- ...,
33
- exists=True,
34
- file_okay=False,
35
- dir_okay=True,
36
- readable=True,
37
- resolve_path=True,
38
- help="Directory to scan for .agents/.cursor/.claude/.github/.codex configs.",
39
- ),
40
- ) -> None:
41
- """Scan TARGET_DIR and cache discovered skills, subagents and rules."""
42
- catalog = scanner.scan(target_dir)
43
- path = cache_mod.write_cache(catalog)
44
-
45
- table = Table(title="Indexed artifacts")
46
- table.add_column("Kind")
47
- table.add_column("Count", justify="right")
48
- table.add_row("Skills", str(len(catalog.skills)))
49
- table.add_row("Agents", str(len(catalog.agents)))
50
- table.add_row("Rules", str(len(catalog.rules)))
51
- console.print(table)
52
- console.print(f"Cache written to [bold]{path}[/bold]")
53
-
54
-
55
- def _index_collections(target_dir: Path, folders: set[str]) -> None:
56
- new = scanner.scan_collections(target_dir, folders)
57
- catalog = cache_mod.append_skills(new)
58
- console.print(
59
- f"Indexed [bold]{len(new)}[/bold] skill(s); cache now has {len(catalog.skills)} skill(s)."
60
- )
61
-
62
-
63
- @app.command("index-skills")
64
- def index_skills(
65
- target_dir: Path = typer.Argument(
66
- ...,
67
- exists=True,
68
- file_okay=False,
69
- dir_okay=True,
70
- readable=True,
71
- resolve_path=True,
72
- help="Directory to search for skills/<skill> packages.",
73
- ),
74
- ) -> None:
75
- """Find skills/<skill> packages under TARGET_DIR and add them to the cache."""
76
- _index_collections(target_dir, scanner.SKILL_COLLECTION_DIRS)
77
-
78
-
79
- @app.command("index-plugins")
80
- def index_plugins(
81
- target_dir: Path = typer.Argument(
82
- ...,
83
- exists=True,
84
- file_okay=False,
85
- dir_okay=True,
86
- readable=True,
87
- resolve_path=True,
88
- help="Directory to search for claude-plugin/<skill> and cursor-plugin/<skill> packages.",
89
- ),
90
- ) -> None:
91
- """Find claude-plugin/<skill> and cursor-plugin/<skill> packages under TARGET_DIR and add them to the cache."""
92
- _index_collections(target_dir, scanner.PLUGIN_COLLECTION_DIRS)
93
-
94
-
95
- def _load_catalog() -> Catalog:
96
- try:
97
- return cache_mod.read_cache()
98
- except FileNotFoundError:
99
- console.print(
100
- "[red]No cache found.[/red] Run [bold]agent-catalog init <target_dir>[/bold] first."
101
- )
102
- raise typer.Exit(code=1)
103
-
104
-
105
- def _choice_label(a: Artifact) -> str:
106
- desc = a.description.strip().replace("\n", " ")
107
- if len(desc) > 100:
108
- desc = desc[:97] + "..."
109
- suffix = f" - {desc}" if desc else ""
110
- return f"[{a.source_root}] {a.name}{suffix}"
111
-
112
-
113
- def _filter(artifacts: list[Artifact], term: Optional[str]) -> list[Artifact]:
114
- if not term:
115
- return artifacts
116
- t = term.lower()
117
- return [a for a in artifacts if t in a.name.lower()]
118
-
119
-
120
- def _pick(
121
- kind: Kind,
122
- target: Path,
123
- assistant: Optional[str],
124
- overwrite: bool,
125
- term: Optional[str] = None,
126
- ) -> None:
127
- catalog = _load_catalog()
128
- artifacts = _filter(catalog.by_kind(kind), term)
129
- label = _KIND_LABEL[kind]
130
-
131
- if not artifacts:
132
- suffix = f" matching '{term}'" if term else ""
133
- console.print(f"[yellow]No {label}{suffix} indexed.[/yellow]")
134
- raise typer.Exit(code=0)
135
-
136
- choices = [questionary.Choice(title=_choice_label(a), value=a) for a in artifacts]
137
- selected: list[Artifact] = (
138
- questionary.checkbox(
139
- f"Select {label} to copy (type to filter, Space to toggle, Enter to confirm):",
140
- choices=choices,
141
- use_search_filter=True,
142
- use_jk_keys=False,
143
- ).ask()
144
- or []
145
- )
146
- if not selected:
147
- console.print("[yellow]Nothing selected.[/yellow]")
148
- raise typer.Exit(code=0)
149
-
150
- if assistant is None:
151
- assistant = questionary.select(
152
- "Copy into which coding assistant?",
153
- choices=dest_mod.ASSISTANTS,
154
- ).ask()
155
- if assistant is None:
156
- console.print("[yellow]No assistant chosen.[/yellow]")
157
- raise typer.Exit(code=0)
158
- if assistant not in dest_mod.ASSISTANTS:
159
- console.print(f"[red]Unknown assistant '{assistant}'.[/red]")
160
- raise typer.Exit(code=1)
161
-
162
- dest_dir = dest_mod.destination_dir(target, assistant, kind)
163
- results = install(selected, dest_dir, overwrite=overwrite)
164
-
165
- for r in results:
166
- if r.status == "copied":
167
- console.print(f"[green]copied[/green] {r.artifact.name} -> {r.destination}")
168
- elif r.status == "skipped":
169
- console.print(
170
- f"[yellow]skipped[/yellow] {r.artifact.name} (exists; use --overwrite) -> {r.destination}"
171
- )
172
- else:
173
- console.print(f"[red]missing[/red] {r.artifact.name} (source gone: {r.artifact.path})")
174
-
175
-
176
- @app.command("pick-skills")
177
- def pick_skills(
178
- target: Path = typer.Option(
179
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
180
- ),
181
- assistant: Optional[str] = typer.Option(
182
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
183
- ),
184
- overwrite: bool = typer.Option(
185
- False,
186
- "--overwrite",
187
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
188
- ),
189
- ) -> None:
190
- """Interactively select indexed skills and copy them into a project."""
191
- _pick("skill", target, assistant, overwrite)
192
-
193
-
194
- @app.command("pick-agents")
195
- def pick_agents(
196
- target: Path = typer.Option(
197
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
198
- ),
199
- assistant: Optional[str] = typer.Option(
200
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
201
- ),
202
- overwrite: bool = typer.Option(
203
- False,
204
- "--overwrite",
205
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
206
- ),
207
- ) -> None:
208
- """Interactively select indexed subagents and copy them into a project."""
209
- _pick("agent", target, assistant, overwrite)
210
-
211
-
212
- @app.command("pick-rules")
213
- def pick_rules(
214
- target: Path = typer.Option(
215
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
216
- ),
217
- assistant: Optional[str] = typer.Option(
218
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
219
- ),
220
- overwrite: bool = typer.Option(
221
- False,
222
- "--overwrite",
223
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
224
- ),
225
- ) -> None:
226
- """Interactively select indexed rules and copy them into a project."""
227
- _pick("rule", target, assistant, overwrite)
228
-
229
-
230
- def _resolve_term(term: Optional[str]) -> Optional[str]:
231
- return term if term else questionary.text("Filter term:").ask()
232
-
233
-
234
- @app.command("pick-skills:filter")
235
- def pick_skills_filter(
236
- term: Optional[str] = typer.Argument(None, help="Pre-filter by substring in name."),
237
- target: Path = typer.Option(
238
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
239
- ),
240
- assistant: Optional[str] = typer.Option(
241
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
242
- ),
243
- overwrite: bool = typer.Option(
244
- False,
245
- "--overwrite",
246
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
247
- ),
248
- ) -> None:
249
- """Pre-filter indexed skills by a term, then select and copy them."""
250
- _pick("skill", target, assistant, overwrite, term=_resolve_term(term))
251
-
252
-
253
- @app.command("pick-agents:filter")
254
- def pick_agents_filter(
255
- term: Optional[str] = typer.Argument(None, help="Pre-filter by substring in name."),
256
- target: Path = typer.Option(
257
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
258
- ),
259
- assistant: Optional[str] = typer.Option(
260
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
261
- ),
262
- overwrite: bool = typer.Option(
263
- False,
264
- "--overwrite",
265
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
266
- ),
267
- ) -> None:
268
- """Pre-filter indexed subagents by a term, then select and copy them."""
269
- _pick("agent", target, assistant, overwrite, term=_resolve_term(term))
270
-
271
-
272
- @app.command("pick-rules:filter")
273
- def pick_rules_filter(
274
- term: Optional[str] = typer.Argument(None, help="Pre-filter by substring in name."),
275
- target: Path = typer.Option(
276
- Path.cwd(), "--target", "-t", resolve_path=True, help="Project root to copy into."
277
- ),
278
- assistant: Optional[str] = typer.Option(
279
- None, "--assistant", "-a", help="Skip the prompt and use this assistant root."
280
- ),
281
- overwrite: bool = typer.Option(
282
- False,
283
- "--overwrite",
284
- help="Replace artifacts that already exist at the destination (skills merge over the folder, overwriting overlapping files; single files are replaced). Without it, existing targets are skipped.",
285
- ),
286
- ) -> None:
287
- """Pre-filter indexed rules by a term, then select and copy them."""
288
- _pick("rule", target, assistant, overwrite, term=_resolve_term(term))
289
-
290
-
291
- if __name__ == "__main__":
292
- app()