@caddis/cli 0.4.55 → 0.4.57
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/bundles/antigravity-plugin/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/guard_agy.py +1056 -83
- package/bundles/antigravity-plugin/hooks.json +2 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +182 -4
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +20 -12
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +63 -12
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +14 -5
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +27 -1
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +93 -6
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +67 -22
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +8 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +290 -176
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +346 -50
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +82 -8
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +89 -24
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +10 -6
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +7 -1
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +12 -5
- package/bundles/antigravity-plugin/scripts/caddis_worktree_safety.py +182 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +2 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +6 -2
- package/bundles/antigravity-plugin/scripts/no_window.py +49 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +303 -79
- package/bundles/antigravity-plugin/scripts/secret_filter.py +190 -22
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +6 -1
- package/bundles/antigravity-plugin/session_end_agy.py +27 -11
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +55 -9
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +11 -8
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +10 -6
- package/bundles/antigravity-plugin/skills/git-commit/SKILL.md +2 -3
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -17
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +45 -5
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +4 -2
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +13 -4
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +28 -6
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +16 -13
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +32 -10
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +32 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +44 -6
- package/bundles/antigravity-plugin/skills/writing-plans/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +40 -16
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +2 -1
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +4 -2
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +10 -6
- package/bundles/codex/.codex/skills/devops/git-commit/SKILL.md +2 -3
- package/bundles/codex/.codex/skills/docs/writing-plans/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +28 -6
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +16 -13
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Remove a git worktree, or a folder that held one, without deleting through a link.
|
|
2
|
+
|
|
3
|
+
On Windows, `git worktree remove --force` and `shutil.rmtree` can follow a directory junction and delete its
|
|
4
|
+
TARGET: a shared `.venv`, a `node_modules`, the live repo. So every symlink and junction in the tree goes first,
|
|
5
|
+
removed as a link (never entered), and only then is the tree removed. If a link cannot be removed, nothing is
|
|
6
|
+
deleted: the caller gets the problem back.
|
|
7
|
+
|
|
8
|
+
remove_links(root) -> the links removed (raises LinkRemovalError when one cannot go)
|
|
9
|
+
remove_tree(path) -> None, or the problem (links first, then rmtree)
|
|
10
|
+
safe_remove_worktree(repo, wt) -> None, or the problem (refuses the main checkout, unregistered paths, and
|
|
11
|
+
a locked or dirty worktree that git would keep, before any link goes)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
24
|
+
_DIRECTORY = getattr(stat, "FILE_ATTRIBUTE_DIRECTORY", 0x10)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LinkRemovalError(OSError):
|
|
28
|
+
"""A link could not be removed. `removed` holds the links that were already removed before the failure; no
|
|
29
|
+
other file was deleted."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, cause: OSError, removed: list[Path]):
|
|
32
|
+
detail = str(cause)
|
|
33
|
+
if removed:
|
|
34
|
+
detail += (f" (removed {len(removed)} link(s) before the failure: "
|
|
35
|
+
f"{', '.join(str(p) for p in removed)})")
|
|
36
|
+
super().__init__(detail)
|
|
37
|
+
self.cause = cause
|
|
38
|
+
self.removed = removed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_link(st: os.stat_result) -> bool:
|
|
42
|
+
"""A symlink, or on Windows any reparse point (a junction included), from `os.lstat`."""
|
|
43
|
+
return stat.S_ISLNK(st.st_mode) or bool(getattr(st, "st_file_attributes", 0) & _REPARSE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unlink_link(path: str, st: os.stat_result) -> None:
|
|
47
|
+
"""Remove the link itself, never its target: on Windows a junction or a directory symlink with os.rmdir, any
|
|
48
|
+
other link with os.unlink."""
|
|
49
|
+
if os.name == "nt" and getattr(st, "st_file_attributes", 0) & _DIRECTORY:
|
|
50
|
+
os.rmdir(path)
|
|
51
|
+
else:
|
|
52
|
+
os.unlink(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def remove_links(root: Path) -> list[Path]:
|
|
56
|
+
"""Remove every symlink and junction at or under `root`, bottom-up, never following one. Returns the links
|
|
57
|
+
removed. Raises LinkRemovalError (an OSError) when a link cannot be removed; the links removed before it are in
|
|
58
|
+
its `removed`. The caller must then delete nothing more, because a delete through a link destroys its target.
|
|
59
|
+
A missing `root` removes nothing."""
|
|
60
|
+
root = Path(root)
|
|
61
|
+
try:
|
|
62
|
+
st = os.lstat(root)
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return []
|
|
65
|
+
if is_link(st):
|
|
66
|
+
try:
|
|
67
|
+
_unlink_link(str(root), st)
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise LinkRemovalError(exc, []) from exc
|
|
70
|
+
return [root]
|
|
71
|
+
folders = [str(root)]
|
|
72
|
+
order: list[str] = []
|
|
73
|
+
while folders: # top-down collection of real folders only; a link is never entered
|
|
74
|
+
folder = folders.pop()
|
|
75
|
+
order.append(folder)
|
|
76
|
+
with os.scandir(folder) as entries:
|
|
77
|
+
for entry in entries:
|
|
78
|
+
entry_st = os.lstat(entry.path)
|
|
79
|
+
if not is_link(entry_st) and stat.S_ISDIR(entry_st.st_mode):
|
|
80
|
+
folders.append(entry.path)
|
|
81
|
+
removed: list[Path] = []
|
|
82
|
+
try:
|
|
83
|
+
for folder in reversed(order): # bottom-up removal of the links in each folder
|
|
84
|
+
with os.scandir(folder) as entries:
|
|
85
|
+
found = [(entry.path, os.lstat(entry.path)) for entry in entries]
|
|
86
|
+
for path, entry_st in found:
|
|
87
|
+
if is_link(entry_st):
|
|
88
|
+
_unlink_link(path, entry_st)
|
|
89
|
+
removed.append(Path(path))
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
raise LinkRemovalError(exc, removed) from exc
|
|
92
|
+
return removed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def remove_tree(path: Path) -> str | None:
|
|
96
|
+
"""Delete the folder `path` (for example the temp folder that held a worktree): its links first, then the
|
|
97
|
+
rest. None when it is gone, else the problem. When a link cannot go, no file is deleted (the links removed
|
|
98
|
+
before the failure are named in the problem)."""
|
|
99
|
+
path = Path(path)
|
|
100
|
+
if not os.path.lexists(path):
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
remove_links(path)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
return f"not removed: {path}: a link could not be removed: {exc}; no file was deleted"
|
|
106
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
107
|
+
return f"not removed: {path}" if os.path.lexists(path) else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _same(a: Path | str, b: Path | str) -> bool:
|
|
111
|
+
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _run_git(repo: Path, args: Sequence[str], git_flags: Sequence[str],
|
|
115
|
+
env: Mapping[str, str] | None) -> subprocess.CompletedProcess:
|
|
116
|
+
return subprocess.run(["git", *git_flags, "-C", str(repo), *args], capture_output=True, text=True, check=False,
|
|
117
|
+
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
|
|
118
|
+
env=dict(env) if env is not None else None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _worktree_blocks(porcelain: str) -> list[list[str]]:
|
|
122
|
+
"""`git worktree list --porcelain` split into one list of lines per worktree."""
|
|
123
|
+
blocks: list[list[str]] = []
|
|
124
|
+
for line in porcelain.splitlines():
|
|
125
|
+
if line.startswith("worktree "):
|
|
126
|
+
blocks.append([line])
|
|
127
|
+
elif line.strip() and blocks:
|
|
128
|
+
blocks[-1].append(line)
|
|
129
|
+
return blocks
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def safe_remove_worktree(repo: Path, worktree: Path, *, force: bool = True, force_locked: bool = False,
|
|
133
|
+
git_flags: Sequence[str] = (), env: Mapping[str, str] | None = None) -> str | None:
|
|
134
|
+
"""Remove the linked worktree `worktree` of `repo`: its links first, then `git worktree remove`.
|
|
135
|
+
|
|
136
|
+
None when git removed it, else the problem. Before any link is touched it refuses a path that is a link, the
|
|
137
|
+
main checkout, a path that is not a registered linked worktree of `repo`, a locked worktree unless
|
|
138
|
+
`force_locked`, and a dirty one unless `force`: git would keep those, and a kept worktree must keep its links.
|
|
139
|
+
`force` passes `--force` (a dirty worktree goes); `force_locked` passes it twice (a locked one goes too). A
|
|
140
|
+
relative `worktree` is taken from `repo`, not from the process cwd. `git_flags` go before `-C` (for example
|
|
141
|
+
`-c core.hooksPath=...`); `env` is the git environment."""
|
|
142
|
+
worktree = Path(worktree)
|
|
143
|
+
if not worktree.is_absolute():
|
|
144
|
+
worktree = Path(repo) / worktree
|
|
145
|
+
try:
|
|
146
|
+
if os.path.lexists(worktree) and is_link(os.lstat(worktree)):
|
|
147
|
+
return f"refusing to remove {worktree}: the worktree folder is a junction or symlink"
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
return f"refusing to remove {worktree}: cannot inspect it: {exc}"
|
|
150
|
+
listing = _run_git(Path(repo), ("worktree", "list", "--porcelain"), git_flags, env)
|
|
151
|
+
if listing.returncode != 0:
|
|
152
|
+
return f"refusing to remove {worktree}: git worktree list failed: {(listing.stderr or '').strip()}"
|
|
153
|
+
blocks = _worktree_blocks(listing.stdout)
|
|
154
|
+
if not blocks:
|
|
155
|
+
return f"refusing to remove {worktree}: git listed no worktrees"
|
|
156
|
+
if _same(worktree, blocks[0][0][len("worktree "):]):
|
|
157
|
+
return f"refusing to remove {worktree}: it is the main checkout"
|
|
158
|
+
if _same(worktree, repo):
|
|
159
|
+
return f"refusing to remove {worktree}: it is the checkout git runs in"
|
|
160
|
+
block = next((b for b in blocks[1:] if _same(worktree, b[0][len("worktree "):])), None)
|
|
161
|
+
if block is None:
|
|
162
|
+
return f"refusing to remove {worktree}: not a registered worktree of {repo}"
|
|
163
|
+
if not force_locked and any(line == "locked" or line.startswith("locked ") for line in block):
|
|
164
|
+
return f"refusing to remove {worktree}: it is locked (git worktree unlock it first); its links were kept"
|
|
165
|
+
if not force and os.path.lexists(worktree):
|
|
166
|
+
status = _run_git(worktree, ("status", "--porcelain"), git_flags, env)
|
|
167
|
+
if status.returncode != 0:
|
|
168
|
+
return f"refusing to remove {worktree}: git status failed: {(status.stderr or '').strip()}"
|
|
169
|
+
if status.stdout.strip():
|
|
170
|
+
return f"refusing to remove {worktree}: it has uncommitted changes; its links were kept"
|
|
171
|
+
try:
|
|
172
|
+
removed = remove_links(worktree)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
return f"not removed: {worktree}: a link could not be removed: {exc}; no file was deleted"
|
|
175
|
+
flags = ["--force", "--force"] if force_locked else (["--force"] if force else [])
|
|
176
|
+
proc = _run_git(Path(repo), ("worktree", "remove", *flags, str(worktree)), git_flags, env)
|
|
177
|
+
if proc.returncode != 0:
|
|
178
|
+
detail = (proc.stderr or proc.stdout or "").strip() or str(worktree)
|
|
179
|
+
if removed:
|
|
180
|
+
detail += f" ({len(removed)} link(s) inside it were removed first)"
|
|
181
|
+
return f"git worktree remove failed: {detail}"
|
|
182
|
+
return None
|
|
@@ -448,6 +448,8 @@ def _repo_root() -> Path:
|
|
|
448
448
|
out = subprocess.run(
|
|
449
449
|
["git", "rev-parse", "--show-toplevel"],
|
|
450
450
|
capture_output=True, text=True, timeout=5,
|
|
451
|
+
# No console window when the parent has none (a hook or an editor task).
|
|
452
|
+
creationflags=0x08000000 if os.name == "nt" else 0,
|
|
451
453
|
)
|
|
452
454
|
if out.returncode == 0 and out.stdout.strip():
|
|
453
455
|
return Path(out.stdout.strip())
|
|
@@ -41,6 +41,10 @@ FIX_HINTS: dict[str, str] = {
|
|
|
41
41
|
|
|
42
42
|
# Probed contract versions (docs/analysis/*-contract.md). WARN when the installed binary drifts past
|
|
43
43
|
# these — the recorded schemas may be stale and want a re-probe.
|
|
44
|
+
# No child process opens a console window on Windows (this runs under hooks and status lines that
|
|
45
|
+
# have no console). Inline, not scripts/no_window.py: in the source tree this file does not sit beside it.
|
|
46
|
+
_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
47
|
+
|
|
44
48
|
PROBED_VERSIONS = {"codex": "0.137", "agy": "1.1.5"}
|
|
45
49
|
AGENTS_MD_BUDGET = 200 # always-loaded rules file line budget (mirrors check_doc_coverage)
|
|
46
50
|
|
|
@@ -100,7 +104,7 @@ def _which(name: str) -> str | None:
|
|
|
100
104
|
|
|
101
105
|
def _binary_version(path: str) -> str | None:
|
|
102
106
|
try:
|
|
103
|
-
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20)
|
|
107
|
+
out = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW)
|
|
104
108
|
return (out.stdout or out.stderr).strip().splitlines()[0] if (out.stdout or out.stderr) else None
|
|
105
109
|
except Exception:
|
|
106
110
|
return None
|
|
@@ -461,7 +465,7 @@ def host_guard_findings(
|
|
|
461
465
|
if sys.platform != "win32":
|
|
462
466
|
return []
|
|
463
467
|
|
|
464
|
-
run = run or subprocess.run
|
|
468
|
+
run = run or (lambda *a, **k: subprocess.run(*a, creationflags=_NO_WINDOW, **k))
|
|
465
469
|
out: list[tuple[str, str]] = []
|
|
466
470
|
|
|
467
471
|
try:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Start subprocesses without a visible console window on Windows. No dependencies.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A process that has NO console (a Claude session started by VS Code, the `claude daemon`, a
|
|
6
|
+
background session) makes Windows open a NEW, VISIBLE console for every console program it starts
|
|
7
|
+
(git.exe, cmd, python, npx, ruff). Hooks and the status line run in exactly such sessions, so each
|
|
8
|
+
`subprocess.run(["git", ...])` flashed a window (owner report, 19 Oct).
|
|
9
|
+
|
|
10
|
+
`CREATE_NO_WINDOW` fixes it. The child gets a hidden console, and output captured through pipes
|
|
11
|
+
still works. When the parent already has a console the flag is harmless.
|
|
12
|
+
|
|
13
|
+
Never add `DETACHED_PROCESS`: Windows then ignores `CREATE_NO_WINDOW`, the child has no console,
|
|
14
|
+
and every program it starts opens its own visible window. `merge()` clears that bit.
|
|
15
|
+
|
|
16
|
+
Use
|
|
17
|
+
---
|
|
18
|
+
from no_window import run # a drop-in for subprocess.run
|
|
19
|
+
subprocess.run(argv, **NO_WINDOW_KW) # or spread the keywords yourself
|
|
20
|
+
|
|
21
|
+
On POSIX `NO_WINDOW_KW` is empty and `merge()` returns the keywords unchanged.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
|
|
28
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
29
|
+
DETACHED_PROCESS = 0x00000008
|
|
30
|
+
|
|
31
|
+
NO_WINDOW_KW = {"creationflags": CREATE_NO_WINDOW} if os.name == "nt" else {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def merge(kwargs: dict) -> dict:
|
|
35
|
+
"""A copy of `kwargs` with CREATE_NO_WINDOW ORed into `creationflags` (Windows only)."""
|
|
36
|
+
out = dict(kwargs)
|
|
37
|
+
if os.name == "nt":
|
|
38
|
+
out["creationflags"] = (int(out.get("creationflags") or 0) & ~DETACHED_PROCESS) | CREATE_NO_WINDOW
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run(*args, **kwargs):
|
|
43
|
+
"""subprocess.run that never opens a console window on Windows."""
|
|
44
|
+
return subprocess.run(*args, **merge(kwargs))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def popen(*args, **kwargs):
|
|
48
|
+
"""subprocess.Popen that never opens a console window on Windows."""
|
|
49
|
+
return subprocess.Popen(*args, **merge(kwargs))
|