ai-project-maintainer 0.3.0

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/ai-project-maintainer/SKILL.md +62 -0
  4. package/ai-project-maintainer/agents/openai.yaml +6 -0
  5. package/ai-project-maintainer/references/ci-guardrails.md +55 -0
  6. package/ai-project-maintainer/references/database.md +60 -0
  7. package/ai-project-maintainer/references/electron-desktop.md +43 -0
  8. package/ai-project-maintainer/references/incident-response.md +52 -0
  9. package/ai-project-maintainer/references/local-gate.md +117 -0
  10. package/ai-project-maintainer/references/security.md +48 -0
  11. package/ai-project-maintainer/references/tool-router.md +53 -0
  12. package/ai-project-maintainer/scripts/audit-plan.mjs +155 -0
  13. package/ai-project-maintainer/scripts/bootstrap-local-tools.ps1 +109 -0
  14. package/ai-project-maintainer/scripts/check-syntax.mjs +41 -0
  15. package/ai-project-maintainer/scripts/ci-smoke-gate.mjs +26 -0
  16. package/ai-project-maintainer/scripts/cli.mjs +165 -0
  17. package/ai-project-maintainer/scripts/doctor.mjs +80 -0
  18. package/ai-project-maintainer/scripts/init-audit.mjs +105 -0
  19. package/ai-project-maintainer/scripts/init-project.mjs +229 -0
  20. package/ai-project-maintainer/scripts/lib/check-registry.mjs +68 -0
  21. package/ai-project-maintainer/scripts/lib/checks.mjs +337 -0
  22. package/ai-project-maintainer/scripts/lib/command-runner.mjs +130 -0
  23. package/ai-project-maintainer/scripts/lib/intake.mjs +172 -0
  24. package/ai-project-maintainer/scripts/lib/policy.mjs +150 -0
  25. package/ai-project-maintainer/scripts/lib/project-detect.mjs +111 -0
  26. package/ai-project-maintainer/scripts/lib/report.mjs +227 -0
  27. package/ai-project-maintainer/scripts/probe-project.mjs +218 -0
  28. package/ai-project-maintainer/scripts/report-summary.mjs +25 -0
  29. package/ai-project-maintainer/scripts/run-local-gate.mjs +147 -0
  30. package/docs/CI-GITHUB-ACTIONS.zh-CN.md +83 -0
  31. package/docs/DEMO.md +81 -0
  32. package/docs/DEMO.zh-CN.md +81 -0
  33. package/docs/GITHUB-LAUNCH-CHECKLIST.md +77 -0
  34. package/docs/INSTALL.zh-CN.md +112 -0
  35. package/docs/INTAKE-SCHEMA.zh-CN.md +105 -0
  36. package/docs/POLICY-AND-EXCEPTIONS.zh-CN.md +96 -0
  37. package/docs/PRODUCTION-AUDIT.zh-CN.md +89 -0
  38. package/docs/PROMOTION.md +116 -0
  39. package/docs/UPGRADE-ROADMAP.zh-CN.md +47 -0
  40. package/docs/demo-output/security-report.md +57 -0
  41. package/docs/superpowers/plans/2026-06-29-ci-dogfooding.md +200 -0
  42. package/package.json +21 -0
