installation-doctor
Diagnose cc-thingz plugin installations with the bundled read-only doctor. Use for missing installed skills, helpers, or hook files, stale package versions, duplicate packages, or migration from old cc-thingz packages. NOT for broad agent configuration audits or config edits; use
Install
npx skills add https://github.com/alexei-led/cc-thingz/tree/master/src/skills/installation-doctor
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alexei-led-cc-thingz@llmmart
git clone https://github.com/alexei-led/cc-thingz.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alexei-led/cc-thingz collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Installation Doctor
Inspect installed resources without a repository checkout. The helper compares files against its bundled release catalog; it does not execute hooks or modify installations.
Run
- Locate
scripts/doctor.pyrelative to this installedSKILL.md. - Use an existing Python 3.12+ interpreter with
-B. If using uv, runuv run --no-project python -B <skill-directory>/scripts/doctor.py --json. uv may populate its own cache; use an existing interpreter directly when the entire invocation must be read-only. - Select roots for the requested runtime. Without root options the helper
inspects the Codex cc-thingz marketplace cache and user flat skill directories.
For Claude or a specific installation, pass
--plugin-root <directory>. Repeat--plugin-rootor--skill-rootfor multiple locations. Any explicit root option restricts inspection to supplied roots. - Optionally pass
--config-root <.codex-directory>to check agent profile presence, or--repo <cc-thingz-checkout>for comparison with source manifests. - Summarize failed checks with paths, migration suggestions, and verification gaps. Exit 1 means findings; exit 2 means invalid arguments.
Interpretation
- Cached copies do not prove enabled plugins or duplicate execution. Inspect the runtime's enabled-plugin list before recommending removal.
- Versions are compared with the release containing this helper, not an online latest release. Without a containing native manifest, version checks are skipped.
hook-filesfails when a hook registration names a plugin file that is not on disk. A running session keeps the plugin root it started with; after a plugin update replaces that versioned directory, its hooks exit 127 or deny with "can't open file" even though the new install passes. Restart those sessions.- Hook support, helper dependencies, active plugins, and effective permissions remain unsupported by static inventory; exit 0 does not verify them.
- Report missing Python or unreadable metadata precisely. Do not install tools, change configuration, or delete caches as part of this diagnostic run.
Output
Report the inspected roots, baseline release, confirmed findings with paths, skipped or unsupported checks, and the next corrective action. Keep hook commands and credential contents out of the response.
Files (cc-thingz)
-
assets
-
catalog.json 3.7 KB
{ "browser": { "skills": [ "browser-automation" ], "skill_targets": {}, "resources": { "browser-automation": [ "SKILL.md", "scripts/.gitignore", "scripts/lib/helpers.js", "scripts/lib/runtime.js", "scripts/lib/screenshot.js", "scripts/package.json", "scripts/run.js", "scripts/screenshot-sequence.js", "scripts/screenshot-url.js", "scripts/setup-runtime.js" ] } }, "dev-flow": { "skills": [ "committing-code", "documenting-code", "releasing-code", "fixing-code", "improving-tests", "reviewing-code" ], "skill_targets": {}, "resources": { "committing-code": [ "SKILL.md", "scripts/commit-state.sh" ], "documenting-code": [ "SKILL.md", "scripts/check-links.py", "scripts/prose-lint.py", "scripts/render-mermaid.sh", "assets/examples/decision-outcomes.mmd", "assets/examples/lifecycle.mmd", "assets/examples/pipeline.mmd", "assets/examples/request-flow.mmd", "assets/examples/system-context.mmd" ], "releasing-code": [ "SKILL.md", "scripts/release_notes.py" ], "fixing-code": [ "SKILL.md" ], "improving-tests": [ "SKILL.md" ], "reviewing-code": [ "SKILL.md" ] } }, "discovery": { "skills": [ "brainstorming-ideas", "evolving-config", "installation-doctor", "looking-up-docs", "researching-web", "reviewing-instructions", "writing-skills" ], "skill_targets": {}, "resources": { "brainstorming-ideas": [ "SKILL.md" ], "evolving-config": [ "SKILL.md" ], "installation-doctor": [ "SKILL.md", "scripts/doctor.py", "assets/catalog.json" ], "looking-up-docs": [ "SKILL.md" ], "researching-web": [ "SKILL.md" ], "reviewing-instructions": [ "SKILL.md", "scripts/lint-instructions.py" ], "writing-skills": [ "SKILL.md" ] } }, "git-flow": { "skills": [ "cleanup-git", "configuring-git-hygiene", "using-git-worktrees" ], "skill_targets": {}, "resources": { "cleanup-git": [ "SKILL.md", "scripts/cleanup-git.sh" ], "configuring-git-hygiene": [ "SKILL.md" ], "using-git-worktrees": [ "SKILL.md", "scripts/cleanup-worktree.sh", "scripts/setup-worktree.sh" ] } }, "infra-ops": { "skills": [ "operating-infra" ], "skill_targets": {}, "resources": { "operating-infra": [ "SKILL.md", "scripts/bq-cost-check.py" ] } }, "programming": { "skills": [ "writing-csharp", "writing-go", "writing-java-kotlin", "writing-python", "writing-rust", "writing-shell", "writing-typescript", "writing-web" ], "skill_targets": {}, "resources": { "writing-csharp": [ "SKILL.md" ], "writing-go": [ "SKILL.md" ], "writing-java-kotlin": [ "SKILL.md" ], "writing-python": [ "SKILL.md" ], "writing-rust": [ "SKILL.md" ], "writing-shell": [ "SKILL.md" ], "writing-typescript": [ "SKILL.md" ], "writing-web": [ "SKILL.md" ] } }, "spec-flow": { "skills": [ "spec-flow" ], "skill_targets": {}, "resources": { "spec-flow": [ "SKILL.md", "scripts/specctl", "scripts/specctl.py" ] } } }
-
-
scripts
-
doctor.py 15.7 KB
"""Inspect cc-thingz resources without executing hooks or modifying installations.""" from __future__ import annotations import argparse import hashlib import json import re from collections import defaultdict from datetime import UTC, datetime from pathlib import Path from typing import Literal, TypedDict Status = Literal["passed", "failed", "skipped", "unsupported"] class CheckResult(TypedDict): check: str status: Status reason: str command: list[str] | None cwd: str scope: list[str] timestamp: str OBSOLETE = { "dev-tools": ["discovery", "git-flow"], "dev-workflow": ["dev-flow"], "spec-dev": ["spec-flow"], "go-dev": ["programming"], "py-dev": ["programming"], "python-dev": ["programming"], "rust-dev": ["programming"], "ts-dev": ["programming"], "typescript-dev": ["programming"], "web-dev": ["programming"], "test-e2e": ["browser"], "browser-automation": ["browser"], } MANIFESTS = (".codex-plugin/plugin.json", ".claude-plugin/plugin.json") # `${PLUGIN_ROOT}` or `${CLAUDE_PLUGIN_ROOT}` followed, across shell quotes, by a path. PLUGIN_FILE = re.compile(r"\$\{?(?:CLAUDE_)?PLUGIN_ROOT\}?[\"']*(/[\w./-]+)") class Inspection: def __init__(self, repo: Path | None) -> None: self.repo = repo self.results: list[CheckResult] = [] def add(self, check: str, status: Status, reason: str, paths: list[Path]) -> None: self.results.append( { "check": check, "status": status, "reason": reason, "command": None, "cwd": str(self.repo or Path.cwd()), "scope": [str(path) for path in paths], "timestamp": datetime.now(UTC).isoformat(), } ) def read_json(self, path: Path) -> dict: try: if path.stat().st_size > 2_000_000: raise ValueError("oversized") value = json.loads(path.read_text(encoding="utf-8")) if not isinstance(value, dict): raise ValueError("expected object") return value except (OSError, UnicodeError, ValueError): self.add("metadata", "failed", "Unreadable or invalid JSON object", [path]) return {} def canonical(self) -> dict[str, dict]: if self.repo is None: return self.bundled() catalog = {} manifests = sorted((self.repo / "src/.agentbundler/packages").glob("*.json")) if not manifests: self.add( "catalog", "failed", "No source package manifests found", [self.repo] ) for path in manifests: data = self.read_json(path) assets = data.get("assets", []) if not isinstance(assets, list): self.add("catalog", "failed", "Invalid assets list", [path]) continue skills = [ item["path"].split("/")[1] for item in assets if isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"].startswith("skills/") and len(item["path"].split("/")) == 2 and item["path"].split("/")[1] not in (".", "..") ] metadata = data.get("metadata", {}) catalog[path.stem] = { "skills": skills, "skill_targets": { item["path"].split("/")[1]: item["targets"] for item in assets if isinstance(item, dict) and item.get("path") in [f"skills/{skill}" for skill in skills] and isinstance(item.get("targets"), list) }, "version": metadata.get("version") if isinstance(metadata, dict) else None, "source": str(path), "resources": { skill: ["SKILL.md"] + [ str(helper.relative_to(self.repo / "src/skills" / skill)) for helper in sorted( (self.repo / "src/skills" / skill / "scripts").rglob("*") ) if helper.is_file() and "__pycache__" not in helper.parts ] + [ str(asset.relative_to(self.repo / "src/skills" / skill)) for asset in sorted( (self.repo / "src/skills" / skill / "assets").rglob("*") ) if asset.is_file() ] for skill in skills }, } return catalog def bundled(self) -> dict[str, dict]: catalog_path = Path(__file__).resolve().parent.parent / "assets/catalog.json" catalog = self.read_json(catalog_path) version = None version_source = catalog_path for parent in Path(__file__).resolve().parents: for suffix in ( *MANIFESTS, ".cursor-plugin/plugin.json", "plugin.json", "package.json", ): path = parent / suffix if not path.is_file(): continue metadata = self.read_json(path) if metadata.get("name") not in ( "discovery", "@cc-thingz/discovery", "cc-thingz", ): continue candidate = metadata.get("version") if isinstance(candidate, str) and candidate: version, version_source = candidate, path break if version is not None: break self.add( "catalog-version", "passed" if version else "skipped", f"Bundled release baseline {version}" if version else "No release manifest; version comparison unavailable (use --repo)", [version_source], ) for package in catalog.values(): package["version"] = version package["source"] = str(catalog_path) return catalog def plugins(self, roots: list[Path]) -> list[dict]: found = {} for root in roots: if not root.is_dir(): self.add("plugin-root", "skipped", "Directory not present", [root]) continue for depth in range(4): for suffix in MANIFESTS: for path in sorted(root.glob("*/" * depth + suffix)): plugin = path.parent.parent.resolve() if not plugin.is_relative_to(root.resolve()): continue if plugin in found: continue data = self.read_json(path) name = data.get("name") if not isinstance(name, str) or not name: self.add( "plugin-name", "failed", "Missing plugin name", [path] ) continue found[plugin] = { "name": name, "target": "codex" if suffix.startswith(".codex") else "claude", "version": data.get("version"), "source": str(plugin), } return sorted(found.values(), key=lambda item: item["source"]) def hooks(self, plugin: Path) -> None: path = plugin / "hooks/hooks.json" if not path.is_file(): return events = self.read_json(path).get("hooks") if not isinstance(events, dict): self.add("hook-registrations", "unsupported", "Unknown hook schema", [path]) return seen: set[str] = set() duplicates = 0 referenced: set[str] = set() for event, groups in events.items(): if not isinstance(groups, list): self.add("hook-registrations", "failed", "Invalid event groups", [path]) continue for group in groups: if not isinstance(group, dict) or not isinstance( group.get("hooks"), list ): self.add( "hook-registrations", "failed", "Invalid hook group", [path] ) continue for hook in group["hooks"]: identity = json.dumps( [event, {k: v for k, v in group.items() if k != "hooks"}, hook], sort_keys=True, ) digest = hashlib.sha256(identity.encode()).hexdigest() duplicates += digest in seen seen.add(digest) if isinstance(hook, dict): texts = [hook.get("command"), *(hook.get("args") or [])] for text in texts: if isinstance(text, str): referenced.update(PLUGIN_FILE.findall(text)) self.add( "hook-registrations", "failed" if duplicates else "passed", f"{duplicates} exact duplicate registrations within this manifest; " "runtime activation was not inspected", [path], ) missing = sorted( plugin / relative.lstrip("/") for relative in referenced if not (plugin / relative.lstrip("/")).is_file() ) self.add( "hook-files", "failed" if missing else "passed", f"{len(missing)} of {len(referenced)} referenced hook files missing" + ("; reinstall the plugin and restart its sessions" if missing else ""), missing or [path], ) def inspect( repo: Path | None, roots: list[Path], config_root: Path | None, skill_roots: list[Path] | None = None, ) -> dict: scan = Inspection(repo) catalog = scan.canonical() plugins = scan.plugins(roots) skills: dict[str, list[str]] = defaultdict(list) names: dict[str, list[str]] = defaultdict(list) for root in skill_roots or []: if not root.is_dir(): scan.add("skill-root", "skipped", "Directory not present", [root]) for path in sorted(root.glob("*/SKILL.md")): skills[path.parent.name].append(str(path)) for plugin in plugins: name = plugin["name"] path = Path(plugin["source"]) names[name].append(str(path)) if name in OBSOLETE: scan.add( "obsolete-package", "failed", f"{name}: review migration to {', '.join(OBSOLETE[name])}", [path], ) expected = catalog.get(name) if expected: scan.add( "package-version", ( "skipped" if expected["version"] is None else "passed" if plugin["version"] == expected["version"] else "failed" ), f"Installed {plugin['version']}; baseline {expected['version']}", [path], ) for skill in expected["skills"]: targets = expected["skill_targets"].get(skill) if targets is not None and plugin["target"] not in targets: continue skill_path = path / "skills" / skill required = [skill_path / item for item in expected["resources"][skill]] missing = [item for item in required if not item.is_file()] scan.add( "skill-resources", "failed" if missing else "passed", f"{skill}: {len(missing)} missing skill/helper files", missing or [skill_path], ) for skill_file in sorted((path / "skills").glob("*/SKILL.md")): skills[skill_file.parent.name].append(str(skill_file)) scan.hooks(path) for kind, inventory in (("plugin", names), ("skill", skills)): for name, sources in sorted(inventory.items()): if len(sources) > 1: scan.add( f"duplicate-{kind}", "failed", f"{name}: {len(sources)} copies on disk; activation unknown", [Path(source) for source in sources], ) if config_root is not None: for role in ("reviewer", "runner"): path = config_root / "agents" / f"{role}.toml" scan.add( "codex-agent-profile", "passed" if path.is_file() else "failed", f"{role}: profile {'present' if path.is_file() else 'missing'}; " "loading and effective permissions not verified", [path], ) else: scan.add("codex-agent-profile", "skipped", "No --config-root supplied", []) scan.add( "runtime-support", "unsupported", "Static inventory cannot establish enabled plugins, hook event support, " "helper dependencies, or effective agent permissions", roots, ) return { "schema_version": 1, "canonical_packages": catalog, "plugins": plugins, "skills": dict(sorted(skills.items())), "checks": scan.results, } def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--repo", type=Path, help="Optional checkout for source comparison instead of bundled baseline", ) parser.add_argument("--plugin-root", action="append", type=Path) parser.add_argument("--skill-root", action="append", type=Path) parser.add_argument("--config-root", type=Path, help="Codex .codex directory") parser.add_argument( "--json", action="store_true", help="Emit schema-versioned JSON" ) args = parser.parse_args(argv) roots = ( args.plugin_root if args.plugin_root is not None else [] if args.skill_root is not None or args.config_root is not None else [ Path.home() / ".codex/plugins/cache/alexei-led-cc-thingz", ] ) skill_roots = args.skill_root or [] if ( args.plugin_root is None and args.skill_root is None and args.config_root is None ): skill_roots = [Path.home() / ".codex/skills", Path.home() / ".agents/skills"] report = inspect( args.repo.resolve() if args.repo is not None else None, [p.resolve() for p in roots], args.config_root.resolve() if args.config_root is not None else None, [p.resolve() for p in skill_roots], ) if args.json: print(json.dumps(report, indent=2)) else: print("cc-thingz doctor: disk inventory (activation unknown)") for name, package in report["canonical_packages"].items(): print( f"canonical {name}@{package['version']}: {', '.join(package['skills'])}" ) for plugin in report["plugins"]: print(f"installed {plugin['name']}@{plugin['version']}: {plugin['source']}") for check in report["checks"]: print(f"{check['status']}: {check['check']}: {check['reason']}") for path in check["scope"]: print(f" {path}") return int(any(item["status"] == "failed" for item in report["checks"])) if __name__ == "__main__": raise SystemExit(main())
-
-
SKILL.md 2.7 KB
--- name: installation-doctor description: >- Diagnose cc-thingz plugin installations with the bundled read-only doctor. Use for missing installed skills, helpers, or hook files, stale package versions, duplicate packages, or migration from old cc-thingz packages. NOT for broad agent configuration audits or config edits; use evolving-config. --- # Installation Doctor Inspect installed resources without a repository checkout. The helper compares files against its bundled release catalog; it does not execute hooks or modify installations. ## Run 1. Locate `scripts/doctor.py` relative to this installed `SKILL.md`. 2. Use an existing Python 3.12+ interpreter with `-B`. If using uv, run `uv run --no-project python -B <skill-directory>/scripts/doctor.py --json`. uv may populate its own cache; use an existing interpreter directly when the entire invocation must be read-only. 3. Select roots for the requested runtime. Without root options the helper inspects the Codex cc-thingz marketplace cache and user flat skill directories. For Claude or a specific installation, pass `--plugin-root <directory>`. Repeat `--plugin-root` or `--skill-root` for multiple locations. Any explicit root option restricts inspection to supplied roots. 4. Optionally pass `--config-root <.codex-directory>` to check agent profile presence, or `--repo <cc-thingz-checkout>` for comparison with source manifests. 5. Summarize failed checks with paths, migration suggestions, and verification gaps. Exit 1 means findings; exit 2 means invalid arguments. ## Interpretation - Cached copies do not prove enabled plugins or duplicate execution. Inspect the runtime's enabled-plugin list before recommending removal. - Versions are compared with the release containing this helper, not an online latest release. Without a containing native manifest, version checks are skipped. - `hook-files` fails when a hook registration names a plugin file that is not on disk. A running session keeps the plugin root it started with; after a plugin update replaces that versioned directory, its hooks exit 127 or deny with "can't open file" even though the new install passes. Restart those sessions. - Hook support, helper dependencies, active plugins, and effective permissions remain unsupported by static inventory; exit 0 does not verify them. - Report missing Python or unreadable metadata precisely. Do not install tools, change configuration, or delete caches as part of this diagnostic run. ## Output Report the inspected roots, baseline release, confirmed findings with paths, skipped or unsupported checks, and the next corrective action. Keep hook commands and credential contents out of the response.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.