agentic-harness 0.2.0__py3-none-any.whl

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.
Files changed (110) hide show
  1. agent_harness/__init__.py +6 -0
  2. agent_harness/cli.py +179 -0
  3. agent_harness/config.py +57 -0
  4. agent_harness/conftest.py +51 -0
  5. agent_harness/detect.py +54 -0
  6. agent_harness/exclusions.py +64 -0
  7. agent_harness/fix.py +36 -0
  8. agent_harness/git_files.py +75 -0
  9. agent_harness/init/__init__.py +0 -0
  10. agent_harness/init/diagnostic.py +69 -0
  11. agent_harness/init/scaffold.py +169 -0
  12. agent_harness/init/templates.py +141 -0
  13. agent_harness/lint.py +38 -0
  14. agent_harness/policies/compose/configs.rego +33 -0
  15. agent_harness/policies/compose/configs_test.rego +23 -0
  16. agent_harness/policies/compose/escaping.rego +72 -0
  17. agent_harness/policies/compose/escaping_test.rego +46 -0
  18. agent_harness/policies/compose/hostname.rego +63 -0
  19. agent_harness/policies/compose/hostname_test.rego +69 -0
  20. agent_harness/policies/compose/images.rego +108 -0
  21. agent_harness/policies/compose/images_test.rego +72 -0
  22. agent_harness/policies/compose/services.rego +95 -0
  23. agent_harness/policies/compose/services_test.rego +75 -0
  24. agent_harness/policies/compose/volumes.rego +58 -0
  25. agent_harness/policies/compose/volumes_test.rego +39 -0
  26. agent_harness/policies/dockerfile/base_image.rego +45 -0
  27. agent_harness/policies/dockerfile/base_image_test.rego +44 -0
  28. agent_harness/policies/dockerfile/cache.rego +48 -0
  29. agent_harness/policies/dockerfile/cache_test.rego +44 -0
  30. agent_harness/policies/dockerfile/healthcheck.rego +38 -0
  31. agent_harness/policies/dockerfile/healthcheck_test.rego +33 -0
  32. agent_harness/policies/dockerfile/layers.rego +100 -0
  33. agent_harness/policies/dockerfile/layers_test.rego +55 -0
  34. agent_harness/policies/dockerfile/secrets.rego +88 -0
  35. agent_harness/policies/dockerfile/secrets_test.rego +47 -0
  36. agent_harness/policies/dockerfile/user.rego +39 -0
  37. agent_harness/policies/dockerfile/user_test.rego +33 -0
  38. agent_harness/policies/dokploy/traefik.rego +72 -0
  39. agent_harness/policies/dokploy/traefik_test.rego +90 -0
  40. agent_harness/policies/gitignore/secrets.rego +64 -0
  41. agent_harness/policies/gitignore/secrets_test.rego +62 -0
  42. agent_harness/policies/javascript/package.rego +61 -0
  43. agent_harness/policies/javascript/package_test.rego +66 -0
  44. agent_harness/policies/python/coverage.rego +43 -0
  45. agent_harness/policies/python/coverage_test.rego +50 -0
  46. agent_harness/policies/python/pytest.rego +56 -0
  47. agent_harness/policies/python/pytest_test.rego +41 -0
  48. agent_harness/policies/python/ruff.rego +69 -0
  49. agent_harness/policies/python/ruff_test.rego +47 -0
  50. agent_harness/policies/python/test_isolation.rego +31 -0
  51. agent_harness/policies/python/test_isolation_test.rego +20 -0
  52. agent_harness/preset.py +47 -0
  53. agent_harness/presets/__init__.py +1 -0
  54. agent_harness/presets/docker/__init__.py +60 -0
  55. agent_harness/presets/docker/conftest_compose_check.py +51 -0
  56. agent_harness/presets/docker/conftest_dockerfile_check.py +76 -0
  57. agent_harness/presets/docker/detect.py +20 -0
  58. agent_harness/presets/docker/hadolint_check.py +51 -0
  59. agent_harness/presets/docker/templates.py +46 -0
  60. agent_harness/presets/dokploy/__init__.py +38 -0
  61. agent_harness/presets/dokploy/conftest_dokploy_check.py +51 -0
  62. agent_harness/presets/dokploy/detect.py +16 -0
  63. agent_harness/presets/javascript/__init__.py +56 -0
  64. agent_harness/presets/javascript/biome_check.py +58 -0
  65. agent_harness/presets/javascript/conftest_package_check.py +34 -0
  66. agent_harness/presets/javascript/detect.py +10 -0
  67. agent_harness/presets/javascript/fix.py +23 -0
  68. agent_harness/presets/javascript/templates.py +39 -0
  69. agent_harness/presets/javascript/type_check.py +75 -0
  70. agent_harness/presets/python/__init__.py +51 -0
  71. agent_harness/presets/python/conftest_check.py +18 -0
  72. agent_harness/presets/python/detect.py +10 -0
  73. agent_harness/presets/python/fix.py +36 -0
  74. agent_harness/presets/python/ruff_check.py +47 -0
  75. agent_harness/presets/python/setup_check.py +168 -0
  76. agent_harness/presets/python/templates.py +225 -0
  77. agent_harness/presets/python/ty_check.py +32 -0
  78. agent_harness/presets/universal/__init__.py +96 -0
  79. agent_harness/presets/universal/claudemd_setup.py +64 -0
  80. agent_harness/presets/universal/conftest_gitignore_check.py +40 -0
  81. agent_harness/presets/universal/conftest_json_check.py +91 -0
  82. agent_harness/presets/universal/file_length_check.py +85 -0
  83. agent_harness/presets/universal/gitignore_setup.py +167 -0
  84. agent_harness/presets/universal/gitignore_tracked_check.py +59 -0
  85. agent_harness/presets/universal/gitignore_tracked_fix.py +37 -0
  86. agent_harness/presets/universal/precommit_check.py +82 -0
  87. agent_harness/presets/universal/templates.py +49 -0
  88. agent_harness/presets/universal/yamllint_check.py +95 -0
  89. agent_harness/registry.py +10 -0
  90. agent_harness/runner.py +73 -0
  91. agent_harness/security/__init__.py +0 -0
  92. agent_harness/security/audit.py +36 -0
  93. agent_harness/security/config.py +48 -0
  94. agent_harness/security/display.py +60 -0
  95. agent_harness/security/gitleaks_scanner.py +100 -0
  96. agent_harness/security/models.py +76 -0
  97. agent_harness/security/osv_scanner.py +150 -0
  98. agent_harness/setup_check.py +21 -0
  99. agent_harness/templates/gitignore/Linux.gitignore +16 -0
  100. agent_harness/templates/gitignore/Node.gitignore +144 -0
  101. agent_harness/templates/gitignore/Python.gitignore +216 -0
  102. agent_harness/templates/gitignore/SOURCE.md +6 -0
  103. agent_harness/templates/gitignore/Windows.gitignore +24 -0
  104. agent_harness/templates/gitignore/macOS.gitignore +25 -0
  105. agent_harness/workspace.py +13 -0
  106. agentic_harness-0.2.0.dist-info/METADATA +310 -0
  107. agentic_harness-0.2.0.dist-info/RECORD +110 -0
  108. agentic_harness-0.2.0.dist-info/WHEEL +4 -0
  109. agentic_harness-0.2.0.dist-info/entry_points.txt +2 -0
  110. agentic_harness-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ # src/agent_harness/__init__.py