@@ -0,0 +1,53 @@
1
+ # Tool Router
2
+
3
+ Use this reference to choose checks dynamically. Run only tools that match the repository and the user's scope.
4
+
5
+ ## Selection Order
6
+
7
+ 1. Inspect the project with `scripts/probe-project.mjs`.
8
+ 2. Use installed Codex skills for security review when available.
9
+ 3. Use local CLI tools that are already installed.
10
+ 4. Use package-manager or containerized tools only when the repo already uses them or the user approves.
11
+ 5. Fall back to manual review with explicit coverage gaps.
12
+
13
+ ## Core Routing Table
14
+
15
+ | Surface | Signals | Primary tools | Notes |
16
+ | --- | --- | --- | --- |
17
+ | Code security | Auth, API routes, serializers, file upload, HTTP clients, SQL construction, crypto | `codex-security`, Semgrep, CodeQL, Open Code Review | Prioritize changed files and dataflow into sinks. |
18
+ | Secrets | `.env`, private keys, token-like config, CI variables, committed credentials | Gitleaks, Trivy secret scan | Do not print secret values. Report only location and type. |
19
+ | Dependencies | Lockfiles, Docker images, SBOM, package manifests | Trivy, OSV-Scanner, native package audit | Separate reachable production risk from dev-only noise. |
20
+ | Database migrations | `migrations/`, `db/migrate`, Prisma, Drizzle, Alembic, Flyway, Liquibase, raw SQL | Squawk, Atlas, Bytebase, pgroll, strong_migrations, gh-ost, pt-online-schema-change | Review lock behavior and rollback before correctness polish. |
21
+ | IaC and cloud | Terraform, CloudFormation, Pulumi, Helm, Kubernetes YAML, Docker Compose | Checkov, Trivy config, Conftest/OPA, Kubescape | Focus on public exposure, IAM, network policy, privileged runtime, and secret mounts. |
22
+ | Containers | Dockerfile, compose, image references, deploy manifests | Trivy image/config, Hadolint, Docker build checks | Check root user, privileged mode, writable filesystem, and unpinned base images. |
23
+ | Kubernetes runtime | K8s manifests, `kubectl` context, incident request | k8sgpt, HolmesGPT, Kubescape, Falco, Cilium/Tetragon | Default to read-only commands. |
24
+ | Web/API DAST | Staging URL explicitly provided by user | OWASP ZAP, Nuclei | Require explicit scope for active scans. Never infer internet targets. |
25
+ | Incident response | Alerts, logs, metrics, traces, rollout history, deploys, migrations | HolmesGPT, OpenSRE, k8sgpt, kubectl, observability CLIs/APIs | Build a timeline before proposing fixes. |
26
+
27
+ ## Command Patterns
28
+
29
+ Use these as patterns, adapting paths and package managers to the repo.
30
+
31
+ ```bash
32
+ semgrep scan --config auto
33
+ trivy fs --scanners vuln,secret,misconfig .
34
+ gitleaks detect --source . --redact
35
+ checkov -d .
36
+ squawk path/to/migration.sql
37
+ atlas migrate lint --dir "file://migrations"
38
+ kubescape scan
39
+ k8sgpt analyze
40
+ ```
41
+
42
+ Use CodeQL when a CodeQL database or GitHub code scanning setup already exists, or when the user asks for deeper security analysis.
43
+
44
+ ## Missing Tool Handling
45
+
46
+ When a useful tool is unavailable, write a short gap:
47
+
48
+ ```text
49
+ Unavailable: squawk. Coverage gap: Postgres migration lock-risk linting was not run.
50
+ Smallest next guardrail: add squawk to CI for changed `.sql` migration files.
51
+ ```
52
+
53
+ Do not turn the review into an installation project unless the user asks for setup.
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { detectProject } from "./lib/project-detect.mjs";
6
+ import { hasConfiguredEvidence, loadIntake } from "./lib/intake.mjs";
7
+
8
+ function item(id, title, status, summary, recommendation = "") {
9
+ return { id, title, status, summary, recommendation };
10
+ }
11
+
12
+ function realBusinessFlows(intake) {
13
+ return (intake.businessFlows.business_flows || []).filter((flow) => flow && !flow.template && flow.id);
14
+ }
15
+
16
+ function flowTests(flows) {
17
+ return flows.flatMap((flow) => (Array.isArray(flow.tests) ? flow.tests : []));
18
+ }
19
+
20
+ export function buildAuditPlan(project, intake = loadIntake(project.root, project)) {
21
+ const profile = {
22
+ projectType: intake.profile.derived.projectType,
23
+ hasDatabase: intake.profile.derived.hasDatabase,
24
+ hasCi: intake.profile.derived.hasCi,
25
+ hasElectron: intake.profile.derived.hasElectron,
26
+ hasDeployment: intake.profile.derived.hasDeployment,
27
+ handlesAuth: Boolean(intake.profile.risk?.handles_auth),
28
+ handlesSensitiveData: Boolean(intake.profile.risk?.handles_sensitive_data),
29
+ handlesFinancialData: Boolean(intake.profile.risk?.handles_financial_data),
30
+ };
31
+ const evidence = intake.evidence.evidence || {};
32
+ const deployment = evidence.deployment || {};
33
+ const observability = evidence.observability || {};
34
+ const database = evidence.database || {};
35
+ const flows = realBusinessFlows(intake);
36
+ const tests = flowTests(flows);
37
+ const plan = [];
38
+
39
+ plan.push(
40
+ intake.initialized
41
+ ? item("intake-config", "Production audit intake", "PASS", "Project profile and evidence source templates are present.")
42
+ : item("intake-config", "Production audit intake", "GAP", "Production audit intake has not been initialized.", "Run init-audit and fill the generated templates."),
43
+ );
44
+
45
+ plan.push(
46
+ flows.length
47
+ ? item("business-critical-flows", "Critical business flows", "PASS", `${flows.length} critical flow(s) declared.`)
48
+ : item("business-critical-flows", "Critical business flows", "USER_DECISION", "The maintainer must declare the business flows that must not break.", "Fill .ai-maintainer/business-flows.yml with real flows."),
49
+ );
50
+
51
+ plan.push(
52
+ flows.length && tests.length === 0
53
+ ? item("business-flow-tests", "Business flow tests", "GAP", "Critical flows are declared without linked automated tests.", "Add tests for each critical flow and list them in business-flows.yml.")
54
+ : item("business-flow-tests", "Business flow tests", flows.length ? "PASS" : "USER_DECISION", flows.length ? `${tests.length} test reference(s) declared.` : "Declare flows before test coverage can be judged."),
55
+ );
56
+
57
+ plan.push(
58
+ profile.hasElectron
59
+ ? item("electron-security", "Electron security review", "PASS", "Electron surface detected; IPC, preload, file permission, and update trust checks are enabled.")
60
+ : item("electron-security", "Electron security review", "N/A", "No Electron surface detected."),
61
+ );
62
+
63
+ plan.push(
64
+ profile.hasCi
65
+ ? item("ci-security", "CI security review", "PASS", "CI workflow evidence detected; actionlint and zizmor checks are applicable.")
66
+ : item("ci-security", "CI security review", "GAP", "No GitHub Actions workflow evidence detected.", "Add CI or document another release gate in evidence-sources.yml."),
67
+ );
68
+
69
+ plan.push(
70
+ profile.hasDeployment || deployment.has_production
71
+ ? item("release-approval", "Production release approval", deployment.production_requires_approval ? "PASS" : "GAP", deployment.production_requires_approval ? "Production approval evidence declared." : "Production deployment exists without approval evidence.", "Use GitHub Environments or document the approval gate.")
72
+ : item("release-approval", "Production release approval", "GAP", "No production deployment evidence declared.", "Declare deployment provider and approval status in evidence-sources.yml."),
73
+ );
74
+
75
+ for (const [id, title, key] of [
76
+ ["observability-errors", "Error monitoring", "errors"],
77
+ ["observability-logs", "Production logs", "logs"],
78
+ ["observability-metrics", "Production metrics", "metrics"],
79
+ ["observability-alerts", "Production alerts", "alerts"],
80
+ ]) {
81
+ plan.push(
82
+ hasConfiguredEvidence(observability[key])
83
+ ? item(id, title, "PASS", `${title} evidence declared.`)
84
+ : item(id, title, "GAP", `${title} evidence is missing.`, `Declare ${key} evidence in evidence-sources.yml.`),
85
+ );
86
+ }
87
+
88
+ if (profile.hasDatabase) {
89
+ plan.push(item("database-migrations", "Database migration review", hasConfiguredEvidence(database.review_tool) ? "PASS" : "GAP", hasConfiguredEvidence(database.review_tool) ? "Database review tool evidence declared." : "Database surface detected without migration review tool evidence.", "Use Atlas, Bytebase, Squawk, or document manual migration review."));
90
+ plan.push(item("database-backup", "Database backup evidence", hasConfiguredEvidence(database.backup_policy) ? "PASS" : "GAP", hasConfiguredEvidence(database.backup_policy) ? "Backup policy evidence declared." : "Database backup evidence is missing.", "Document backup policy before production migration."));
91
+ plan.push(item("database-rollback", "Database rollback or forward-fix plan", hasConfiguredEvidence(database.rollback_plan) ? "PASS" : "GAP", hasConfiguredEvidence(database.rollback_plan) ? "Rollback or forward-fix evidence declared." : "Database rollback or forward-fix evidence is missing.", "Document rollback or forward-fix strategy."));
92
+ } else {
93
+ plan.push(item("database-migrations", "Database migration review", "N/A", "No database surface detected or declared."));
94
+ plan.push(item("database-backup", "Database backup evidence", "N/A", "No database surface detected or declared."));
95
+ plan.push(item("database-rollback", "Database rollback or forward-fix plan", "N/A", "No database surface detected or declared."));
96
+ }
97
+
98
+ if (intake.parseErrors.length) {
99
+ for (const error of intake.parseErrors) {
100
+ plan.push(item(`intake-parse-${error.path}`, "Intake YAML parse error", "GAP", `${error.path}: ${error.reason}`, "Fix malformed YAML so the audit can use maintainer-supplied evidence."));
101
+ }
102
+ }
103
+
104
+ return {
105
+ schemaVersion: 1,
106
+ generatedAt: new Date().toISOString(),
107
+ root: project.root,
108
+ profile,
109
+ plan,
110
+ evidence: plan.filter((entry) => entry.status === "PASS"),
111
+ coverageGaps: plan.filter((entry) => entry.status === "GAP"),
112
+ userDecisions: plan.filter((entry) => entry.status === "USER_DECISION"),
113
+ };
114
+ }
115
+
116
+ function resolveOutputPath(root, outputPath) {
117
+ if (!outputPath) return null;
118
+ return path.isAbsolute(outputPath) ? outputPath : path.resolve(root, outputPath);
119
+ }
120
+
121
+ export function runAuditPlan(projectRoot, options = {}) {
122
+ const root = path.resolve(projectRoot || process.cwd());
123
+ const project = detectProject(root);
124
+ const audit = buildAuditPlan(project, loadIntake(root, project));
125
+ const outputPath = resolveOutputPath(root, options.outputPath);
126
+ if (outputPath) {
127
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
128
+ fs.writeFileSync(outputPath, JSON.stringify(audit, null, 2));
129
+ }
130
+ return audit;
131
+ }
132
+
133
+ function parseArgs(args) {
134
+ const positional = [];
135
+ let outputPath = null;
136
+ let jsonOnly = false;
137
+ for (let i = 0; i < args.length; i += 1) {
138
+ const arg = args[i];
139
+ if (arg === "--output") outputPath = args[++i];
140
+ else if (arg.startsWith("--output=")) outputPath = arg.slice("--output=".length);
141
+ else if (arg === "--json") jsonOnly = true;
142
+ else if (!arg.startsWith("--")) positional.push(arg);
143
+ }
144
+ return { projectRoot: positional[0] || process.cwd(), outputPath, jsonOnly };
145
+ }
146
+
147
+ function main() {
148
+ const args = parseArgs(process.argv.slice(2));
149
+ const audit = runAuditPlan(args.projectRoot, { outputPath: args.outputPath });
150
+ console.log(JSON.stringify(audit, null, 2));
151
+ }
152
+
153
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
154
+ main();
155
+ }
@@ -0,0 +1,109 @@
1
+ param(
2
+ [string[]]$Tools = @("gitleaks", "trivy", "squawk"),
3
+ [string]$InstallDir = "$env:USERPROFILE\.codex\security-tools",
4
+ [switch]$AddToPath
5
+ )
6
+
7
+ $ErrorActionPreference = "Stop"
8
+ $binDir = Join-Path $InstallDir "bin"
9
+ New-Item -ItemType Directory -Force -Path $binDir | Out-Null
10
+ $requestedTools = @()
11
+ foreach ($item in $Tools) {
12
+ $requestedTools += ($item -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
13
+ }
14
+
15
+ function Write-Step($Message) {
16
+ Write-Host "==> $Message"
17
+ }
18
+
19
+ function Test-Command($Name) {
20
+ $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
21
+ }
22
+
23
+ function Get-Asset($Repo, [string[]]$Patterns) {
24
+ $release = Invoke-RestMethod -Headers @{ "User-Agent" = "ai-project-maintainer" } -Uri "https://api.github.com/repos/$Repo/releases/latest"
25
+ foreach ($pattern in $Patterns) {
26
+ $asset = $release.assets | Where-Object { $_.name -match $pattern -and $_.name -match '\.zip$' } | Select-Object -First 1
27
+ if ($asset) { return $asset }
28
+ }
29
+ throw "No Windows zip asset matched $($Patterns -join ', ') for $Repo"
30
+ }
31
+
32
+ function Install-FromGitHubZip($Name, $Repo, [string[]]$Patterns, $ExeName) {
33
+ if (Test-Command $ExeName) {
34
+ Write-Step "$Name already available on PATH"
35
+ return
36
+ }
37
+
38
+ $asset = Get-Asset -Repo $Repo -Patterns $Patterns
39
+ $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("$Name-" + [guid]::NewGuid())
40
+ $zip = Join-Path $tmp $asset.name
41
+ New-Item -ItemType Directory -Force -Path $tmp | Out-Null
42
+
43
+ Write-Step "Downloading $Name from $Repo ($($asset.name))"
44
+ Invoke-WebRequest -Headers @{ "User-Agent" = "ai-project-maintainer" } -Uri $asset.browser_download_url -OutFile $zip
45
+ Expand-Archive -LiteralPath $zip -DestinationPath $tmp -Force
46
+
47
+ $exe = Get-ChildItem -Path $tmp -Recurse -File -Filter "$ExeName.exe" | Select-Object -First 1
48
+ if (-not $exe) { throw "Downloaded $Name but could not find $ExeName.exe in archive" }
49
+
50
+ Copy-Item -LiteralPath $exe.FullName -Destination (Join-Path $binDir "$ExeName.exe") -Force
51
+ Write-Step "Installed $Name to $binDir"
52
+ }
53
+
54
+ function Install-WithUvTool($Name, $Package, $ExeName) {
55
+ if (Test-Command $ExeName) {
56
+ Write-Step "$Name already available on PATH"
57
+ return
58
+ }
59
+ if (-not (Test-Command "uv")) {
60
+ Write-Warning "uv is not installed; cannot install $Name locally. Install uv, Python, or Docker and rerun."
61
+ return
62
+ }
63
+ Write-Step "Installing $Name with uv tool install $Package"
64
+ & uv tool install $Package
65
+ if ($LASTEXITCODE -ne 0) {
66
+ throw "uv tool install $Package failed with exit code $LASTEXITCODE"
67
+ }
68
+ }
69
+
70
+ foreach ($tool in $requestedTools) {
71
+ try {
72
+ switch ($tool.ToLowerInvariant()) {
73
+ "gitleaks" {
74
+ Install-FromGitHubZip -Name "gitleaks" -Repo "gitleaks/gitleaks" -Patterns @("windows.*x64", "windows.*64") -ExeName "gitleaks"
75
+ }
76
+ "trivy" {
77
+ Install-FromGitHubZip -Name "trivy" -Repo "aquasecurity/trivy" -Patterns @("windows-64bit", "windows.*64") -ExeName "trivy"
78
+ }
79
+ "squawk" {
80
+ Install-FromGitHubZip -Name "squawk" -Repo "sbdchd/squawk" -Patterns @("windows.*x86_64", "windows.*amd64", "windows.*64") -ExeName "squawk"
81
+ }
82
+ "semgrep" {
83
+ Install-WithUvTool -Name "semgrep" -Package "semgrep" -ExeName "semgrep"
84
+ }
85
+ "checkov" {
86
+ Install-WithUvTool -Name "checkov" -Package "checkov" -ExeName "checkov"
87
+ }
88
+ default {
89
+ Write-Warning "Unknown local bootstrap tool '$tool'. Supported: gitleaks, trivy, squawk, semgrep, checkov."
90
+ }
91
+ }
92
+ } catch {
93
+ Write-Warning "Could not install ${tool}: $($_.Exception.Message)"
94
+ }
95
+ }
96
+
97
+ if ($AddToPath) {
98
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
99
+ $parts = $current -split ';' | Where-Object { $_ }
100
+ if ($parts -notcontains $binDir) {
101
+ [Environment]::SetEnvironmentVariable("Path", ($parts + $binDir -join ';'), "User")
102
+ Write-Step "Added $binDir to the user PATH. Restart terminals/Codex to pick it up."
103
+ } else {
104
+ Write-Step "$binDir is already in the user PATH"
105
+ }
106
+ }
107
+
108
+ Write-Step "Installed tools:"
109
+ Get-ChildItem -File -LiteralPath $binDir | Select-Object Name,Length,LastWriteTime
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ const targetDirs = [
9
+ path.join(repoRoot, "ai-project-maintainer", "scripts"),
10
+ path.join(repoRoot, "test"),
11
+ ];
12
+
13
+ function collectMjsFiles(dir, out = []) {
14
+ if (!fs.existsSync(dir)) return out;
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ const full = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) collectMjsFiles(full, out);
18
+ else if (entry.isFile() && entry.name.endsWith(".mjs")) out.push(full);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ const files = targetDirs.flatMap((dir) => collectMjsFiles(dir));
24
+ let failed = false;
25
+
26
+ for (const file of files) {
27
+ const result = spawnSync(process.execPath, ["--check", file], {
28
+ cwd: repoRoot,
29
+ encoding: "utf8",
30
+ });
31
+ if (result.status !== 0) {
32
+ failed = true;
33
+ process.stderr.write(result.stderr || result.stdout);
34
+ }
35
+ }
36
+
37
+ if (!failed) {
38
+ console.log(`Syntax check passed for ${files.length} files.`);
39
+ }
40
+
41
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { runLocalGate } from "./run-local-gate.mjs";
4
+
5
+ const projectRoot = path.resolve(process.argv[2] || process.cwd());
6
+ const outputPath = process.argv[3] || "reports/security-report.json";
7
+
8
+ const report = runLocalGate(projectRoot, {
9
+ noTests: true,
10
+ outputPath,
11
+ writeReports: true,
12
+ runnerOptions: {
13
+ envPath: "",
14
+ },
15
+ });
16
+
17
+ const summary = {
18
+ status: report.passed ? "PASS" : "FAIL",
19
+ blockers: report.summary?.blockers ?? 0,
20
+ warnings: report.summary?.warnings ?? 0,
21
+ coverageGaps: report.summary?.coverageGaps ?? 0,
22
+ outputPath,
23
+ };
24
+
25
+ console.log(JSON.stringify(summary, null, 2));
26
+ process.exit(report.passed ? 0 : 1);
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { runAuditPlan } from "./audit-plan.mjs";
5
+ import { runDoctor } from "./doctor.mjs";
6
+ import { initAudit } from "./init-audit.mjs";
7
+ import { initProject } from "./init-project.mjs";
8
+ import { runLocalGate } from "./run-local-gate.mjs";
9
+ import { summarizeReport } from "./report-summary.mjs";
10
+ import { toMarkdown } from "./lib/report.mjs";
11
+
12
+ function readOption(args, name, fallback = null) {
13
+ const index = args.indexOf(name);
14
+ if (index === -1) {
15
+ const inline = args.find((arg) => arg.startsWith(`${name}=`));
16
+ return inline ? inline.slice(name.length + 1) : fallback;
17
+ }
18
+ return args[index + 1] || fallback;
19
+ }
20
+
21
+ export function parseCliArgs(argv) {
22
+ const [command = "help", ...rest] = argv;
23
+
24
+ if (command === "doctor") {
25
+ return {
26
+ command,
27
+ args: {
28
+ jsonOnly: rest.includes("--json"),
29
+ checkTrivyDb: !rest.includes("--no-trivy-db"),
30
+ },
31
+ };
32
+ }
33
+
34
+ if (command === "init") {
35
+ return {
36
+ command,
37
+ args: {
38
+ projectRoot: rest.find((arg) => !arg.startsWith("--")) || process.cwd(),
39
+ profile: readOption(rest, "--profile", "oss"),
40
+ ci: readOption(rest, "--ci", "github"),
41
+ preCommit: rest.includes("--pre-commit"),
42
+ },
43
+ };
44
+ }
45
+
46
+ if (command === "gate") {
47
+ return {
48
+ command,
49
+ args: {
50
+ projectRoot: rest.find((arg) => !arg.startsWith("--")) || process.cwd(),
51
+ strict: rest.includes("--strict"),
52
+ release: rest.includes("--release"),
53
+ noTests: rest.includes("--no-tests"),
54
+ jsonOnly: rest.includes("--json"),
55
+ production: rest.includes("--production"),
56
+ outputPath: readOption(rest, "--output", null),
57
+ },
58
+ };
59
+ }
60
+
61
+ if (command === "init-audit") {
62
+ return {
63
+ command,
64
+ args: {
65
+ projectRoot: rest.find((arg) => !arg.startsWith("--")) || process.cwd(),
66
+ },
67
+ };
68
+ }
69
+
70
+ if (command === "audit-plan") {
71
+ return {
72
+ command,
73
+ args: {
74
+ projectRoot: rest.find((arg) => !arg.startsWith("--")) || process.cwd(),
75
+ outputPath: readOption(rest, "--output", null),
76
+ jsonOnly: rest.includes("--json"),
77
+ },
78
+ };
79
+ }
80
+
81
+ if (command === "summary") {
82
+ return {
83
+ command,
84
+ args: {
85
+ reportPath: rest.find((arg) => !arg.startsWith("--")),
86
+ },
87
+ };
88
+ }
89
+
90
+ return { command: "help", args: {} };
91
+ }
92
+
93
+ function doctorToMarkdown(report) {
94
+ const lines = [];
95
+ lines.push(`# AI Project Maintainer Doctor: ${report.passed ? "PASS" : "WARN"}`);
96
+ lines.push("");
97
+ lines.push(`- node: ${report.node.version}`);
98
+ lines.push("");
99
+ lines.push("## Required Scanners");
100
+ for (const tool of report.required) lines.push(`- ${tool.name}: ${tool.status}${tool.version ? ` (${tool.version})` : ""}`);
101
+ lines.push("");
102
+ lines.push("## Optional Scanners");
103
+ for (const tool of report.optional) lines.push(`- ${tool.name}: ${tool.status}${tool.version ? ` (${tool.version})` : ""}`);
104
+ lines.push("");
105
+ lines.push("## Trivy DB");
106
+ lines.push(`- ${report.trivyDb.status}: ${report.trivyDb.summary}`);
107
+ return lines.join("\n");
108
+ }
109
+
110
+ export function runCli(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
111
+ const parsed = parseCliArgs(argv);
112
+
113
+ if (parsed.command === "doctor") {
114
+ const report = runDoctor({ checkTrivyDb: parsed.args.checkTrivyDb });
115
+ io.stdout.write(`${parsed.args.jsonOnly ? JSON.stringify(report, null, 2) : doctorToMarkdown(report)}\n`);
116
+ return report.passed ? 0 : 1;
117
+ }
118
+
119
+ if (parsed.command === "init") {
120
+ const result = initProject(parsed.args.projectRoot, {
121
+ profile: parsed.args.profile,
122
+ ci: parsed.args.ci,
123
+ preCommit: parsed.args.preCommit,
124
+ });
125
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
126
+ return 0;
127
+ }
128
+
129
+ if (parsed.command === "gate") {
130
+ const report = runLocalGate(parsed.args.projectRoot, {
131
+ strict: parsed.args.strict,
132
+ release: parsed.args.release,
133
+ noTests: parsed.args.noTests,
134
+ production: parsed.args.production,
135
+ outputPath: parsed.args.outputPath,
136
+ writeReports: true,
137
+ });
138
+ io.stdout.write(`${parsed.args.jsonOnly ? JSON.stringify(report, null, 2) : toMarkdown(report)}\n`);
139
+ return report.passed ? 0 : 1;
140
+ }
141
+
142
+ if (parsed.command === "init-audit") {
143
+ const result = initAudit(parsed.args.projectRoot);
144
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
145
+ return 0;
146
+ }
147
+
148
+ if (parsed.command === "audit-plan") {
149
+ const audit = runAuditPlan(parsed.args.projectRoot, { outputPath: parsed.args.outputPath });
150
+ io.stdout.write(`${JSON.stringify(audit, null, 2)}\n`);
151
+ return 0;
152
+ }
153
+
154
+ if (parsed.command === "summary" && parsed.args.reportPath) {
155
+ io.stdout.write(`${summarizeReport(parsed.args.reportPath)}\n`);
156
+ return 0;
157
+ }
158
+
159
+ io.stderr.write("Usage: ai-project-maintainer <doctor|init|init-audit|audit-plan|gate|summary> [options]\n");
160
+ return 2;
161
+ }
162
+
163
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
164
+ process.exit(runCli());
165
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { commandPath, runCommand } from "./lib/command-runner.mjs";
5
+
6
+ const coreTools = ["git", "npm", "pnpm", "yarn", "bun"];
7
+ const requiredScanners = ["gitleaks", "trivy", "semgrep"];
8
+ const optionalScanners = ["osv-scanner", "actionlint", "zizmor", "syft", "grype", "checkov", "squawk", "scorecard", "pre-commit", "mega-linter-runner"];
9
+
10
+ function inspectTool(name, options) {
11
+ const resolved = commandPath(name, options.runnerOptions || {});
12
+ if (!resolved) return { name, status: "missing", path: null, version: null };
13
+ const result = runCommand(name, ["--version"], { ...(options.runnerOptions || {}), timeoutMs: 30 * 1000 });
14
+ return {
15
+ name,
16
+ status: result.status === "pass" ? "available" : "error",
17
+ path: resolved,
18
+ version: `${result.stdout}\n${result.stderr}`.trim().split(/\r?\n/).find(Boolean) || `exit ${result.code}`,
19
+ };
20
+ }
21
+
22
+ export function runDoctor(options = {}) {
23
+ const required = requiredScanners.map((tool) => inspectTool(tool, options));
24
+ const optional = optionalScanners.map((tool) => inspectTool(tool, options));
25
+ const core = coreTools.map((tool) => inspectTool(tool, options));
26
+ const trivyAvailable = required.find((tool) => tool.name === "trivy")?.status === "available";
27
+ let trivyDb = { status: "skipped", summary: trivyAvailable ? "Trivy DB check disabled by option." : "trivy is not available" };
28
+
29
+ if (trivyAvailable && options.checkTrivyDb !== false) {
30
+ const result = runCommand("trivy", ["image", "--download-db-only", "--timeout", "30s"], {
31
+ ...(options.runnerOptions || {}),
32
+ timeoutMs: 60 * 1000,
33
+ });
34
+ trivyDb = {
35
+ status: result.status === "pass" ? "available" : "error",
36
+ summary: result.status === "pass" ? "Trivy vulnerability DB can be downloaded." : `${result.stderr}\n${result.stdout}`.trim(),
37
+ };
38
+ }
39
+
40
+ return {
41
+ node: { status: "available", version: process.version },
42
+ core,
43
+ required,
44
+ optional,
45
+ trivyDb,
46
+ passed: required.every((tool) => tool.status === "available") && (trivyDb.status === "available" || trivyDb.status === "skipped"),
47
+ };
48
+ }
49
+
50
+ function toMarkdown(report) {
51
+ const lines = [];
52
+ lines.push(`# AI Project Maintainer Doctor: ${report.passed ? "PASS" : "WARN"}`);
53
+ lines.push("");
54
+ lines.push(`- node: ${report.node.version}`);
55
+ lines.push("");
56
+ lines.push("## Core");
57
+ for (const tool of report.core) lines.push(`- ${tool.name}: ${tool.status}${tool.version ? ` (${tool.version})` : ""}`);
58
+ lines.push("");
59
+ lines.push("## Required Scanners");
60
+ for (const tool of report.required) lines.push(`- ${tool.name}: ${tool.status}${tool.version ? ` (${tool.version})` : ""}`);
61
+ lines.push("");
62
+ lines.push("## Optional Scanners");
63
+ for (const tool of report.optional) lines.push(`- ${tool.name}: ${tool.status}${tool.version ? ` (${tool.version})` : ""}`);
64
+ lines.push("");
65
+ lines.push("## Trivy DB");
66
+ lines.push(`- ${report.trivyDb.status}: ${report.trivyDb.summary}`);
67
+ return lines.join("\n");
68
+ }
69
+
70
+ function main() {
71
+ const jsonOnly = process.argv.includes("--json");
72
+ const noTrivyDb = process.argv.includes("--no-trivy-db");
73
+ const report = runDoctor({ checkTrivyDb: !noTrivyDb });
74
+ console.log(jsonOnly ? JSON.stringify(report, null, 2) : toMarkdown(report));
75
+ process.exit(report.passed ? 0 : 1);
76
+ }
77
+
78
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
79
+ main();
80
+ }