kitctl 0.0.1__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.
@@ -0,0 +1,19 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ *.egg-info/
9
+ node_modules/
10
+ npm/python/
11
+ *.tgz
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ .pypirc
16
+ inventory.json
17
+ inventory-report.md
18
+ reports/
19
+ .DS_Store
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 — 2026-10-11
4
+
5
+ - Initial Python CLI with version, help, and explicitly scoped read-only inventory.
6
+ - Detect skill folders and config locations; compute hashes without emitting contents.
7
+ - npm launcher bundles the same Python wheel and runs it through uvx.
8
+ - Sandbox tests, Linux/Windows CI, MIT license, and phased implementation plan.
9
+
10
+ This release does not change native agent configuration.
kitctl-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Connor England
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
kitctl-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: kitctl
3
+ Version: 0.0.1
4
+ Summary: Inventory and manage agent skills, MCP connectors, hooks, and loadouts.
5
+ Project-URL: Repository, https://github.com/ConnorBritain/kitctl
6
+ Project-URL: Issues, https://github.com/ConnorBritain/kitctl/issues
7
+ Author: Connor England
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,claude,codex,mcp,skills
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Kit (`kitctl`)
20
+
21
+ Kit inventories the skills, MCP configuration, hooks, and plugins used by coding
22
+ agents. The planned compiler manages named **loadouts** from a separate private
23
+ warehouse and writes each harness's native configuration with an ownership ledger.
24
+ The planned default warehouse location is `~/kits`; setup will ask which parent
25
+ directories contain your repositories.
26
+
27
+ **0.0.1 is an early, read-only release.** It discovers skill folders and known
28
+ configuration files in explicitly selected roots, computes SHA-256 hashes, and
29
+ prints text or JSON. It does not yet parse connectors, count usage, compile
30
+ loadouts, reconcile changes, or modify agent configuration.
31
+
32
+ ## Run
33
+
34
+ Python 3.11+:
35
+
36
+ ```sh
37
+ # From a checkout (works before the PyPI release):
38
+ uv run kitctl --version
39
+ uv run kitctl inventory --root /path/to/repo --json
40
+
41
+ # After publication to PyPI:
42
+ uvx kitctl --version
43
+ uv tool install kitctl
44
+ ```
45
+
46
+ Node 20+ with [uv installed](https://docs.astral.sh/uv/getting-started/installation/):
47
+
48
+ ```sh
49
+ # After publication to npm:
50
+ npx kitctl --version
51
+ npx kitctl inventory --root /path/to/repo --json
52
+ ```
53
+
54
+ The npm package is a thin launcher around the bundled Python wheel from the same
55
+ release. It uses `uvx` with that local wheel; it does not require a PyPI release,
56
+ run install scripts, or ship a `kit` binary. uv manages Python if needed.
57
+
58
+ `inventory` defaults to the current directory. Repeat `--root` to inspect several
59
+ repositories, or explicitly pass your home directory to include user config.
60
+ There is no recursive search through unrelated repositories. Symlinked skills and
61
+ configuration are skipped with warnings. JSON includes file paths and hashes,
62
+ never configuration contents. Exit codes: 0 complete, 1 partial due to skipped
63
+ items, 2 invalid arguments or roots. Reports may reveal private paths; keep them
64
+ out of public source control.
65
+
66
+ ## Develop
67
+
68
+ ```sh
69
+ uv sync --locked
70
+ uv run ruff check .
71
+ uv run ruff format --check .
72
+ uv run mypy
73
+ uv run pytest
74
+ uv build
75
+ uv run python scripts/prepare_npm.py
76
+ npm --prefix npm test
77
+ cd npm
78
+ npm pack --dry-run
79
+ ```
80
+
81
+ Tests use throwaway HOME, CODEX_HOME, CLAUDE_CONFIG_DIR, and repository fixtures.
82
+ CI runs on Linux and Windows with Python 3.11 and 3.14. A local pre-commit
83
+ configuration and CI check reject common token formats and private keys. Install
84
+ the hook with `uvx pre-commit install` if desired; this basic check is not a
85
+ comprehensive secret scanner.
86
+
87
+ See [PLAN.md](PLAN.md), [design](docs/design.md),
88
+ [harness reference](docs/harness-reference.md), and [publishing](docs/publishing.md).
89
+
90
+ ## Naming
91
+
92
+ Kit ships only the `kitctl` command. AWS's Kubernetes Iteration Toolkit and
93
+ Gravwell also use a `kitctl` binary; installing those tools alongside Kit can
94
+ cause PATH collisions. This project is independent of those projects and KitOps.
95
+
96
+ MIT License. The code repository and private warehouse are deliberately separate.
kitctl-0.0.1/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Kit (`kitctl`)
2
+
3
+ Kit inventories the skills, MCP configuration, hooks, and plugins used by coding
4
+ agents. The planned compiler manages named **loadouts** from a separate private
5
+ warehouse and writes each harness's native configuration with an ownership ledger.
6
+ The planned default warehouse location is `~/kits`; setup will ask which parent
7
+ directories contain your repositories.
8
+
9
+ **0.0.1 is an early, read-only release.** It discovers skill folders and known
10
+ configuration files in explicitly selected roots, computes SHA-256 hashes, and
11
+ prints text or JSON. It does not yet parse connectors, count usage, compile
12
+ loadouts, reconcile changes, or modify agent configuration.
13
+
14
+ ## Run
15
+
16
+ Python 3.11+:
17
+
18
+ ```sh
19
+ # From a checkout (works before the PyPI release):
20
+ uv run kitctl --version
21
+ uv run kitctl inventory --root /path/to/repo --json
22
+
23
+ # After publication to PyPI:
24
+ uvx kitctl --version
25
+ uv tool install kitctl
26
+ ```
27
+
28
+ Node 20+ with [uv installed](https://docs.astral.sh/uv/getting-started/installation/):
29
+
30
+ ```sh
31
+ # After publication to npm:
32
+ npx kitctl --version
33
+ npx kitctl inventory --root /path/to/repo --json
34
+ ```
35
+
36
+ The npm package is a thin launcher around the bundled Python wheel from the same
37
+ release. It uses `uvx` with that local wheel; it does not require a PyPI release,
38
+ run install scripts, or ship a `kit` binary. uv manages Python if needed.
39
+
40
+ `inventory` defaults to the current directory. Repeat `--root` to inspect several
41
+ repositories, or explicitly pass your home directory to include user config.
42
+ There is no recursive search through unrelated repositories. Symlinked skills and
43
+ configuration are skipped with warnings. JSON includes file paths and hashes,
44
+ never configuration contents. Exit codes: 0 complete, 1 partial due to skipped
45
+ items, 2 invalid arguments or roots. Reports may reveal private paths; keep them
46
+ out of public source control.
47
+
48
+ ## Develop
49
+
50
+ ```sh
51
+ uv sync --locked
52
+ uv run ruff check .
53
+ uv run ruff format --check .
54
+ uv run mypy
55
+ uv run pytest
56
+ uv build
57
+ uv run python scripts/prepare_npm.py
58
+ npm --prefix npm test
59
+ cd npm
60
+ npm pack --dry-run
61
+ ```
62
+
63
+ Tests use throwaway HOME, CODEX_HOME, CLAUDE_CONFIG_DIR, and repository fixtures.
64
+ CI runs on Linux and Windows with Python 3.11 and 3.14. A local pre-commit
65
+ configuration and CI check reject common token formats and private keys. Install
66
+ the hook with `uvx pre-commit install` if desired; this basic check is not a
67
+ comprehensive secret scanner.
68
+
69
+ See [PLAN.md](PLAN.md), [design](docs/design.md),
70
+ [harness reference](docs/harness-reference.md), and [publishing](docs/publishing.md).
71
+
72
+ ## Naming
73
+
74
+ Kit ships only the `kitctl` command. AWS's Kubernetes Iteration Toolkit and
75
+ Gravwell also use a `kitctl` binary; installing those tools alongside Kit can
76
+ cause PATH collisions. This project is independent of those projects and KitOps.
77
+
78
+ MIT License. The code repository and private warehouse are deliberately separate.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kitctl"
7
+ version = "0.0.1"
8
+ description = "Inventory and manage agent skills, MCP connectors, hooks, and loadouts."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Connor England" }]
13
+ keywords = ["agents", "claude", "codex", "skills", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.scripts]
24
+ kitctl = "kitctl.cli:main"
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/ConnorBritain/kitctl"
28
+ Issues = "https://github.com/ConnorBritain/kitctl/issues"
29
+
30
+ [dependency-groups]
31
+ dev = ["pytest>=8,<10", "ruff>=0.11,<1", "mypy>=1.15,<2"]
32
+
33
+ [tool.ruff]
34
+ target-version = "py311"
35
+ line-length = 100
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "I", "UP", "B"]
39
+
40
+ [tool.mypy]
41
+ python_version = "3.11"
42
+ strict = true
43
+ files = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/CHANGELOG.md", "/pyproject.toml"]
@@ -0,0 +1,3 @@
1
+ """Kit: inventory and manage agent tooling through deterministic loadouts."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from kitctl.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ """CLI for Kit's initial read-only release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from kitctl import __version__
11
+ from kitctl.inventory import inventory
12
+
13
+
14
+ def main(argv: list[str] | None = None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ prog="kitctl", description="Kit — inventory and manage agent tooling."
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"kitctl {__version__}")
19
+ commands = parser.add_subparsers(dest="command", required=True)
20
+ scan = commands.add_parser("inventory", help="Read-only discovery in explicit roots.")
21
+ scan.add_argument(
22
+ "--root",
23
+ action="append",
24
+ type=Path,
25
+ dest="roots",
26
+ metavar="PATH",
27
+ help="Repository or home directory to inspect; repeatable. Defaults to current directory.",
28
+ )
29
+ scan.add_argument("--json", action="store_true", help="Print metadata and hashes as JSON.")
30
+ args = parser.parse_args(argv)
31
+ try:
32
+ report = inventory(args.roots or [Path.cwd()])
33
+ except (OSError, ValueError) as error:
34
+ print(f"kitctl: {error}", file=sys.stderr)
35
+ return 2
36
+ if args.json:
37
+ print(json.dumps(report, indent=2))
38
+ else:
39
+ for entry in report["entries"]:
40
+ print(f"{entry['kind']:6} {entry['name']} {entry['path']}")
41
+ print(f"{len(report['entries'])} items; no files changed.")
42
+ for warning in report["warnings"]:
43
+ print(f"warning: {warning}", file=sys.stderr)
44
+ return 1 if report["warnings"] else 0
@@ -0,0 +1,129 @@
1
+ """Read-only, explicitly scoped discovery. Never serialize configuration values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import TypedDict
10
+
11
+ CONFIG_PATHS = (
12
+ ".claude/settings.json",
13
+ ".claude/settings.local.json",
14
+ ".claude.json",
15
+ ".mcp.json",
16
+ ".codex/config.toml",
17
+ ".codex/hooks.json",
18
+ "CLAUDE.md",
19
+ "AGENTS.md",
20
+ "kit.yaml",
21
+ "kit.lock",
22
+ )
23
+ SKILL_PATHS = (".claude/skills", ".agents/skills", ".codex/skills")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Entry:
28
+ kind: str
29
+ name: str
30
+ root: str
31
+ path: str
32
+ content_hash: str
33
+
34
+
35
+ class Report(TypedDict):
36
+ schema_version: int
37
+ entries: list[dict[str, str]]
38
+ warnings: list[str]
39
+
40
+
41
+ def _safe_file(path: Path, root: Path) -> bool:
42
+ """Skip links, including a link in a parent directory, and special files."""
43
+ relative = path.relative_to(root)
44
+ current = root
45
+ for part in relative.parts:
46
+ current /= part
47
+ if current.is_symlink():
48
+ return False
49
+ return path.is_file()
50
+
51
+
52
+ def _file_hash(path: Path) -> str:
53
+ digest = hashlib.sha256()
54
+ with path.open("rb") as stream:
55
+ for chunk in iter(lambda: stream.read(65536), b""):
56
+ digest.update(chunk)
57
+ return digest.hexdigest()
58
+
59
+
60
+ def _skill_hash(directory: Path, root: Path) -> str:
61
+ """Hash relative names and bytes of all regular skill assets deterministically."""
62
+ digest = hashlib.sha256()
63
+
64
+ def fail(error: OSError) -> None:
65
+ raise error
66
+
67
+ for current, dirs, files in os.walk(directory, followlinks=False, onerror=fail):
68
+ dirs[:] = sorted(
69
+ name for name in dirs if name != ".git" and not (Path(current) / name).is_symlink()
70
+ )
71
+ for name in sorted(files):
72
+ path = Path(current) / name
73
+ if _safe_file(path, root):
74
+ digest.update(path.relative_to(directory).as_posix().encode("utf-8"))
75
+ digest.update(b"\0")
76
+ digest.update(_file_hash(path).encode("ascii"))
77
+ digest.update(b"\0")
78
+ return digest.hexdigest()
79
+
80
+
81
+ def inventory(roots: list[Path]) -> Report:
82
+ entries: list[Entry] = []
83
+ warnings: list[str] = []
84
+ for root in sorted({path.expanduser().resolve() for path in roots}):
85
+ if not root.is_dir():
86
+ raise ValueError(f"Inventory root is not a directory: {root}")
87
+ for relative in CONFIG_PATHS:
88
+ path = root / relative
89
+ try:
90
+ if _safe_file(path, root):
91
+ entries.append(
92
+ Entry("config", relative, str(root), str(path), _file_hash(path))
93
+ )
94
+ elif path.exists() or path.is_symlink():
95
+ warnings.append(f"Skipped link or non-regular file: {path}")
96
+ except OSError:
97
+ warnings.append(f"Cannot read: {path}")
98
+ for relative in SKILL_PATHS:
99
+ directory = root / relative
100
+ try:
101
+ if directory.is_symlink() or any(
102
+ parent.is_symlink() for parent in directory.parents if parent != root
103
+ ):
104
+ warnings.append(f"Skipped linked skill directory: {directory}")
105
+ continue
106
+ if not directory.is_dir():
107
+ continue
108
+ for skill in sorted(directory.iterdir()):
109
+ manifest = skill / "SKILL.md"
110
+ if skill.is_symlink():
111
+ warnings.append(f"Skipped linked skill: {skill}")
112
+ elif manifest.is_symlink():
113
+ warnings.append(f"Skipped linked skill manifest: {manifest}")
114
+ elif _safe_file(manifest, root):
115
+ try:
116
+ entries.append(
117
+ Entry(
118
+ "skill",
119
+ skill.name,
120
+ str(root),
121
+ str(skill),
122
+ _skill_hash(skill, root),
123
+ )
124
+ )
125
+ except OSError:
126
+ warnings.append(f"Cannot read complete skill: {skill}")
127
+ except OSError:
128
+ warnings.append(f"Cannot read: {directory}")
129
+ return {"schema_version": 1, "entries": [asdict(e) for e in entries], "warnings": warnings}
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
8
+ home = tmp_path / "home"
9
+ repo = tmp_path / "repo"
10
+ home.mkdir()
11
+ repo.mkdir()
12
+ monkeypatch.setenv("HOME", str(home))
13
+ monkeypatch.setenv("USERPROFILE", str(home))
14
+ monkeypatch.setenv("CODEX_HOME", str(home / ".codex"))
15
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home / ".claude"))
16
+ monkeypatch.chdir(repo)
17
+ return repo
@@ -0,0 +1,67 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from kitctl.cli import main
7
+ from kitctl.inventory import inventory
8
+
9
+
10
+ def snapshot(root: Path) -> dict[str, bytes]:
11
+ return {str(p.relative_to(root)): p.read_bytes() for p in root.rglob("*") if p.is_file()}
12
+
13
+
14
+ def test_inventory_reads_hashes_without_writes_or_secret_values(sandbox: Path) -> None:
15
+ skill = sandbox / ".agents/skills/writing"
16
+ skill.mkdir(parents=True)
17
+ (skill / "SKILL.md").write_text("---\nname: writing\n---\nWrite clearly.")
18
+ config = sandbox / ".mcp.json"
19
+ config.write_text('{"token": "fixture-secret-value"}')
20
+ before = snapshot(sandbox)
21
+ report = inventory([sandbox, sandbox])
22
+ assert len(report["entries"]) == 2
23
+ assert report["warnings"] == []
24
+ assert "fixture-secret-value" not in json.dumps(report)
25
+ assert snapshot(sandbox) == before
26
+
27
+
28
+ def test_asset_edits_change_skill_hash(sandbox: Path) -> None:
29
+ skill = sandbox / ".claude/skills/demo"
30
+ skill.mkdir(parents=True)
31
+ (skill / "SKILL.md").write_text("Demo")
32
+ asset = skill / "helper.py"
33
+ asset.write_text("print(1)")
34
+ first = inventory([sandbox])
35
+ asset.write_text("print(2)")
36
+ assert first != inventory([sandbox])
37
+
38
+
39
+ def test_symlinks_do_not_read_outside_root(sandbox: Path, tmp_path: Path) -> None:
40
+ outside = tmp_path / "outside"
41
+ outside.mkdir()
42
+ (outside / "SKILL.md").write_text("Private")
43
+ skills = sandbox / ".agents/skills"
44
+ skills.mkdir(parents=True)
45
+ try:
46
+ (skills / "linked").symlink_to(outside, target_is_directory=True)
47
+ (sandbox / ".mcp.json").symlink_to(outside / "SKILL.md")
48
+ except OSError:
49
+ pytest.skip("Symlink creation unavailable on this platform")
50
+ report = inventory([sandbox])
51
+ assert report["entries"] == []
52
+ assert len(report["warnings"]) == 2
53
+
54
+
55
+ def test_cli_explicit_scope_and_missing_root(sandbox: Path, capsys: pytest.CaptureFixture) -> None:
56
+ (sandbox / "AGENTS.md").write_text("Instructions")
57
+ assert main(["inventory", "--json"]) == 0
58
+ assert len(json.loads(capsys.readouterr().out)["entries"]) == 1
59
+ assert main(["inventory", "--root", str(sandbox / "missing")]) == 2
60
+ assert "not a directory" in capsys.readouterr().err
61
+
62
+
63
+ def test_version(capsys: pytest.CaptureFixture) -> None:
64
+ with pytest.raises(SystemExit) as result:
65
+ main(["--version"])
66
+ assert result.value.code == 0
67
+ assert capsys.readouterr().out == "kitctl 0.0.1\n"
@@ -0,0 +1,33 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+
6
+ def test_staged_secret_check_uses_index_and_withholds_values(sandbox: Path) -> None:
7
+ checker = Path(__file__).resolve().parents[1] / "scripts/check_secrets.py"
8
+ subprocess.run(["git", "init", "-q"], cwd=sandbox, check=True)
9
+ token = "ghp_" + "A" * 36
10
+ candidate = sandbox / "config.txt"
11
+ candidate.write_text(token)
12
+ subprocess.run(["git", "add", "config.txt"], cwd=sandbox, check=True)
13
+ # Cleaning the working tree must not hide a secret still in the staging area.
14
+ candidate.write_text("clean")
15
+ result = subprocess.run(
16
+ [sys.executable, str(checker), "--staged"],
17
+ cwd=sandbox,
18
+ capture_output=True,
19
+ text=True,
20
+ check=False,
21
+ )
22
+ assert result.returncode == 1
23
+ assert "config.txt" in result.stderr
24
+ assert token not in result.stderr + result.stdout
25
+ subprocess.run(["git", "add", "config.txt"], cwd=sandbox, check=True)
26
+ result = subprocess.run(
27
+ [sys.executable, str(checker), "--staged"],
28
+ cwd=sandbox,
29
+ capture_output=True,
30
+ text=True,
31
+ check=False,
32
+ )
33
+ assert result.returncode == 0