@event4u/agent-config 2.2.2 → 2.3.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.
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +49 -0
- package/README.md +1 -8
- package/docs/architecture.md +1 -1
- package/docs/contracts/installed-tools-lockfile.md +138 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/installation.md +14 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +26 -3
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_tools.py +188 -44
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/agent-config +69 -0
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1053 -51
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""``agent-config uninstall`` — remove bridge markers (Phase 4.1).
|
|
2
|
+
|
|
3
|
+
Removes the per-tool bridge marker files this package created (the
|
|
4
|
+
files listed in ``PROJECT_BRIDGE_MARKERS`` for project scope, the
|
|
5
|
+
lockfile entries for global scope). User-deployed content in
|
|
6
|
+
``~/.claude/skills/`` etc. is left in place — uninstall removes the
|
|
7
|
+
*link* between the project and agent-config, not the content the user
|
|
8
|
+
may still want. Use ``--purge`` to also delete the deployed content
|
|
9
|
+
directories (opt-in, destructive).
|
|
10
|
+
|
|
11
|
+
Idempotent: removing an already-absent marker is a no-op success.
|
|
12
|
+
Refuses to operate on a non-empty drift unless ``--force`` is passed.
|
|
13
|
+
|
|
14
|
+
Schema v2 (P2.2): when the manifest carries per-tool ``files[]`` and
|
|
15
|
+
``merged_keys[]`` inventories, uninstall walks them instead of the
|
|
16
|
+
hardcoded ``PROJECT_BRIDGE_MARKERS`` map. JSON merges are subtracted
|
|
17
|
+
key-by-key so neighbour packages' contributions to the same shared
|
|
18
|
+
file (e.g. ``.cursor/hooks.json``) survive. Bridge files that are JSON
|
|
19
|
+
documents are deleted only when subtraction left them empty; if a
|
|
20
|
+
sibling tool still owns keys there, the file stays.
|
|
21
|
+
|
|
22
|
+
Two-phase commit: the tool entry is rewritten with ``status:
|
|
23
|
+
"uninstalling"`` before any deletion, deletions / subtractions run,
|
|
24
|
+
then the entry is removed on success. A crash between the two phases
|
|
25
|
+
leaves the manifest in a state ``cmd_prune`` recognises (the orphaned
|
|
26
|
+
``files[]`` of an ``uninstalling`` tool resurface for cleanup).
|
|
27
|
+
Manifests without ``files[]`` fall back to the legacy v1 path
|
|
28
|
+
unchanged.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import shutil
|
|
35
|
+
import sys
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Iterable
|
|
39
|
+
|
|
40
|
+
from scripts._lib import fs_atomic, installed_lock, installed_tools
|
|
41
|
+
from scripts._lib.json_pointers import subtract_pointers
|
|
42
|
+
from scripts.install import PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_project_root(arg: str | None) -> Path:
|
|
46
|
+
if arg:
|
|
47
|
+
return Path(arg).expanduser().resolve()
|
|
48
|
+
return Path.cwd().resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _filter_tools(all_tools: Iterable[str], requested: str | None) -> list[str]:
|
|
52
|
+
pool = list(all_tools)
|
|
53
|
+
if not requested or requested.strip() == "all":
|
|
54
|
+
return pool
|
|
55
|
+
wanted = {t.strip() for t in requested.split(",") if t.strip()}
|
|
56
|
+
return [t for t in pool if t in wanted]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _remove_project_marker(project_root: Path, tool: str, *, dry_run: bool) -> tuple[str, bool]:
|
|
60
|
+
rel = PROJECT_BRIDGE_MARKERS.get(tool)
|
|
61
|
+
if not rel:
|
|
62
|
+
return (f"{tool}: no project marker registered (skipped)", False)
|
|
63
|
+
target = project_root / rel
|
|
64
|
+
if not target.exists():
|
|
65
|
+
return (f"{tool}: {rel} already absent", False)
|
|
66
|
+
if dry_run:
|
|
67
|
+
return (f"{tool}: would remove {rel}", True)
|
|
68
|
+
try:
|
|
69
|
+
target.unlink()
|
|
70
|
+
return (f"{tool}: removed {rel}", True)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
return (f"{tool}: ❌ failed to remove {rel} ({exc})", False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _remove_global_content(tool: str, *, dry_run: bool, purge: bool) -> tuple[str, bool]:
|
|
76
|
+
anchor = USER_SCOPE_PATHS.get(tool)
|
|
77
|
+
if not anchor:
|
|
78
|
+
return (f"{tool}: no global anchor registered (skipped)", False)
|
|
79
|
+
target = Path(anchor).expanduser()
|
|
80
|
+
if not target.exists():
|
|
81
|
+
return (f"{tool}: {anchor} already absent", False)
|
|
82
|
+
if not purge:
|
|
83
|
+
return (f"{tool}: {anchor} preserved (pass --purge to delete)", False)
|
|
84
|
+
if dry_run:
|
|
85
|
+
return (f"{tool}: would purge {anchor}", True)
|
|
86
|
+
try:
|
|
87
|
+
if target.is_dir():
|
|
88
|
+
shutil.rmtree(target)
|
|
89
|
+
else:
|
|
90
|
+
target.unlink()
|
|
91
|
+
return (f"{tool}: purged {anchor}", True)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
return (f"{tool}: ❌ failed to purge {anchor} ({exc})", False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Schema v2 helpers (P2.2 — manifest-driven uninstall)
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_v2_entry(entry: dict[str, Any]) -> bool:
|
|
102
|
+
"""Whether ``entry`` carries v2 per-tool inventories.
|
|
103
|
+
|
|
104
|
+
A tool entry counts as v2 when at least one of ``files[]`` or
|
|
105
|
+
``merged_keys[]`` is non-empty. Tools written by older installers
|
|
106
|
+
have neither and fall through to the legacy ``PROJECT_BRIDGE_MARKERS``
|
|
107
|
+
path so a manifest written by a v1 ``init`` stays uninstallable.
|
|
108
|
+
"""
|
|
109
|
+
return bool(entry.get("files")) or bool(entry.get("merged_keys"))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_recorded_path(project_root: Path, recorded: str) -> Path:
|
|
113
|
+
"""Resolve a manifest-recorded path against the project root.
|
|
114
|
+
|
|
115
|
+
``files[].path`` and ``merged_keys[].file`` are written as absolute
|
|
116
|
+
paths by the installer (user-scope content lives outside the
|
|
117
|
+
project tree) but a relative path is accepted for portability and
|
|
118
|
+
resolved against ``project_root``. Returns the absolute path.
|
|
119
|
+
"""
|
|
120
|
+
p = Path(recorded)
|
|
121
|
+
if p.is_absolute():
|
|
122
|
+
return p
|
|
123
|
+
return (project_root / p).resolve()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _set_tool_status(
|
|
127
|
+
manifest_path: Path,
|
|
128
|
+
version: str,
|
|
129
|
+
tools: list[dict[str, Any]],
|
|
130
|
+
name: str,
|
|
131
|
+
status: str,
|
|
132
|
+
*,
|
|
133
|
+
deploy_roots: list[str] | None,
|
|
134
|
+
) -> list[dict[str, Any]]:
|
|
135
|
+
"""Persist ``status`` on the named tool entry and return the new list.
|
|
136
|
+
|
|
137
|
+
Two-phase commit anchor (P2.2): writing ``status: uninstalling``
|
|
138
|
+
before any deletion gives ``cmd_prune`` a stable signal to clean
|
|
139
|
+
up after a crash mid-uninstall.
|
|
140
|
+
"""
|
|
141
|
+
new_tools: list[dict[str, Any]] = []
|
|
142
|
+
for entry in tools:
|
|
143
|
+
if entry.get("name") == name:
|
|
144
|
+
entry = {**entry, "status": status}
|
|
145
|
+
new_tools.append(entry)
|
|
146
|
+
installed_tools.write_manifest(
|
|
147
|
+
manifest_path, version, new_tools, deploy_roots=deploy_roots,
|
|
148
|
+
)
|
|
149
|
+
return new_tools
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _subtract_merged_keys(
|
|
153
|
+
entry: dict[str, Any],
|
|
154
|
+
project_root: Path,
|
|
155
|
+
*,
|
|
156
|
+
dry_run: bool,
|
|
157
|
+
) -> tuple[list[str], set[str], set[str]]:
|
|
158
|
+
"""Subtract this tool's ``merged_keys`` from every referenced JSON file.
|
|
159
|
+
|
|
160
|
+
Returns ``(warnings, emptied_files, touched_files)``:
|
|
161
|
+
|
|
162
|
+
* ``touched_files`` — absolute path strings of every JSON file this
|
|
163
|
+
tool recorded merge contributions for (regardless of subtraction
|
|
164
|
+
outcome). Used by :func:`_delete_tool_files` to decide whether a
|
|
165
|
+
JSON bridge is shared (touched + non-empty) or owned solely
|
|
166
|
+
(untouched → delete with the rest).
|
|
167
|
+
* ``emptied_files`` — subset of ``touched_files`` whose document is
|
|
168
|
+
now ``{}`` after subtraction. Foreign keys from neighbour
|
|
169
|
+
packages are preserved by :func:`subtract_pointers`.
|
|
170
|
+
"""
|
|
171
|
+
warnings: list[str] = []
|
|
172
|
+
emptied: set[str] = set()
|
|
173
|
+
touched: set[str] = set()
|
|
174
|
+
merged_keys = entry.get("merged_keys") or []
|
|
175
|
+
if not merged_keys:
|
|
176
|
+
return warnings, emptied, touched
|
|
177
|
+
by_file: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
178
|
+
for record in merged_keys:
|
|
179
|
+
by_file[record["file"]].append(record)
|
|
180
|
+
for file_label, records in by_file.items():
|
|
181
|
+
target = _resolve_recorded_path(project_root, file_label)
|
|
182
|
+
touched.add(str(target))
|
|
183
|
+
if not target.exists():
|
|
184
|
+
warnings.append(
|
|
185
|
+
f"{file_label}: absent — skipping {len(records)} pointer(s)"
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
doc = json.loads(target.read_text(encoding="utf-8"))
|
|
190
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
191
|
+
warnings.append(f"{file_label}: unparseable JSON ({exc}); skipped")
|
|
192
|
+
continue
|
|
193
|
+
if not isinstance(doc, dict):
|
|
194
|
+
warnings.append(f"{file_label}: not a JSON object; skipped")
|
|
195
|
+
continue
|
|
196
|
+
new_doc, sub_warnings = subtract_pointers(doc, records)
|
|
197
|
+
for w in sub_warnings:
|
|
198
|
+
warnings.append(
|
|
199
|
+
f"{file_label}{w['pointer']}: {w['reason']}"
|
|
200
|
+
)
|
|
201
|
+
if dry_run:
|
|
202
|
+
if not new_doc:
|
|
203
|
+
emptied.add(str(target))
|
|
204
|
+
continue
|
|
205
|
+
if new_doc:
|
|
206
|
+
fs_atomic.write_atomic(
|
|
207
|
+
target, json.dumps(new_doc, indent=2) + "\n",
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
emptied.add(str(target))
|
|
211
|
+
return warnings, emptied, touched
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _delete_tool_files(
|
|
215
|
+
entry: dict[str, Any],
|
|
216
|
+
project_root: Path,
|
|
217
|
+
*,
|
|
218
|
+
dry_run: bool,
|
|
219
|
+
purge: bool,
|
|
220
|
+
emptied_files: set[str],
|
|
221
|
+
touched_files: set[str],
|
|
222
|
+
) -> tuple[list[str], list[str]]:
|
|
223
|
+
"""Delete ``files[]`` entries by kind; honour --purge for deployed.
|
|
224
|
+
|
|
225
|
+
``touched_files`` is the set of JSON paths this tool recorded
|
|
226
|
+
``merged_keys`` against. A JSON bridge is preserved only when it
|
|
227
|
+
was touched (shared with neighbour tools) AND subtraction left
|
|
228
|
+
foreign keys behind. Untouched JSON bridges are owned solely by
|
|
229
|
+
this tool and removed with the rest.
|
|
230
|
+
"""
|
|
231
|
+
deleted: list[str] = []
|
|
232
|
+
skipped: list[str] = []
|
|
233
|
+
for record in entry.get("files") or []:
|
|
234
|
+
path = _resolve_recorded_path(project_root, record["path"])
|
|
235
|
+
kind = record.get("kind")
|
|
236
|
+
label = str(path)
|
|
237
|
+
if kind == "bridge":
|
|
238
|
+
# Shared JSON bridges with foreign keys are kept; otherwise
|
|
239
|
+
# the tool owns the file outright and we remove it.
|
|
240
|
+
is_shared_json = (
|
|
241
|
+
path.exists()
|
|
242
|
+
and path.suffix == ".json"
|
|
243
|
+
and label in touched_files
|
|
244
|
+
and label not in emptied_files
|
|
245
|
+
)
|
|
246
|
+
if is_shared_json:
|
|
247
|
+
skipped.append(f"bridge {label}: foreign keys preserved")
|
|
248
|
+
continue
|
|
249
|
+
if not path.exists():
|
|
250
|
+
skipped.append(f"bridge {label}: already absent")
|
|
251
|
+
continue
|
|
252
|
+
if dry_run:
|
|
253
|
+
deleted.append(f"would remove bridge {label}")
|
|
254
|
+
continue
|
|
255
|
+
try:
|
|
256
|
+
path.unlink()
|
|
257
|
+
deleted.append(f"removed bridge {label}")
|
|
258
|
+
except OSError as exc:
|
|
259
|
+
skipped.append(f"bridge {label}: ❌ {exc}")
|
|
260
|
+
elif kind == "marker":
|
|
261
|
+
if not path.exists():
|
|
262
|
+
skipped.append(f"marker {label}: already absent")
|
|
263
|
+
continue
|
|
264
|
+
if dry_run:
|
|
265
|
+
deleted.append(f"would remove marker {label}")
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
path.unlink()
|
|
269
|
+
deleted.append(f"removed marker {label}")
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
skipped.append(f"marker {label}: ❌ {exc}")
|
|
272
|
+
elif kind == "deployed":
|
|
273
|
+
if not purge:
|
|
274
|
+
skipped.append(f"deployed {label}: preserved (pass --purge)")
|
|
275
|
+
continue
|
|
276
|
+
if not path.exists():
|
|
277
|
+
skipped.append(f"deployed {label}: already absent")
|
|
278
|
+
continue
|
|
279
|
+
if dry_run:
|
|
280
|
+
deleted.append(f"would purge deployed {label}")
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
if path.is_dir():
|
|
284
|
+
shutil.rmtree(path)
|
|
285
|
+
else:
|
|
286
|
+
path.unlink()
|
|
287
|
+
deleted.append(f"purged deployed {label}")
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
skipped.append(f"deployed {label}: ❌ {exc}")
|
|
290
|
+
else:
|
|
291
|
+
skipped.append(f"{label}: unknown kind={kind!r}")
|
|
292
|
+
return deleted, skipped
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
296
|
+
parser = argparse.ArgumentParser(
|
|
297
|
+
prog="agent-config uninstall",
|
|
298
|
+
description=(
|
|
299
|
+
"Remove agent-config bridge markers (project) or lockfile "
|
|
300
|
+
"entries (global). Idempotent. Pass --purge to also delete "
|
|
301
|
+
"deployed content directories."
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument("--global", dest="global_mode", action="store_true",
|
|
305
|
+
help="operate on user-scope lockfile (~/.config/agent-config/installed.lock)")
|
|
306
|
+
parser.add_argument("--tools", default=None,
|
|
307
|
+
help="comma-separated tool IDs to uninstall (default: all in lockfile)")
|
|
308
|
+
parser.add_argument("--project", default=None, help="project root (default: cwd)")
|
|
309
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
310
|
+
help="show what would be removed; make no changes")
|
|
311
|
+
parser.add_argument("--purge", action="store_true",
|
|
312
|
+
help="also delete deployed content under user-scope anchors (destructive)")
|
|
313
|
+
parser.add_argument("--force", action="store_true",
|
|
314
|
+
help="proceed even if lockfile is absent (uninstall by tool list)")
|
|
315
|
+
return parser.parse_args(argv)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _uninstall_project(opts: argparse.Namespace) -> int:
|
|
319
|
+
project_root = _resolve_project_root(opts.project)
|
|
320
|
+
manifest_path = installed_tools.manifest_path(project_root)
|
|
321
|
+
manifest = installed_tools.read_manifest(manifest_path)
|
|
322
|
+
if manifest is None and not opts.force:
|
|
323
|
+
print(f"❌ no project lockfile at {manifest_path}", file=sys.stderr)
|
|
324
|
+
print(" pass --force to uninstall by --tools=<list> without manifest", file=sys.stderr)
|
|
325
|
+
return 1
|
|
326
|
+
pool = [e.get("name", "") for e in (manifest.get("tools", []) if manifest else [])]
|
|
327
|
+
if not pool and opts.tools:
|
|
328
|
+
pool = [t.strip() for t in opts.tools.split(",") if t.strip()]
|
|
329
|
+
tools = _filter_tools(pool, opts.tools)
|
|
330
|
+
if not tools:
|
|
331
|
+
print("ℹ️ no tools to uninstall")
|
|
332
|
+
return 0
|
|
333
|
+
print(f"{'[dry-run] ' if opts.dry_run else ''}uninstalling {len(tools)} tool(s) from {project_root}:")
|
|
334
|
+
|
|
335
|
+
# --force path without a manifest falls straight to the legacy
|
|
336
|
+
# bridge-marker map; v2 inventories are not available off-manifest.
|
|
337
|
+
if manifest is None:
|
|
338
|
+
for tool in tools:
|
|
339
|
+
line, _ = _remove_project_marker(project_root, tool, dry_run=opts.dry_run)
|
|
340
|
+
print(f" · {line}")
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
version = manifest.get("agent_config_version", "")
|
|
344
|
+
deploy_roots = manifest.get("deploy_roots") or None
|
|
345
|
+
tool_entries = list(manifest.get("tools", []))
|
|
346
|
+
removed_names: list[str] = []
|
|
347
|
+
|
|
348
|
+
for tool in tools:
|
|
349
|
+
entry = next((e for e in tool_entries if e.get("name") == tool), None)
|
|
350
|
+
if entry is None:
|
|
351
|
+
# Tool requested but not in the manifest — legacy marker fallback.
|
|
352
|
+
line, removed = _remove_project_marker(
|
|
353
|
+
project_root, tool, dry_run=opts.dry_run,
|
|
354
|
+
)
|
|
355
|
+
print(f" · {line}")
|
|
356
|
+
if removed and not opts.dry_run:
|
|
357
|
+
removed_names.append(tool)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
if not _is_v2_entry(entry):
|
|
361
|
+
# v1 entry — keep the legacy single-marker behaviour.
|
|
362
|
+
line, removed = _remove_project_marker(
|
|
363
|
+
project_root, tool, dry_run=opts.dry_run,
|
|
364
|
+
)
|
|
365
|
+
print(f" · {line}")
|
|
366
|
+
if removed and not opts.dry_run:
|
|
367
|
+
removed_names.append(tool)
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
files_n = len(entry.get("files") or [])
|
|
371
|
+
merges_n = len(entry.get("merged_keys") or [])
|
|
372
|
+
print(
|
|
373
|
+
f" · {tool}: v2 uninstall "
|
|
374
|
+
f"({files_n} file(s), {merges_n} merge pointer(s))"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Phase 1: flag the entry as uninstalling so a crash here is
|
|
378
|
+
# recoverable by ``cmd_prune`` (P2.1).
|
|
379
|
+
if not opts.dry_run:
|
|
380
|
+
tool_entries = _set_tool_status(
|
|
381
|
+
manifest_path, version, tool_entries, tool, "uninstalling",
|
|
382
|
+
deploy_roots=deploy_roots,
|
|
383
|
+
)
|
|
384
|
+
entry = next(
|
|
385
|
+
(e for e in tool_entries if e.get("name") == tool), entry,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Phase 2: subtract this tool's JSON merge contributions.
|
|
389
|
+
warnings, emptied, touched = _subtract_merged_keys(
|
|
390
|
+
entry, project_root, dry_run=opts.dry_run,
|
|
391
|
+
)
|
|
392
|
+
for w in warnings:
|
|
393
|
+
print(f" ⚠️ {w}")
|
|
394
|
+
|
|
395
|
+
# Phase 3: delete files[] entries — bridge files are kept when
|
|
396
|
+
# subtraction left foreign keys behind.
|
|
397
|
+
deleted, skipped = _delete_tool_files(
|
|
398
|
+
entry, project_root,
|
|
399
|
+
dry_run=opts.dry_run, purge=opts.purge,
|
|
400
|
+
emptied_files=emptied,
|
|
401
|
+
touched_files=touched,
|
|
402
|
+
)
|
|
403
|
+
for d in deleted:
|
|
404
|
+
print(f" ✓ {d}")
|
|
405
|
+
for s in skipped:
|
|
406
|
+
print(f" ↷ {s}")
|
|
407
|
+
|
|
408
|
+
if not opts.dry_run:
|
|
409
|
+
removed_names.append(tool)
|
|
410
|
+
|
|
411
|
+
# Phase 4: drop uninstalled entries; persist the manifest atomically.
|
|
412
|
+
if removed_names and not opts.dry_run:
|
|
413
|
+
remaining = [
|
|
414
|
+
e for e in tool_entries if e.get("name") not in removed_names
|
|
415
|
+
]
|
|
416
|
+
installed_tools.write_manifest(
|
|
417
|
+
manifest_path, version, remaining, deploy_roots=deploy_roots,
|
|
418
|
+
)
|
|
419
|
+
print(f"✅ manifest updated ({len(removed_names)} entries removed)")
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _uninstall_global(opts: argparse.Namespace) -> int:
|
|
424
|
+
lock_path = installed_lock.lockfile_path()
|
|
425
|
+
lock = installed_lock.read_lockfile(lock_path)
|
|
426
|
+
if lock is None and not opts.force:
|
|
427
|
+
print(f"❌ no global lockfile at {lock_path}", file=sys.stderr)
|
|
428
|
+
return 1
|
|
429
|
+
pool = list(lock.get("tools", []) if lock else [])
|
|
430
|
+
if not pool and opts.tools:
|
|
431
|
+
pool = [t.strip() for t in opts.tools.split(",") if t.strip()]
|
|
432
|
+
tools = _filter_tools(pool, opts.tools)
|
|
433
|
+
if not tools:
|
|
434
|
+
print("ℹ️ no tools to uninstall")
|
|
435
|
+
return 0
|
|
436
|
+
print(f"{'[dry-run] ' if opts.dry_run else ''}uninstalling {len(tools)} tool(s) from global scope:")
|
|
437
|
+
removed_names: list[str] = []
|
|
438
|
+
for tool in tools:
|
|
439
|
+
line, removed = _remove_global_content(tool, dry_run=opts.dry_run, purge=opts.purge)
|
|
440
|
+
print(f" · {line}")
|
|
441
|
+
if removed and not opts.dry_run:
|
|
442
|
+
removed_names.append(tool)
|
|
443
|
+
if lock is not None and not opts.dry_run:
|
|
444
|
+
remaining = [t for t in lock.get("tools", []) if t not in tools]
|
|
445
|
+
if remaining:
|
|
446
|
+
installed_lock.write_lockfile(remaining, version=lock.get("agent_config_version", ""))
|
|
447
|
+
print(f"✅ lockfile updated ({len(tools)} entries removed, {len(remaining)} kept)")
|
|
448
|
+
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}")
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main(argv: list[str] | None = None) -> int:
|
|
458
|
+
opts = _parse(list(argv) if argv is not None else sys.argv[1:])
|
|
459
|
+
if opts.global_mode:
|
|
460
|
+
return _uninstall_global(opts)
|
|
461
|
+
return _uninstall_project(opts)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
if __name__ == "__main__": # pragma: no cover
|
|
465
|
+
raise SystemExit(main())
|
|
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|
|
27
27
|
|
|
28
28
|
import argparse
|
|
29
29
|
import json
|
|
30
|
+
import os
|
|
30
31
|
import re
|
|
31
32
|
import subprocess
|
|
32
33
|
import sys
|
|
@@ -158,21 +159,40 @@ def main(
|
|
|
158
159
|
help="Print the latest available version and exit. No file is written.")
|
|
159
160
|
parser.add_argument("--to", metavar="VERSION",
|
|
160
161
|
help="Pin to an explicit version (registry-existence checked).")
|
|
162
|
+
parser.add_argument("--offline", action="store_true",
|
|
163
|
+
help="Skip the npm registry check; requires --to <version> "
|
|
164
|
+
"(without --to there is no source for 'latest').")
|
|
161
165
|
args = parser.parse_args(argv)
|
|
162
166
|
|
|
163
167
|
cwd = (cwd or Path.cwd()).resolve()
|
|
164
168
|
installed_version = installed_version or _detect_installed_version()
|
|
165
169
|
state_path = state_path or update_check.DEFAULT_STATE_PATH
|
|
166
170
|
|
|
171
|
+
# AGENT_CONFIG_OFFLINE=1 (set by `install.py --offline`) is honored
|
|
172
|
+
# as an env-level kill-switch. Mirrors cmd_versions.py.
|
|
173
|
+
offline = args.offline or os.environ.get("AGENT_CONFIG_OFFLINE") == "1"
|
|
174
|
+
|
|
175
|
+
if offline and not args.to:
|
|
176
|
+
print(
|
|
177
|
+
"❌ agent-config: --offline requires --to <version> "
|
|
178
|
+
"(no registry, no 'latest' to fetch).",
|
|
179
|
+
file=err,
|
|
180
|
+
)
|
|
181
|
+
return 1
|
|
182
|
+
|
|
167
183
|
if args.to:
|
|
168
184
|
target = _normalize(args.to)
|
|
169
|
-
if
|
|
185
|
+
if offline:
|
|
186
|
+
# Trust the caller; air-gapped env can't reach the registry.
|
|
187
|
+
latest = target
|
|
188
|
+
elif not version_checker(target):
|
|
170
189
|
print(
|
|
171
190
|
f"❌ agent-config: version {target} not found on the npm registry.",
|
|
172
191
|
file=err,
|
|
173
192
|
)
|
|
174
193
|
return 1
|
|
175
|
-
|
|
194
|
+
else:
|
|
195
|
+
latest = target
|
|
176
196
|
else:
|
|
177
197
|
latest = fetcher()
|
|
178
198
|
if not latest:
|
|
@@ -203,7 +223,10 @@ def main(
|
|
|
203
223
|
else:
|
|
204
224
|
print(f"ℹ️ {rel} already pins to {latest}.", file=out)
|
|
205
225
|
|
|
206
|
-
|
|
226
|
+
# `npx --yes <pkg>@<v> --version` would hit the registry; skip it
|
|
227
|
+
# offline so the air-gap guarantee holds end-to-end.
|
|
228
|
+
if not offline:
|
|
229
|
+
cache_warmer(latest)
|
|
207
230
|
_refresh_state(latest, latest, state_path)
|
|
208
231
|
_refresh_global_lockfile(latest, out=out)
|
|
209
232
|
return 0
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""``agent-config versions`` — list available package versions (Phase 4.1).
|
|
2
|
+
|
|
3
|
+
Queries the npm registry for available versions of
|
|
4
|
+
``@event4u/agent-config`` and prints them. Marks the current pin
|
|
5
|
+
(from ``.agent-settings.yml`` ``agent_config_version``) and the latest
|
|
6
|
+
published version.
|
|
7
|
+
|
|
8
|
+
Offline-tolerant: when ``--offline`` is passed or the registry is
|
|
9
|
+
unreachable, falls back to reading the local ``package.json`` version
|
|
10
|
+
and prints a single-line notice instead of failing.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
PACKAGE_NAME = "@event4u/agent-config"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _local_package_version() -> str:
|
|
25
|
+
"""Return ``version`` from the local ``package.json``, or ``""`` if absent."""
|
|
26
|
+
candidates = [
|
|
27
|
+
Path(__file__).resolve().parents[2] / "package.json",
|
|
28
|
+
Path.cwd() / "package.json",
|
|
29
|
+
]
|
|
30
|
+
for p in candidates:
|
|
31
|
+
if p.exists():
|
|
32
|
+
try:
|
|
33
|
+
return str(json.loads(p.read_text()).get("version", ""))
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
continue
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pinned_version() -> str:
|
|
40
|
+
"""Return the ``agent_config_version`` pin from ``.agent-settings.yml``."""
|
|
41
|
+
settings = Path.cwd() / ".agent-settings.yml"
|
|
42
|
+
if not settings.exists():
|
|
43
|
+
return ""
|
|
44
|
+
try:
|
|
45
|
+
for line in settings.read_text(encoding="utf-8").splitlines():
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if line.startswith("agent_config_version"):
|
|
48
|
+
_, _, rhs = line.partition(":")
|
|
49
|
+
return rhs.strip().strip('"').strip("'")
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _query_npm() -> list[str]:
|
|
56
|
+
"""Run ``npm view <pkg> versions --json``; return parsed list or ``[]``."""
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.run(
|
|
59
|
+
["npm", "view", PACKAGE_NAME, "versions", "--json"],
|
|
60
|
+
capture_output=True, text=True, timeout=15,
|
|
61
|
+
)
|
|
62
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
63
|
+
return []
|
|
64
|
+
if proc.returncode != 0:
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(proc.stdout)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
return []
|
|
70
|
+
if isinstance(data, str):
|
|
71
|
+
return [data]
|
|
72
|
+
if isinstance(data, list):
|
|
73
|
+
return [str(v) for v in data]
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_table(versions: list[str], current: str, pinned: str, limit: int) -> str:
|
|
78
|
+
rows: list[str] = []
|
|
79
|
+
head = versions[-limit:] if limit > 0 else versions
|
|
80
|
+
for v in head:
|
|
81
|
+
marks = []
|
|
82
|
+
if v == pinned:
|
|
83
|
+
marks.append("← pinned")
|
|
84
|
+
if v == current:
|
|
85
|
+
marks.append("← latest")
|
|
86
|
+
suffix = (" " + " ".join(marks)) if marks else ""
|
|
87
|
+
rows.append(f" {v}{suffix}")
|
|
88
|
+
return "\n".join(rows)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog="agent-config versions",
|
|
94
|
+
description="List available @event4u/agent-config versions on npm.",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument("--offline", action="store_true",
|
|
97
|
+
help="skip npm registry query; only show local package + pin")
|
|
98
|
+
parser.add_argument("--limit", type=int, default=20,
|
|
99
|
+
help="show only the N most recent versions (default: 20; 0 = all)")
|
|
100
|
+
parser.add_argument("--json", dest="as_json", action="store_true",
|
|
101
|
+
help="machine-readable output: {pinned, local, latest, versions[]}")
|
|
102
|
+
return parser.parse_args(argv)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: list[str] | None = None) -> int:
|
|
106
|
+
opts = _parse(list(argv) if argv is not None else sys.argv[1:])
|
|
107
|
+
|
|
108
|
+
# AGENT_CONFIG_OFFLINE=1 (set by `install.py --offline`) is honored
|
|
109
|
+
# as a global kill-switch even when the per-command --offline flag
|
|
110
|
+
# is absent. Keeps the env-driven offline contract consistent.
|
|
111
|
+
offline = opts.offline or os.environ.get("AGENT_CONFIG_OFFLINE") == "1"
|
|
112
|
+
|
|
113
|
+
local = _local_package_version()
|
|
114
|
+
pinned = _pinned_version()
|
|
115
|
+
versions: list[str] = []
|
|
116
|
+
if not offline:
|
|
117
|
+
versions = _query_npm()
|
|
118
|
+
latest = versions[-1] if versions else local
|
|
119
|
+
|
|
120
|
+
if opts.as_json:
|
|
121
|
+
print(json.dumps({
|
|
122
|
+
"pinned": pinned,
|
|
123
|
+
"local": local,
|
|
124
|
+
"latest": latest,
|
|
125
|
+
"versions": versions,
|
|
126
|
+
"source": "npm" if versions else "local",
|
|
127
|
+
}, indent=2))
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
print(f"package: {PACKAGE_NAME}")
|
|
131
|
+
print(f"pinned: {pinned or '— (no .agent-settings.yml)'}")
|
|
132
|
+
print(f"local: {local or '—'}")
|
|
133
|
+
if not versions:
|
|
134
|
+
if offline:
|
|
135
|
+
print("offline mode — registry query skipped")
|
|
136
|
+
else:
|
|
137
|
+
print("⚠️ npm registry unreachable; showing local only")
|
|
138
|
+
return 0
|
|
139
|
+
print(f"latest: {latest}")
|
|
140
|
+
print()
|
|
141
|
+
print(f"available versions ({'last ' + str(opts.limit) if opts.limit > 0 else 'all'}):")
|
|
142
|
+
print(_format_table(versions, latest, pinned, opts.limit))
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__": # pragma: no cover
|
|
147
|
+
raise SystemExit(main())
|