2
+ from pathlib import Path
3
+
4
+ __version__ = "0.2.0"
5
+
6
+ POLICIES_DIR = Path(__file__).resolve().parent / "policies"
agent_harness/cli.py ADDED
@@ -0,0 +1,179 @@
1
+ # src/agent_harness/cli.py
2
+ import click
3
+ from pathlib import Path
4
+
5
+
6
+ @click.group()
7
+ @click.version_option()
8
+ def cli():
9
+ """AI Harness — deterministic controls for AI agents."""
10
+
11
+
12
+ @cli.command()
13
+ def detect():
14
+ """Detect project stacks and subprojects."""
15
+ from agent_harness.detect import detect_all
16
+
17
+ cwd = Path.cwd()
18
+ results = detect_all(cwd)
19
+ if not results:
20
+ click.echo("no stacks detected")
21
+ return
22
+ for path, stacks in sorted(results.items()):
23
+ rel = "." if path == cwd else str(path.relative_to(cwd))
24
+ stacks_str = ", ".join(sorted(stacks))
25
+ has_harness = (path / ".agent-harness.yml").exists()
26
+ suffix = "" if has_harness else " (no .agent-harness.yml)"
27
+ click.echo(f"{rel:<30} {stacks_str}{suffix}")
28
+
29
+
30
+ def print_results(results) -> int:
31
+ """Print lint results and return exit code."""
32
+ failed = [r for r in results if not r.passed]
33
+ passed = [r for r in results if r.passed]
34
+ total_ms = sum(r.duration_ms for r in results)
35
+
36
+ for r in results:
37
+ icon = "PASS" if r.passed else "FAIL"
38
+ click.echo(f" {icon} {r.name} ({r.duration_ms}ms)")
39
+ if not r.passed:
40
+ detail = r.error or r.output
41
+ if detail:
42
+ for line in detail.strip().splitlines():
43
+ click.echo(f" {line}")
44
+
45
+ click.echo(f"\n{len(passed)} passed, {len(failed)} failed ({total_ms}ms)")
46
+ return 1 if failed else 0
47
+
48
+
49
+ @cli.command()
50
+ def lint():
51
+ """Run all harness checks (auto-discovers subprojects)."""
52
+ from agent_harness.lint import run_lint_all
53
+
54
+ cwd = Path.cwd()
55
+ all_results = run_lint_all(cwd)
56
+ total_exit = 0
57
+ for path, results in sorted(all_results.items()):
58
+ rel = "." if path == cwd else str(path.relative_to(cwd))
59
+ if len(all_results) > 1:
60
+ click.echo(f"\n=== {rel} ===")
61
+ exit_code = print_results(results)
62
+ if exit_code:
63
+ total_exit = 1
64
+ raise SystemExit(total_exit)
65
+
66
+
67
+ @cli.command()
68
+ def fix():
69
+ """Auto-fix what's fixable, then lint (auto-discovers subprojects)."""
70
+ from agent_harness.fix import run_fix_all
71
+ from agent_harness.lint import run_lint_all
72
+
73
+ cwd = Path.cwd()
74
+
75
+ click.echo("Fixing...")
76
+ fix_results = run_fix_all(cwd)
77
+ for path, actions in sorted(fix_results.items()):
78
+ rel = "." if path == cwd else str(path.relative_to(cwd))
79
+ for a in actions:
80
+ click.echo(f" {rel}: {a}")
81
+
82
+ click.echo("\nLinting...")
83
+ lint_results = run_lint_all(cwd)
84
+ total_exit = 0
85
+ for path, results in sorted(lint_results.items()):
86
+ rel = "." if path == cwd else str(path.relative_to(cwd))
87
+ if len(lint_results) > 1:
88
+ click.echo(f"\n=== {rel} ===")
89
+ exit_code = print_results(results)
90
+ if exit_code:
91
+ total_exit = 1
92
+ raise SystemExit(total_exit)
93
+
94
+
95
+ @cli.command()
96
+ @click.option("--apply", is_flag=True, help="Apply fixes and create missing files")
97
+ def init(apply):
98
+ """Diagnose harness setup: check tools, config quality, missing files.
99
+
100
+ Without --apply: report mode (shows issues, suggests fixes).
101
+ With --apply: applies auto-fixes and creates missing config files.
102
+
103
+ Setup checks diagnose configuration quality (thresholds, flags,
104
+ coverage settings) and offer fixes. Lint checks run separately
105
+ via 'agent-harness lint' for fast pass/fail enforcement.
106
+
107
+ Examples:
108
+ agent-harness init # diagnose only
109
+ agent-harness init --apply # diagnose and fix
110
+ """
111
+ from agent_harness.init.scaffold import scaffold_all
112
+
113
+ all_results = scaffold_all(Path.cwd(), apply=apply)
114
+ any_actions = False
115
+ for path, actions in sorted(all_results.items()):
116
+ for action in actions:
117
+ click.echo(f" {action}")
118
+ if actions:
119
+ any_actions = True
120
+ if any_actions:
121
+ click.echo("\n Done. Run: make lint")
122
+
123
+
124
+ def _run_audit(base_branch: str | None, full_history: bool) -> None:
125
+ """Shared logic for security audit commands."""
126
+ from agent_harness.config import load_config
127
+ from agent_harness.security.audit import run_security_audit
128
+ from agent_harness.security.display import format_report
129
+
130
+ cwd = Path.cwd()
131
+ config = load_config(cwd)
132
+
133
+ if base_branch:
134
+ config.setdefault("security", {})["base_branch"] = base_branch
135
+
136
+ stacks = config.get("stacks", set())
137
+ mode = "full history" if full_history else "working directory"
138
+ click.echo(f"Running security audit ({mode})...")
139
+
140
+ report = run_security_audit(
141
+ cwd, stacks=stacks, config=config, full_history=full_history
142
+ )
143
+
144
+ for line in format_report(report):
145
+ click.echo(line)
146
+
147
+ if report.has_failures:
148
+ raise SystemExit(1)
149
+
150
+
151
+ @cli.command("security-audit")
152
+ @click.option(
153
+ "--base-branch",
154
+ default=None,
155
+ help="Base branch for dep diff (default: from config or origin/main)",
156
+ )
157
+ def security_audit(base_branch):
158
+ """Scan working directory for vulnerabilities and leaked secrets.
159
+
160
+ Checks deps for known CVEs (blocks new deps with High/Critical + fix).
161
+ Scans working directory for secrets. Fast, safe for every commit.
162
+ """
163
+ _run_audit(base_branch, full_history=False)
164
+
165
+
166
+ @cli.command("security-audit-history")
167
+ @click.option(
168
+ "--base-branch",
169
+ default=None,
170
+ help="Base branch for dep diff (default: from config or origin/main)",
171
+ )
172
+ def security_audit_history(base_branch):
173
+ """Deep scan full git history for leaked secrets.
174
+
175
+ Same as security-audit but also scans all past commits for secrets
176
+ that were committed and later deleted. Slower (10-60s). Run once
177
+ during setup or periodically in CI.
178
+ """
179
+ _run_audit(base_branch, full_history=True)
@@ -0,0 +1,57 @@
1
+ """Config loading — dict-based, each preset reads its own section."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+
11
+ def _resolve_git_root(project_dir: Path) -> Path | None:
12
+ """Find the git repository root for project_dir, or None if not in a repo."""
13
+ result = subprocess.run(
14
+ ["git", "rev-parse", "--show-toplevel"],
15
+ capture_output=True,
16
+ text=True,
17
+ cwd=str(project_dir),
18
+ )
19
+ if result.returncode != 0:
20
+ return None
21
+ return Path(result.stdout.strip())
22
+
23
+
24
+ def load_config(project_dir: Path) -> dict:
25
+ """Load .agent-harness.yml. Returns dict."""
26
+ config: dict = {"stacks": set(), "exclude": []}
27
+ config["git_root"] = _resolve_git_root(project_dir)
28
+ cfg_path = project_dir / ".agent-harness.yml"
29
+
30
+ if cfg_path.exists():
31
+ try:
32
+ raw = yaml.safe_load(cfg_path.read_text()) or {}
33
+ except yaml.YAMLError as e:
34
+ import click
35
+
36
+ click.echo(
37
+ f" WARNING: {cfg_path} is malformed, using defaults\n {e}", err=True
38
+ )
39
+ raw = {}
40
+
41
+ if "stacks" in raw:
42
+ config["stacks"] = set(raw["stacks"])
43
+ if "exclude" in raw:
44
+ config["exclude"] = list(raw["exclude"])
45
+
46
+ # Pass through all other sections for presets to read
47
+ for key, value in raw.items():
48
+ if key not in ("stacks", "exclude"):
49
+ config[key] = value
50
+
51
+ # Auto-detect if no stacks specified
52
+ if not config["stacks"]:
53
+ from agent_harness.registry import PRESETS
54
+
55
+ config["stacks"] = {p.name for p in PRESETS if p.detect(project_dir)}
56
+
57
+ return config
@@ -0,0 +1,51 @@
1
+ """Shared conftest runner for Rego policy checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from agent_harness import POLICIES_DIR
11
+ from agent_harness.runner import CheckResult, run_check
12
+
13
+
14
+ def run_conftest(
15
+ name: str,
16
+ project_dir: Path,
17
+ target_file: str,
18
+ policy_subdir: str,
19
+ data: dict | None = None,
20
+ ) -> CheckResult:
21
+ """Run conftest test on a target file with bundled policies."""
22
+ target = project_dir / target_file
23
+ if not target.exists():
24
+ return CheckResult(
25
+ name=name, passed=True, output=f"Skipping {name}: {target_file} not found"
26
+ )
27
+
28
+ policy_path = POLICIES_DIR / policy_subdir
29
+ cmd = [
30
+ "conftest",
31
+ "test",
32
+ str(target),
33
+ "--policy",
34
+ str(policy_path),
35
+ "--no-color",
36
+ "--all-namespaces",
37
+ ]
38
+
39
+ data_path = None
40
+ if data:
41
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
42
+ json.dump(data, tmp)
43
+ tmp.close()
44
+ data_path = tmp.name
45
+ cmd.extend(["--data", data_path])
46
+
47
+ try:
48
+ return run_check(name, cmd, cwd=str(project_dir))
49
+ finally:
50
+ if data_path:
51
+ os.unlink(data_path)
@@ -0,0 +1,54 @@
1
+ """Stack detection orchestrator — delegates to preset detect methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from agent_harness.git_files import find_files
8
+ from agent_harness.registry import PRESETS
9
+
10
+ # Dependency manifests that define a real project (not just a build target)
11
+ _PROJECT_MANIFESTS = [
12
+ "pyproject.toml",
13
+ "package.json",
14
+ "go.mod",
15
+ "Cargo.toml",
16
+ "Gemfile",
17
+ "composer.json",
18
+ "pom.xml",
19
+ "build.gradle",
20
+ ]
21
+
22
+
23
+ def detect_stacks(project_dir: Path) -> set[str]:
24
+ """Detect which stacks a project uses based on file presence."""
25
+ return {p.name for p in PRESETS if p.detect(project_dir)}
26
+
27
+
28
+ def detect_all(project_dir: Path) -> dict[Path, set[str]]:
29
+ """Detect stacks in root and subdirectories.
30
+
31
+ A directory is a project only if it has a dependency manifest
32
+ (pyproject.toml, package.json, etc.) or is the root directory.
33
+ Docker-only directories are build targets, not projects.
34
+ """
35
+ all_patterns = [f"**/{m}" for m in _PROJECT_MANIFESTS] + [
36
+ m for m in _PROJECT_MANIFESTS
37
+ ]
38
+ manifest_files = find_files(project_dir, all_patterns)
39
+
40
+ project_dirs: set[Path] = set()
41
+ for f in manifest_files:
42
+ project_dirs.add(f.parent)
43
+
44
+ root_stacks = detect_stacks(project_dir)
45
+ if root_stacks:
46
+ project_dirs.add(project_dir)
47
+
48
+ results: dict[Path, set[str]] = {}
49
+ for d in sorted(project_dirs):
50
+ stacks = detect_stacks(d)
51
+ if stacks:
52
+ results[d] = stacks
53
+
54
+ return results
@@ -0,0 +1,64 @@
1
+ """
2
+ File exclusion system.
3
+
4
+ WHAT: Provides default and configurable file exclusion patterns for all checks.
5
+
6
+ WHY: Without exclusions, agent-harness scans lock files, build output, archives,
7
+ and vendored code. This wastes time (yamllint on pnpm-lock.yaml: 1.7s) and
8
+ produces false positives (JSONC parse failures on tsconfig.json in _archive/).
9
+
10
+ WITHOUT IT: 2.5s lint runs that should be 200ms, false positives on generated
11
+ files, agents fixing issues in files they shouldn't touch.
12
+
13
+ FIX: Add patterns to `exclude:` in .agent-harness.yml.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fnmatch
19
+
20
+ DEFAULT_EXCLUSIONS = [
21
+ # Lock files
22
+ "*.lock",
23
+ "*-lock.*",
24
+ "package-lock.json",
25
+ # Build output
26
+ "dist/",
27
+ ".astro/",
28
+ ".next/",
29
+ ".nuxt/",
30
+ # Dependencies
31
+ "node_modules/",
32
+ ".venv/",
33
+ # Caches
34
+ "__pycache__/",
35
+ ".pytest_cache/",
36
+ ".ruff_cache/",
37
+ # Archives
38
+ "_archive/",
39
+ ]
40
+
41
+
42
+ def get_excluded_patterns(config_exclude: list[str]) -> list[str]:
43
+ """Merge default exclusions with config-provided ones."""
44
+ return DEFAULT_EXCLUSIONS + config_exclude
45
+
46
+
47
+ def is_excluded(filepath: str, patterns: list[str]) -> bool:
48
+ """Check if a filepath matches any exclusion pattern."""
49
+ for pattern in patterns:
50
+ # Directory prefix match: "dist/" matches "dist/foo/bar.js"
51
+ if pattern.endswith("/") and filepath.startswith(pattern):
52
+ return True
53
+ # Also match if any path segment matches directory pattern
54
+ if pattern.endswith("/"):
55
+ dir_name = pattern.rstrip("/")
56
+ if f"/{dir_name}/" in f"/{filepath}" or filepath.startswith(f"{dir_name}/"):
57
+ return True
58
+ # Glob match: "*.lock" matches "poetry.lock"
59
+ if fnmatch.fnmatch(filepath, pattern):
60
+ return True
61
+ # Also match basename: "*-lock.*" matches "path/to/pnpm-lock.yaml"
62
+ if fnmatch.fnmatch(filepath.split("/")[-1], pattern):
63
+ return True
64
+ return False
agent_harness/fix.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ from pathlib import Path
5
+
6
+ from agent_harness.config import load_config
7
+ from agent_harness.registry import PRESETS, UNIVERSAL
8
+ from agent_harness.workspace import discover_roots
9
+
10
+
11
+ def run_fix(project_dir: Path) -> list[str]:
12
+ """Auto-fix what's fixable, then return actions taken."""
13
+ config = load_config(project_dir)
14
+ actions = UNIVERSAL.run_fix(project_dir, config)
15
+ for preset in PRESETS:
16
+ if preset.name in config.get("stacks", set()):
17
+ actions.extend(preset.run_fix(project_dir, config))
18
+ return actions
19
+
20
+
21
+ def run_fix_all(project_dir: Path) -> dict[Path, list[str]]:
22
+ """Discover all subprojects and run fix in each."""
23
+ roots = discover_roots(project_dir)
24
+ if not roots:
25
+ return {project_dir: run_fix(project_dir)}
26
+
27
+ results: dict[Path, list[str]] = {}
28
+ with ThreadPoolExecutor() as pool:
29
+ futures = {pool.submit(run_fix, root): root for root in roots}
30
+ for future in as_completed(futures):
31
+ root = futures[future]
32
+ try:
33
+ results[root] = future.result()
34
+ except Exception as e:
35
+ results[root] = [f"error: {e}"]
36
+ return results
@@ -0,0 +1,75 @@
1
+ """Git-aware file discovery -- respects .gitignore, finds tracked + untracked files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ def find_files(project_dir: Path, patterns: list[str]) -> list[Path]:
11
+ """Find files matching glob patterns, respecting .gitignore.
12
+
13
+ Uses git ls-files for repos (tracked + untracked, excluding ignored).
14
+ Falls back to filesystem walk for non-git directories.
15
+ """
16
+ files = _git_find(project_dir, patterns)
17
+ if files is not None:
18
+ return files
19
+ return _fs_find(project_dir, patterns)
20
+
21
+
22
+ def _git_find(project_dir: Path, patterns: list[str]) -> list[Path] | None:
23
+ """Use git ls-files to find files. Returns None if not in a git repo."""
24
+ result = subprocess.run(
25
+ [
26
+ "git",
27
+ "ls-files",
28
+ "--cached",
29
+ "--others",
30
+ "--exclude-standard",
31
+ *patterns,
32
+ ],
33
+ capture_output=True,
34
+ text=True,
35
+ cwd=str(project_dir),
36
+ )
37
+ if result.returncode != 0:
38
+ return None
39
+ paths = []
40
+ for line in result.stdout.strip().splitlines():
41
+ if line:
42
+ paths.append((project_dir / line).resolve())
43
+ return sorted(set(paths))
44
+
45
+
46
+ def _fs_find(project_dir: Path, patterns: list[str]) -> list[Path]:
47
+ """Fallback: filesystem walk with basic glob matching."""
48
+ _skip = {
49
+ ".venv",
50
+ "venv",
51
+ "node_modules",
52
+ ".git",
53
+ "dist",
54
+ "build",
55
+ "__pycache__",
56
+ ".astro",
57
+ ".next",
58
+ ".nuxt",
59
+ ".worktrees",
60
+ "_archive",
61
+ ".pytest_cache",
62
+ ".ruff_cache",
63
+ }
64
+ results: set[Path] = set()
65
+ for path in project_dir.rglob("*"):
66
+ if any(part in _skip for part in path.parts):
67
+ continue
68
+ if not path.is_file():
69
+ continue
70
+ rel = str(path.relative_to(project_dir))
71
+ if any(
72
+ fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(path.name, p) for p in patterns
73
+ ):
74
+ results.add(path.resolve())
75
+ return sorted(results)
File without changes
@@ -0,0 +1,69 @@
1
+ """Diagnostic display for init command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from agent_harness.preset import ToolInfo
10
+ from agent_harness.runner import tool_available
11
+ from agent_harness.setup_check import SetupIssue
12
+
13
+
14
+ def display_setup_issues(
15
+ preset_name: str,
16
+ issues: list[SetupIssue],
17
+ tools: list[ToolInfo],
18
+ project_dir: Path,
19
+ ) -> tuple[int, int, int]:
20
+ """Display setup check results. Returns (critical_count, recommendation_count, fixable_count)."""
21
+ click.echo(f"\n {preset_name}:")
22
+
23
+ critical_count = 0
24
+ recommendation_count = 0
25
+ fixable_count = 0
26
+
27
+ for issue in issues:
28
+ if issue.severity == "critical":
29
+ suffix = " (fixable)" if issue.fixable else ""
30
+ click.echo(f" \u2717 {issue.file}: {issue.message} critical{suffix}")
31
+ critical_count += 1
32
+ if issue.fixable:
33
+ fixable_count += 1
34
+ else:
35
+ click.echo(f" ~ {issue.file}: {issue.message} recommendation")
36
+ recommendation_count += 1
37
+
38
+ for tool in tools:
39
+ available = tool_available(tool.binary, project_dir)
40
+ if available:
41
+ click.echo(f" \u2713 {tool.name} installed")
42
+ else:
43
+ click.echo(f" \u2717 {tool.name} not installed ({tool.install_hint})")
44
+ critical_count += 1
45
+
46
+ return critical_count, recommendation_count, fixable_count
47
+
48
+
49
+ def display_summary(
50
+ critical: int, recommendations: int, fixable: int, missing_files: int
51
+ ) -> None:
52
+ """Display final summary line."""
53
+ parts = []
54
+ if critical:
55
+ fix_note = f" ({fixable} fixable)" if fixable else ""
56
+ parts.append(f"{critical} critical{fix_note}")
57
+ if recommendations:
58
+ parts.append(
59
+ f"{recommendations} recommendation{'s' if recommendations != 1 else ''}"
60
+ )
61
+ if missing_files:
62
+ parts.append(
63
+ f"{missing_files} file{'s' if missing_files != 1 else ''} to create"
64
+ )
65
+
66
+ if parts:
67
+ click.echo(f"\n {', '.join(parts)}.")
68
+ else:
69
+ click.echo("\n All checks passed.")