@event4u/agent-config 2.4.0 → 2.6.0

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "2.4.0",
9
+ "version": "2.6.0",
10
10
  "keywords": [
11
11
  "agent-config",
12
12
  "skills",
package/CHANGELOG.md CHANGED
@@ -343,6 +343,35 @@ our recommendation order, not its support status.
343
343
  users" tension without removing any path that an existing user
344
344
  might rely on.
345
345
 
346
+ ## [2.6.0](https://github.com/event4u-app/agent-config/compare/2.4.1...2.6.0) (2026-05-13)
347
+
348
+ ### Features
349
+
350
+ * **claude-desktop:** bundle commands as desktop skills ([50cd319](https://github.com/event4u-app/agent-config/commit/50cd31988ac571ecdf883c5d2c6d62c209820ebc))
351
+
352
+ ### Other
353
+
354
+ * 2.5.0 ([1da0e10](https://github.com/event4u-app/agent-config/commit/1da0e1014b9cf24cf41d820795f96f2668ce738c))
355
+
356
+ Tests: 3530 (+9 since 2.4.1)
357
+
358
+ ## [2.5.0](https://github.com/event4u-app/agent-config/compare/2.4.1...2.5.0) (2026-05-13)
359
+
360
+ ### Features
361
+
362
+ * **claude-desktop:** bundle commands as desktop skills ([50cd319](https://github.com/event4u-app/agent-config/commit/50cd31988ac571ecdf883c5d2c6d62c209820ebc))
363
+
364
+ Tests: 3530 (+9 since 2.4.1)
365
+
366
+ ## [2.4.1](https://github.com/event4u-app/agent-config/compare/2.4.0...2.4.1) (2026-05-13)
367
+
368
+ ### Bug Fixes
369
+
370
+ * **install:** write lockfile to canonical event4u namespace ([ca57607](https://github.com/event4u-app/agent-config/commit/ca5760785f150903bcad74e278b59e534f83634d))
371
+ * **install:** source global deploy from .agent-src/ subdirectories ([f151caf](https://github.com/event4u-app/agent-config/commit/f151caf854d9ce8519bc244d441a7c8bf73e7fde))
372
+
373
+ Tests: 3521 (+0 since 2.4.0)
374
+
346
375
  ## [2.4.0](https://github.com/event4u-app/agent-config/compare/2.3.0...2.4.0) (2026-05-13)
347
376
 
348
377
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -422,6 +422,7 @@ def _uninstall_project(opts: argparse.Namespace) -> int:
422
422
 
423
423
  def _uninstall_global(opts: argparse.Namespace) -> int:
424
424
  lock_path = installed_lock.lockfile_path()
425
+ write_path = installed_lock.lockfile_write_path()
425
426
  lock = installed_lock.read_lockfile(lock_path)
426
427
  if lock is None and not opts.force:
427
428
  print(f"❌ no global lockfile at {lock_path}", file=sys.stderr)
@@ -442,15 +443,24 @@ def _uninstall_global(opts: argparse.Namespace) -> int:
442
443
  removed_names.append(tool)
443
444
  if lock is not None and not opts.dry_run:
444
445
  remaining = [t for t in lock.get("tools", []) if t not in tools]
446
+ version = lock.get("agent_config_version", "")
445
447
  if remaining:
446
- installed_lock.write_lockfile(remaining, version=lock.get("agent_config_version", ""))
448
+ installed_lock.write_lockfile(version, remaining, path=write_path)
449
+ # Drop the legacy file if it differs from the canonical write
450
+ # target so the namespace migration completes on uninstall.
451
+ if lock_path != write_path:
452
+ try:
453
+ lock_path.unlink()
454
+ except OSError:
455
+ pass
447
456
  print(f"✅ lockfile updated ({len(tools)} entries removed, {len(remaining)} kept)")
448
457
  else:
449
- try:
450
- lock_path.unlink()
451
- print(f"✅ lockfile deleted ({lock_path})")
452
- except OSError as exc:
453
- print(f"⚠️ could not delete lockfile: {exc}")
458
+ for target in {lock_path, write_path}:
459
+ try:
460
+ target.unlink()
461
+ except OSError:
462
+ pass
463
+ print(f"✅ lockfile deleted ({write_path})")
454
464
  return 0
455
465
 
456
466
 
@@ -243,17 +243,18 @@ def _refresh_global_lockfile(version: str, *, out=sys.stdout) -> None:
243
243
  when ``update`` flips the pin. Atomic write goes through
244
244
  ``installed_lock.write_lockfile``.
245
245
  """
246
- lock_path = installed_lock.lockfile_path()
247
- existing = installed_lock.read_lockfile(path=lock_path)
246
+ read_path = installed_lock.lockfile_path()
247
+ write_path = installed_lock.lockfile_write_path()
248
+ existing = installed_lock.read_lockfile(path=read_path)
248
249
  if existing is None:
249
250
  return
250
251
  recorded = existing.get("agent_config_version")
251
252
  tools = list(existing.get("tools", []))
252
- if recorded == version:
253
- print(f"ℹ️ {lock_path} already records {version}.", file=out)
253
+ if recorded == version and read_path == write_path:
254
+ print(f"ℹ️ {write_path} already records {version}.", file=out)
254
255
  return
255
- installed_lock.write_lockfile(version, tools, path=lock_path)
256
- print(f"✅ Refreshed global lockfile at {lock_path}.", file=out)
256
+ installed_lock.write_lockfile(version, tools, path=write_path)
257
+ print(f"✅ Refreshed global lockfile at {write_path}.", file=out)
257
258
 
258
259
 
259
260
  def _detect_installed_version() -> str:
@@ -2,21 +2,33 @@
2
2
 
3
3
  Claude Desktop has no filesystem convention for skills; the Customize →
4
4
  Skills UI accepts a ZIP per skill via the Upload button. This module
5
- walks ``<package_root>/.claude/skills/*`` and produces one
6
- ``<skill-name>.zip`` per directory into ``dest_dir``.
5
+ walks ``<package_root>/.agent-src/skills/*`` and produces one
6
+ ``<skill-name>.zip`` per directory into ``dest_dir``. It additionally
7
+ walks ``<package_root>/.agent-src/commands/`` and produces one
8
+ ``<command-slug>.zip`` per command ``.md`` file so Claude Desktop sees
9
+ the same surface that Claude Code exposes via the ``.claude/skills/``
10
+ symlink wrapper layer.
7
11
 
8
12
  Contract:
9
13
 
10
14
  - Each ZIP contains ``SKILL.md`` plus every sibling file under the same
11
15
  directory (recursive). Symlinks are dereferenced so the ZIP is
12
16
  self-contained.
17
+ - Command bundles wrap a single ``.agent-src/commands/<path>.md`` file
18
+ as ``SKILL.md`` inside the ZIP. Nested commands flatten to
19
+ ``<cluster>-<leaf>`` slugs (e.g. ``council/default.md`` →
20
+ ``council-default.zip``) to mirror ``compress.py``.
13
21
  - Exclusions: ``.git*``, ``__pycache__``, ``*.pyc`` — matched on the
14
22
  basename of any path component.
15
23
  - A skill folder without a ``SKILL.md`` is skipped (defensive: avoids
16
24
  shipping Claude-Code orchestrator stubs that don't follow the
17
25
  Anthropic skill schema).
26
+ - Command files named ``AGENTS.md`` are skipped (cluster authoring docs,
27
+ not invocable commands).
28
+ - A command slug that collides with an existing skill name is skipped —
29
+ the real skill bundle wins, matching ``compress.generate_claude_commands``.
18
30
  - Writes are atomic via tempfile → ``os.replace``.
19
- - Idempotent: each ZIP gets a sibling ``<skill-name>.sha256`` recording
31
+ - Idempotent: each ZIP gets a sibling ``<slug>.sha256`` recording
20
32
  the manifest digest. If the recomputed digest matches the recorded
21
33
  one, the existing ZIP is left untouched (unless ``force=True``).
22
34
  """
@@ -51,8 +63,8 @@ def _walk_skill_files(skill_dir: Path) -> list[tuple[Path, tuple[str, ...]]]:
51
63
  """Return ``[(abs_path, rel_parts), ...]`` for every file in the skill.
52
64
 
53
65
  Symlinks are followed (``os.walk(..., followlinks=True)``) so a
54
- bundle from a symlinked entry under ``.claude/skills/`` contains the
55
- actual target content, not a dangling symlink.
66
+ bundle from a symlinked entry under ``.agent-src/skills/`` contains
67
+ the actual target content, not a dangling symlink.
56
68
  """
57
69
  out: list[tuple[Path, tuple[str, ...]]] = []
58
70
  resolved = skill_dir.resolve()
@@ -121,7 +133,7 @@ def build_skill_bundles(
121
133
  ``curation`` optionally restricts the build to the given skill
122
134
  names; ``None`` bundles every skill folder containing ``SKILL.md``.
123
135
  """
124
- skills_root = package_root / ".claude" / "skills"
136
+ skills_root = package_root / ".agent-src" / "skills"
125
137
  if not skills_root.is_dir():
126
138
  return []
127
139
  dest_dir.mkdir(parents=True, exist_ok=True)
@@ -148,3 +160,79 @@ def build_skill_bundles(
148
160
  digest_path.write_text(digest + "\n", encoding="utf-8")
149
161
  written.append(zip_path)
150
162
  return written
163
+
164
+
165
+ def _command_slug(source_file: Path, commands_root: Path) -> str:
166
+ """Return the flat slug for a command source file.
167
+
168
+ Mirrors ``scripts/compress.py::_command_slug``: top-level commands
169
+ keep their stem (``commit.md`` → ``commit``); nested commands flatten
170
+ the relative path with ``-`` (``council/default.md`` →
171
+ ``council-default``).
172
+ """
173
+ rel = source_file.relative_to(commands_root)
174
+ return "-".join(rel.with_suffix("").parts)
175
+
176
+
177
+ def _iter_command_files(commands_root: Path) -> Iterable[Path]:
178
+ """Yield every command ``.md`` file under ``commands_root`` (recursive).
179
+
180
+ Skips ``AGENTS.md`` cluster authoring docs, matching
181
+ ``scripts/compress.py::_iter_commands``.
182
+ """
183
+ for source_file in sorted(commands_root.rglob("*.md")):
184
+ if source_file.name == "AGENTS.md":
185
+ continue
186
+ yield source_file
187
+
188
+
189
+ def build_command_bundles(
190
+ package_root: Path,
191
+ dest_dir: Path,
192
+ force: bool = False,
193
+ curation: Optional[list[str]] = None,
194
+ ) -> list[Path]:
195
+ """Build per-command ZIPs under ``dest_dir``.
196
+
197
+ Each ZIP contains a single ``SKILL.md`` whose bytes are the source
198
+ command ``.md`` file — same wrapping pattern that
199
+ ``compress.generate_claude_commands`` uses for Claude Code via
200
+ ``.claude/skills/<slug>/SKILL.md`` symlinks.
201
+
202
+ Slugs that collide with an existing skill folder under
203
+ ``<package_root>/.agent-src/skills/`` are skipped so the real skill
204
+ bundle wins.
205
+
206
+ Returns the list of ZIP paths that were (re-)written this call. ZIPs
207
+ skipped because their content digest matched the existing sidecar
208
+ are not in the returned list (but remain on disk).
209
+
210
+ ``curation`` optionally restricts the build to the given command
211
+ slugs; ``None`` bundles every command file.
212
+ """
213
+ commands_root = package_root / ".agent-src" / "commands"
214
+ if not commands_root.is_dir():
215
+ return []
216
+ skills_root = package_root / ".agent-src" / "skills"
217
+ skill_names: set[str] = set()
218
+ if skills_root.is_dir():
219
+ skill_names = {entry.name for entry in skills_root.iterdir() if entry.is_dir()}
220
+ dest_dir.mkdir(parents=True, exist_ok=True)
221
+ written: list[Path] = []
222
+ for source_file in _iter_command_files(commands_root):
223
+ slug = _command_slug(source_file, commands_root)
224
+ if slug in skill_names:
225
+ continue
226
+ if curation is not None and slug not in curation:
227
+ continue
228
+ files = [(source_file.resolve(), ("SKILL.md",))]
229
+ digest = _manifest_digest(files)
230
+ zip_path = dest_dir / f"{slug}.zip"
231
+ digest_path = dest_dir / f"{slug}.sha256"
232
+ recorded = digest_path.read_text(encoding="utf-8").strip() if digest_path.exists() else ""
233
+ if not force and recorded == digest and zip_path.exists():
234
+ continue
235
+ _atomic_write_zip(zip_path, files)
236
+ digest_path.write_text(digest + "\n", encoding="utf-8")
237
+ written.append(zip_path)
238
+ return written
@@ -50,7 +50,7 @@ _TOOL_RE = re.compile(r"^\s*-\s*([A-Za-z0-9_\-.]+)\s*$")
50
50
 
51
51
 
52
52
  def lockfile_path(env: Optional[dict] = None) -> Path:
53
- """Return the active lockfile path, honoring the env override.
53
+ """Return the active lockfile path for **reads**, honoring overrides.
54
54
 
55
55
  Resolution order:
56
56
 
@@ -59,9 +59,10 @@ def lockfile_path(env: Optional[dict] = None) -> Path:
59
59
  3. ``~/.config/agent-config/installed.lock`` (legacy fallback, read-only).
60
60
  4. Canonical write target under the new namespace (Step 2 fallthrough).
61
61
 
62
- Writers always end up at (4) when no lockfile exists yet; readers
63
- benefit from (3) so pre-2.4 installs keep working while the
64
- migration shim has not yet run.
62
+ Readers benefit from (3) so pre-2.4 installs keep working while the
63
+ migration shim has not yet run. Writers must use
64
+ :func:`lockfile_write_path` so a stale legacy file does not anchor
65
+ subsequent writes to the deprecated location.
65
66
  """
66
67
  env = env if env is not None else os.environ
67
68
  override = env.get(LOCKFILE_ENV)
@@ -73,6 +74,24 @@ def lockfile_path(env: Optional[dict] = None) -> Path:
73
74
  return user_global_paths.write_target("installed.lock", env=env)
74
75
 
75
76
 
77
+ def lockfile_write_path(env: Optional[dict] = None) -> Path:
78
+ """Return the canonical write target for the lockfile.
79
+
80
+ Unlike :func:`lockfile_path`, this never falls back to the legacy
81
+ ``~/.config/agent-config/`` location. Honors the
82
+ ``$AGENT_CONFIG_INSTALLED_LOCK`` override for tests, otherwise pins
83
+ to ``~/.event4u/agent-config/installed.lock``. Callers in
84
+ ``init``, ``update``, and ``uninstall`` use this so writes always
85
+ land in the new namespace regardless of whether a stale legacy
86
+ lockfile is still present.
87
+ """
88
+ env = env if env is not None else os.environ
89
+ override = env.get(LOCKFILE_ENV)
90
+ if override:
91
+ return Path(override).expanduser()
92
+ return user_global_paths.write_target("installed.lock", env=env)
93
+
94
+
76
95
  def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
77
96
  """Parse ``path`` (or the default) into a dict; return ``None`` if absent.
78
97
 
@@ -2092,36 +2092,41 @@ PROJECT_BRIDGE_MARKERS = {
2092
2092
  # ``_write_claude_desktop_marker`` rather than via this map.
2093
2093
  #
2094
2094
  # Tools that follow the markdown-skills convention (anchors lifted from
2095
- # nextlevelbuilder/ui-ux-pro-max-skill) deploy ``.claude/skills``
2096
- # the universal Anthropic-shaped skill bundle — into ``<anchor>/skills/``
2097
- # (or ``<anchor>/steering/`` for kiro). ``.claude/rules`` is also copied
2095
+ # nextlevelbuilder/ui-ux-pro-max-skill) deploy the universal Anthropic-
2096
+ # shaped skill bundle — sourced from ``.agent-src/`` (the npm-shipped
2097
+ # canonical asset tree) into ``<anchor>/skills/`` (or
2098
+ # ``<anchor>/steering/`` for kiro). ``.agent-src/rules`` is also copied
2098
2099
  # where the destination is a true rules-aware tool root.
2100
+ #
2101
+ # All source paths reference ``.agent-src/<subdir>`` because that is the
2102
+ # only asset tree included in the npm tarball (see ``package.json#files``).
2103
+ # The legacy ``.augment/``, ``.claude/``, ``.cursor/`` projections only
2104
+ # exist in the development checkout — they are not shipped.
2099
2105
  _CLAUDE_SKILL_BUNDLE: list[tuple[str, str]] = [
2100
- (".claude/rules", "rules"),
2101
- (".claude/skills", "skills"),
2102
- (".claude/personas", "personas"),
2106
+ (".agent-src/rules", "rules"),
2107
+ (".agent-src/skills", "skills"),
2108
+ (".agent-src/personas", "personas"),
2103
2109
  ]
2104
2110
  GLOBAL_DEPLOY_SOURCES: dict[str, list[tuple[str, str]]] = {
2105
2111
  "claude-code": _CLAUDE_SKILL_BUNDLE,
2106
2112
  "augment": [
2107
- (".augment/rules", "rules"),
2108
- (".augment/skills", "skills"),
2109
- (".augment/commands", "commands"),
2110
- (".augment/contexts", "contexts"),
2111
- (".augment/personas", "personas"),
2112
- (".augment/templates", "templates"),
2113
+ (".agent-src/rules", "rules"),
2114
+ (".agent-src/skills", "skills"),
2115
+ (".agent-src/commands", "commands"),
2116
+ (".agent-src/contexts", "contexts"),
2117
+ (".agent-src/personas", "personas"),
2118
+ (".agent-src/templates", "templates"),
2113
2119
  ],
2114
2120
  "cursor": [
2115
- (".cursor/rules", "rules"),
2116
- (".cursor/commands", "commands"),
2117
- (".cursor/personas", "personas"),
2121
+ (".agent-src/rules", "rules"),
2122
+ (".agent-src/commands", "commands"),
2123
+ (".agent-src/personas", "personas"),
2118
2124
  ],
2119
2125
  "windsurf": [
2120
- (".windsurf/rules", "rules"),
2121
- (".windsurf/workflows", "workflows"),
2126
+ (".agent-src/rules", "rules"),
2122
2127
  ],
2123
2128
  "cline": [
2124
- (".clinerules", ""),
2129
+ (".agent-src/rules", ""),
2125
2130
  ],
2126
2131
  # Markdown-skills tools — mirror the universal skill bundle into the
2127
2132
  # tool-specific anchor. Subpath matches the reference repo's
@@ -2142,9 +2147,9 @@ GLOBAL_DEPLOY_SOURCES: dict[str, list[tuple[str, str]]] = {
2142
2147
  # Kiro reads from `steering/` not `skills/` (per
2143
2148
  # platforms/kiro.json#folderStructure.skillPath).
2144
2149
  "kiro": [
2145
- (".claude/rules", "rules"),
2146
- (".claude/skills", "steering"),
2147
- (".claude/personas", "personas"),
2150
+ (".agent-src/rules", "rules"),
2151
+ (".agent-src/skills", "steering"),
2152
+ (".agent-src/personas", "personas"),
2148
2153
  ],
2149
2154
  }
2150
2155
 
@@ -2883,6 +2888,7 @@ def _deploy_claude_desktop(
2883
2888
  bundler = _load_claude_desktop_bundler_module()
2884
2889
  bundles_dir = _claude_desktop_bundles_dir()
2885
2890
  bundler.build_skill_bundles(package_root, bundles_dir, force=force)
2891
+ bundler.build_command_bundles(package_root, bundles_dir, force=force)
2886
2892
  # Count total existing ZIPs (idempotent runs may not rewrite any).
2887
2893
  bundle_count = sum(1 for _ in bundles_dir.glob("*.zip")) if bundles_dir.is_dir() else 0
2888
2894
  _, _, marker_paths = _write_claude_desktop_marker(
@@ -2988,14 +2994,15 @@ def install_global(
2988
2994
 
2989
2995
  lock_mod = _load_installed_lock_module()
2990
2996
  installed_version = lock_mod.current_package_version()
2991
- lock_path = lock_mod.lockfile_path()
2992
- ok, recorded = lock_mod.check_version(installed_version, path=lock_path)
2997
+ read_path = lock_mod.lockfile_path()
2998
+ write_path = lock_mod.lockfile_write_path()
2999
+ ok, recorded = lock_mod.check_version(installed_version, path=read_path)
2993
3000
 
2994
3001
  if not ok and not force:
2995
3002
  if not QUIET:
2996
3003
  print()
2997
3004
  warn("Refusing global install: lockfile version mismatch.")
2998
- info(f" Lockfile: {lock_path}")
3005
+ info(f" Lockfile: {read_path}")
2999
3006
  info(f" Recorded version: {recorded}")
3000
3007
  info(f" Current package: {installed_version}")
3001
3008
  info(" Fix: run `agent-config update`")
@@ -3013,10 +3020,10 @@ def install_global(
3013
3020
  continue
3014
3021
  print(f" {tool_id:<15} → {anchor}")
3015
3022
 
3016
- existing = lock_mod.read_lockfile(path=lock_path) or {}
3023
+ existing = lock_mod.read_lockfile(path=read_path) or {}
3017
3024
  existing_tools = list(existing.get("tools", []))
3018
3025
  merged_tools = sorted(set(existing_tools) | set(tools))
3019
- written = lock_mod.write_lockfile(installed_version, merged_tools, path=lock_path)
3026
+ written = lock_mod.write_lockfile(installed_version, merged_tools, path=write_path)
3020
3027
 
3021
3028
  if not QUIET:
3022
3029
  print()