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.
- kitctl-0.0.1/.gitignore +19 -0
- kitctl-0.0.1/CHANGELOG.md +10 -0
- kitctl-0.0.1/LICENSE +21 -0
- kitctl-0.0.1/PKG-INFO +96 -0
- kitctl-0.0.1/README.md +78 -0
- kitctl-0.0.1/pyproject.toml +49 -0
- kitctl-0.0.1/src/kitctl/__init__.py +3 -0
- kitctl-0.0.1/src/kitctl/__main__.py +3 -0
- kitctl-0.0.1/src/kitctl/cli.py +44 -0
- kitctl-0.0.1/src/kitctl/inventory.py +129 -0
- kitctl-0.0.1/tests/conftest.py +17 -0
- kitctl-0.0.1/tests/test_inventory.py +67 -0
- kitctl-0.0.1/tests/test_secrets.py +33 -0
kitctl-0.0.1/.gitignore
ADDED
|
@@ -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,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
|