ai-kit-python 1.4.2__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.
- ai_kit_python-1.4.2.dist-info/METADATA +132 -0
- ai_kit_python-1.4.2.dist-info/RECORD +278 -0
- ai_kit_python-1.4.2.dist-info/WHEEL +5 -0
- ai_kit_python-1.4.2.dist-info/entry_points.txt +2 -0
- ai_kit_python-1.4.2.dist-info/licenses/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/strix/LICENSE +201 -0
- ai_kit_python-1.4.2.dist-info/top_level.txt +1 -0
- aikit/.agents/skills/go-work/SKILL.md +8 -0
- aikit/.agents/skills/laravel-work/SKILL.md +8 -0
- aikit/.agents/skills/moodle-work/SKILL.md +8 -0
- aikit/.agents/skills/project-continuity/SKILL.md +34 -0
- aikit/.agents/skills/python-work/SKILL.md +8 -0
- aikit/.dockerignore +90 -0
- aikit/.gitattributes +3 -0
- aikit/.github/workflows/check-kit.yml +70 -0
- aikit/.github/workflows/publish-pypi.yml +108 -0
- aikit/.gitignore +60 -0
- aikit/BOOTSTRAP_PROMPT.md +18 -0
- aikit/CHANGELOG.md +85 -0
- aikit/CLAUDE.md +3 -0
- aikit/CONTRIBUTING.md +46 -0
- aikit/Dockerfile +44 -0
- aikit/KIMI.md +3 -0
- aikit/LICENSE +21 -0
- aikit/MANIFEST.in +22 -0
- aikit/MANUS.md +3 -0
- aikit/Makefile +70 -0
- aikit/README.md +1161 -0
- aikit/SECURITY.md +8 -0
- aikit/SKILL.md +105 -0
- aikit/VERSION +1 -0
- aikit/__init__.py +1 -0
- aikit/ai-kit/ADAPTERS.md +5 -0
- aikit/ai-kit/CONTAINERS.md +5 -0
- aikit/ai-kit/ENGINEERING.md +5 -0
- aikit/ai-kit/INTENT.md +5 -0
- aikit/ai-kit/MODELS.md +5 -0
- aikit/ai-kit/SKILLS.md +5 -0
- aikit/ai-kit/stacks/GO.md +5 -0
- aikit/ai-kit/stacks/LARAVEL.md +5 -0
- aikit/ai-kit/stacks/MOODLE.md +5 -0
- aikit/ai-kit/stacks/PYTHON.md +5 -0
- aikit/aikit_cli.py +59 -0
- aikit/deploy/k8s/install-preview-job.yaml +56 -0
- aikit/docker/build-requirements.txt +4 -0
- aikit/docker/check_version.py +29 -0
- aikit/docker/pypi-requirements.txt +3 -0
- aikit/docs/ADAPTER_PATH_FIX.md +23 -0
- aikit/docs/BENCHMARK.md +106 -0
- aikit/docs/CONTEXT7.md +33 -0
- aikit/docs/DECISIONS.md +22 -0
- aikit/docs/DEVELOPMENT_PLAN.md +16 -0
- aikit/docs/DIALOGUE_MEMORY.md +70 -0
- aikit/docs/DOCKER.md +74 -0
- aikit/docs/DOCKER_HUB_OVERVIEW.md +311 -0
- aikit/docs/EVALUATION.md +30 -0
- aikit/docs/IMPLEMENTATION.md +134 -0
- aikit/docs/MANUS_REVIEW.md +46 -0
- aikit/docs/PYPI.md +87 -0
- aikit/docs/PYPI_DESCRIPTION.md +103 -0
- aikit/docs/SECURITY_AUDIT.md +26 -0
- aikit/docs/STRIX.md +28 -0
- aikit/docs/V0_5_0.md +27 -0
- aikit/docs/V0_6_4.md +25 -0
- aikit/docs/V1_4_0.md +73 -0
- aikit/docs/V1_4_1.md +55 -0
- aikit/docs/V1_4_2.md +33 -0
- aikit/docs/WEB_DESIGN.md +72 -0
- aikit/docs/WINDOWS_CI_FIX.md +39 -0
- aikit/docs/adr/0000-template.md +24 -0
- aikit/docs/adr/0001-adaptive-model-routing.md +30 -0
- aikit/docs/adr/0002-template-separation.md +24 -0
- aikit/docs/adr/0003-agent-registry-and-router.md +24 -0
- aikit/docs/adr/0004-onboarding-and-project-facts.md +24 -0
- aikit/docs/adr/0005-client-settings-ownership.md +30 -0
- aikit/docs/adr/0006-routing-evidence-and-local-measurements.md +26 -0
- aikit/docs/adr/0007-generated-client-configuration-and-presets.md +34 -0
- aikit/docs/adr/0008-unified-cli-and-fact-drift.md +20 -0
- aikit/docs/adr/0009-provider-review-doctor-fix-and-ci-self-install.md +20 -0
- aikit/docs/adr/0010-metrics-analysis-and-gemini-verification.md +20 -0
- aikit/docs/adr/0011-evidence-lifecycle-and-evaluation.md +20 -0
- aikit/docs/adr/0012-measurement-led-routing.md +31 -0
- aikit/docs/adr/0013-docker-cli-image.md +24 -0
- aikit/docs/adr/0014-local-dialogue-memory.md +22 -0
- aikit/docs/history/original-bundle-checks.md +11 -0
- aikit/docs/history/v0.1-changelog.txt +37 -0
- aikit/docs/history/v0.10-changelog.md +12 -0
- aikit/docs/history/v0.2-changelog.md +35 -0
- aikit/docs/history/v0.3-changelog.md +25 -0
- aikit/docs/history/v0.4-changelog.md +31 -0
- aikit/docs/history/v0.5-changelog.md +14 -0
- aikit/docs/history/v0.6.4-changelog.md +12 -0
- aikit/docs/history/v0.6.5-v0.9-changelog.md +35 -0
- aikit/docs/history/v1.1.0-changelog.md +12 -0
- aikit/docs/history/v1.2.0-changelog.md +11 -0
- aikit/docs/history/verification-before-measurements.md +53 -0
- aikit/integrations/CONVENTIONS.md +3 -0
- aikit/integrations/README.md +7 -0
- aikit/integrations/agents.json +136 -0
- aikit/integrations/ai-kit-check.yml +66 -0
- aikit/integrations/ai-kit-cline.md +4 -0
- aikit/integrations/ai-kit-roo.md +4 -0
- aikit/integrations/ai-kit-windsurf.md +4 -0
- aikit/integrations/ai-kit.mdc +6 -0
- aikit/integrations/claude-settings.data-ask.json +23 -0
- aikit/integrations/claude-settings.git-ask.json +10 -0
- aikit/integrations/copilot-instructions.md +3 -0
- aikit/integrations/security-audit.json +135 -0
- aikit/integrations/strix.json +69 -0
- aikit/pyproject.toml +60 -0
- aikit/scripts/__init__.py +1 -0
- aikit/scripts/adapters.py +184 -0
- aikit/scripts/adaptive.py +167 -0
- aikit/scripts/adr.py +61 -0
- aikit/scripts/benchmark.py +543 -0
- aikit/scripts/changelog.py +55 -0
- aikit/scripts/check_distribution.py +178 -0
- aikit/scripts/check_kit.py +406 -0
- aikit/scripts/context.py +184 -0
- aikit/scripts/docker_test_usage.py +55 -0
- aikit/scripts/doctor.py +313 -0
- aikit/scripts/evaluate.py +125 -0
- aikit/scripts/install.py +1584 -0
- aikit/scripts/learning.py +260 -0
- aikit/scripts/memory.py +344 -0
- aikit/scripts/memory_import.py +177 -0
- aikit/scripts/memory_store.py +125 -0
- aikit/scripts/metrics.py +351 -0
- aikit/scripts/router.py +399 -0
- aikit/template/.agents/hooks/session-start.md +3 -0
- aikit/template/.agents/hooks/session-start.sh +21 -0
- aikit/template/.agents/skills/ai-kit-bootstrap/SKILL.md +14 -0
- aikit/template/.agents/skills/ai-kit-review/SKILL.md +11 -0
- aikit/template/.agents/skills/ai-kit-sync-context/SKILL.md +12 -0
- aikit/template/.agents/skills/context7-docs/SKILL.md +11 -0
- aikit/template/.agents/skills/design-extract/SKILL.md +9 -0
- aikit/template/.agents/skills/dotnet-work/SKILL.md +9 -0
- aikit/template/.agents/skills/filament-work/SKILL.md +9 -0
- aikit/template/.agents/skills/frontend-work/SKILL.md +9 -0
- aikit/template/.agents/skills/go-work/SKILL.md +9 -0
- aikit/template/.agents/skills/infra-work/SKILL.md +9 -0
- aikit/template/.agents/skills/java-work/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-blade-ui/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-work/SKILL.md +10 -0
- aikit/template/.agents/skills/library-work/SKILL.md +9 -0
- aikit/template/.agents/skills/moodle-work/SKILL.md +9 -0
- aikit/template/.agents/skills/php-work/SKILL.md +9 -0
- aikit/template/.agents/skills/project-continuity/SKILL.md +21 -0
- aikit/template/.agents/skills/python-work/SKILL.md +9 -0
- aikit/template/.agents/skills/rust-work/SKILL.md +9 -0
- aikit/template/.agents/skills/security-audit/SKILL.md +16 -0
- aikit/template/.agents/skills/security-audit/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/strix/SKILL.md +16 -0
- aikit/template/.agents/skills/strix/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/ui-review/SKILL.md +9 -0
- aikit/template/.agents/skills/web-design/SKILL.md +9 -0
- aikit/template/AGENTS.md +16 -0
- aikit/template/CHANGELOG.md +6 -0
- aikit/template/CLAUDE.md +5 -0
- aikit/template/GEMINI.md +4 -0
- aikit/template/KIMI.md +4 -0
- aikit/template/MANUS.md +4 -0
- aikit/template/PROJECT_CONTEXT.md +35 -0
- aikit/template/README.md +6 -0
- aikit/template/WIKI.md +37 -0
- aikit/template/ai-kit/ADAPTERS.md +100 -0
- aikit/template/ai-kit/BOOTSTRAP.md +15 -0
- aikit/template/ai-kit/CONTAINERS.md +63 -0
- aikit/template/ai-kit/CONTEXT7.md +96 -0
- aikit/template/ai-kit/CORE.md +23 -0
- aikit/template/ai-kit/ENGINEERING.md +34 -0
- aikit/template/ai-kit/MCP.md +17 -0
- aikit/template/ai-kit/METRICS.md +83 -0
- aikit/template/ai-kit/SECURITY-AUDIT.md +21 -0
- aikit/template/ai-kit/SECURITY.md +34 -0
- aikit/template/ai-kit/SKILLS.md +37 -0
- aikit/template/ai-kit/STRIX.md +44 -0
- aikit/template/ai-kit/UPSTREAM.md +38 -0
- aikit/template/ai-kit/WEB-DESIGN.md +37 -0
- aikit/template/ai-kit/profiles/OWNER.md +19 -0
- aikit/template/ai-kit/project.json +14 -0
- aikit/template/ai-kit/router/POLICY.md +79 -0
- aikit/template/ai-kit/router/providers/anthropic.json +44 -0
- aikit/template/ai-kit/router/providers/anthropic.md +14 -0
- aikit/template/ai-kit/router/providers/gemini.json +13 -0
- aikit/template/ai-kit/router/providers/gemini.md +10 -0
- aikit/template/ai-kit/router/providers/kimi.json +26 -0
- aikit/template/ai-kit/router/providers/kimi.md +13 -0
- aikit/template/ai-kit/router/providers/local.json +18 -0
- aikit/template/ai-kit/router/providers/local.md +10 -0
- aikit/template/ai-kit/router/providers/openai.json +48 -0
- aikit/template/ai-kit/router/providers/openai.md +14 -0
- aikit/template/ai-kit/router/routing.json +11 -0
- aikit/template/ai-kit/router/selection.json +14 -0
- aikit/template/ai-kit/settings.json +11 -0
- aikit/template/ai-kit/stacks/DOTNET.md +13 -0
- aikit/template/ai-kit/stacks/FILAMENT.md +18 -0
- aikit/template/ai-kit/stacks/FRONTEND.md +9 -0
- aikit/template/ai-kit/stacks/GO.md +19 -0
- aikit/template/ai-kit/stacks/INFRA.md +12 -0
- aikit/template/ai-kit/stacks/JAVA.md +13 -0
- aikit/template/ai-kit/stacks/LARAVEL.md +15 -0
- aikit/template/ai-kit/stacks/LIBRARY.md +9 -0
- aikit/template/ai-kit/stacks/MOODLE.md +12 -0
- aikit/template/ai-kit/stacks/PHP.md +11 -0
- aikit/template/ai-kit/stacks/PYTHON.md +11 -0
- aikit/template/ai-kit/stacks/RUST.md +18 -0
- aikit/template/ai-kit/vendor/security-audit/AI-AND-LLM.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/ATTACK-CLASSES.md +130 -0
- aikit/template/ai-kit/vendor/security-audit/CLIENT-SIDE.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
- aikit/template/ai-kit/vendor/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
- aikit/template/ai-kit/vendor/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
- aikit/template/ai-kit/vendor/security-audit/HUNTING.md +251 -0
- aikit/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- aikit/template/ai-kit/vendor/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
- aikit/template/ai-kit/vendor/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
- aikit/template/ai-kit/vendor/security-audit/RECONNAISSANCE.md +156 -0
- aikit/template/ai-kit/vendor/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
- aikit/template/ai-kit/vendor/security-audit/SKILL.md +192 -0
- aikit/template/ai-kit/vendor/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
- aikit/template/ai-kit/vendor/security-audit/VALIDATION-AND-REPORTING.md +186 -0
- aikit/template/ai-kit/vendor/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
- aikit/template/ai-kit/vendor/security-audit/report-schema.json +461 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.cjs +872 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.test.cjs +740 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.cjs +773 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.test.cjs +652 -0
- aikit/template/ai-kit/vendor/strix/LICENSE +201 -0
- aikit/template/ai-kit/vendor/strix/api-security-testing/SKILL.md +61 -0
- aikit/template/ai-kit/vendor/strix/application-security-testing/SKILL.md +66 -0
- aikit/template/ai-kit/vendor/strix/ci-security-scanning-with-strix/SKILL.md +149 -0
- aikit/template/ai-kit/vendor/strix/find-security-vulnerabilities-in-code/SKILL.md +62 -0
- aikit/template/ai-kit/vendor/strix/fix-security-vulnerabilities-with-strix/SKILL.md +77 -0
- aikit/template/ai-kit/vendor/strix/managed-pentesting-with-strix/SKILL.md +321 -0
- aikit/template/ai-kit/vendor/strix/owasp-top-10-testing/SKILL.md +64 -0
- aikit/template/ai-kit/vendor/strix/penetration-testing-with-strix/SKILL.md +162 -0
- aikit/template/ai-kit/vendor/strix/web-app-penetration-testing/SKILL.md +54 -0
- aikit/template/ai-kit/web-design/LARAVEL.md +51 -0
- aikit/template/ai-kit/web-design/LIBRARIES.md +39 -0
- aikit/template/ai-kit/web-design/REVIEW.md +37 -0
- aikit/template/ai-kit/web-design/TOOLS.md +43 -0
- aikit/template/docs/DECISIONS.md +7 -0
- aikit/template/docs/adr/0000-template.md +20 -0
- aikit/template/docs/design/ACCEPTANCE.md +24 -0
- aikit/template/docs/design/BRIEF.md +26 -0
- aikit/template/docs/design/SYSTEM.md +30 -0
- aikit/templates/agent-secrets.ignore +19 -0
- aikit/templates/gitignore.private +160 -0
- aikit/templates/gitignore.team +139 -0
- aikit/tests/build_distribution.py +73 -0
- aikit/tests/docker_smoke.py +172 -0
- aikit/tests/fixtures/create-junction.ps1 +18 -0
- aikit/tests/fixtures/golden/claude-settings-local.json +58 -0
- aikit/tests/test_adapters.py +105 -0
- aikit/tests/test_aikit.py +79 -0
- aikit/tests/test_benchmark.py +241 -0
- aikit/tests/test_check_kit.py +203 -0
- aikit/tests/test_context.py +76 -0
- aikit/tests/test_context7.py +153 -0
- aikit/tests/test_context_selection.py +70 -0
- aikit/tests/test_distribution.py +181 -0
- aikit/tests/test_docker_image.py +63 -0
- aikit/tests/test_docker_test_usage.py +54 -0
- aikit/tests/test_doctor.py +148 -0
- aikit/tests/test_evaluate.py +66 -0
- aikit/tests/test_install.py +939 -0
- aikit/tests/test_learning.py +247 -0
- aikit/tests/test_memory.py +371 -0
- aikit/tests/test_metrics.py +209 -0
- aikit/tests/test_native.py +342 -0
- aikit/tests/test_onboarding.py +301 -0
- aikit/tests/test_router.py +336 -0
- aikit/tests/test_security_audit.py +237 -0
- aikit/tests/test_strix.py +245 -0
- aikit/tests/test_web_design.py +219 -0
- aikit/tests/wheel_smoke.py +104 -0
aikit/scripts/install.py
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
"""Conservative, offline AI-KIT installation and unchanged-baseline upgrades."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
START = "# BEGIN AI-KIT MANAGED"
|
|
17
|
+
END = "# END AI-KIT MANAGED"
|
|
18
|
+
OWNED = {"README.md", "PROJECT_CONTEXT.md", "WIKI.md", "CHANGELOG.md", "docs/DECISIONS.md",
|
|
19
|
+
"docs/adr/0000-template.md", "ai-kit/project.json"}
|
|
20
|
+
AGENT_REGISTRY_PATH = "integrations/agents.json"
|
|
21
|
+
PROFILE_MANIFESTS = {
|
|
22
|
+
"GO": ("go.mod",),
|
|
23
|
+
"PYTHON": ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"),
|
|
24
|
+
"FRONTEND": ("package.json",),
|
|
25
|
+
}
|
|
26
|
+
FILAMENT_PACKAGES = {"filament/filament", "filament/forms", "filament/tables", "filament/schemas",
|
|
27
|
+
"filament/actions", "filament/widgets", "filament/infolists", "filament/notifications"}
|
|
28
|
+
EXTRAS = {"session-start", "guards", "data-guards", "native-settings", "scoped-rules", "ci", "security-audit", "strix",
|
|
29
|
+
"context7", "web-design"}
|
|
30
|
+
EXTRA_TEMPLATE = {"session-start": (".agents/hooks/session-start.md", ".agents/hooks/session-start.sh")}
|
|
31
|
+
EXTRA_INTEGRATIONS = {"ci": ("ai-kit-check.yml", ".github/workflows/ai-kit.yml")}
|
|
32
|
+
EXTRA_SETTINGS_KEYS = {"session-start": "session_start", "guards": "guards", "data-guards": "data_guards",
|
|
33
|
+
"native-settings": "native_settings", "scoped-rules": "scoped_rules", "ci": "ci",
|
|
34
|
+
"security-audit": "security_audit", "strix": "strix", "context7": "context7",
|
|
35
|
+
"web-design": "web_design"}
|
|
36
|
+
SECURITY_AUDIT_SKILL = ".agents/skills/security-audit/"
|
|
37
|
+
SECURITY_AUDIT_PACKAGE = "ai-kit/vendor/security-audit/"
|
|
38
|
+
SECURITY_AUDIT_MANIFEST = "integrations/security-audit.json"
|
|
39
|
+
STRIX_SKILLS = ("api-security-testing", "application-security-testing", "ci-security-scanning-with-strix",
|
|
40
|
+
"find-security-vulnerabilities-in-code", "fix-security-vulnerabilities-with-strix",
|
|
41
|
+
"managed-pentesting-with-strix", "owasp-top-10-testing", "penetration-testing-with-strix",
|
|
42
|
+
"web-app-penetration-testing")
|
|
43
|
+
OPTIONAL_SKILLS = {
|
|
44
|
+
"security-audit": {"repository": "https://github.com/cloudflare/security-audit-skill",
|
|
45
|
+
"source_directory": "skills/security-audit", "license": "MIT", "depth": 1,
|
|
46
|
+
"required": {"SKILL.md", "LICENSE", "RECONNAISSANCE.md", "HUNTING.md", "ATTACK-CLASSES.md",
|
|
47
|
+
"VALIDATION-AND-REPORTING.md", "report-schema.json", "validate-findings.cjs",
|
|
48
|
+
"validate-coverage-ledger.cjs"}},
|
|
49
|
+
"strix": {"repository": "https://github.com/usestrix/strix", "source_directory": "skills",
|
|
50
|
+
"license": "Apache-2.0", "depth": 2,
|
|
51
|
+
"required": {"LICENSE", *(name + "/SKILL.md" for name in STRIX_SKILLS)}},
|
|
52
|
+
}
|
|
53
|
+
# Local documentation workflows share selection/preservation behavior, without an upstream payload.
|
|
54
|
+
OPTIONAL_ENTRY_POINTS = {**{name: (name,) for name in OPTIONAL_SKILLS}, "context7": ("context7-docs",),
|
|
55
|
+
"web-design": ("web-design", "laravel-blade-ui", "ui-review", "design-extract")}
|
|
56
|
+
OPTIONAL_DOCUMENTS = {"web-design": ("docs/design/BRIEF.md", "docs/design/SYSTEM.md", "docs/design/ACCEPTANCE.md")}
|
|
57
|
+
OPTIONAL_RESOURCES = {"web-design": ("ai-kit/WEB-DESIGN.md", "ai-kit/web-design/LARAVEL.md",
|
|
58
|
+
"ai-kit/web-design/LIBRARIES.md", "ai-kit/web-design/TOOLS.md",
|
|
59
|
+
"ai-kit/web-design/REVIEW.md")}
|
|
60
|
+
# Presets set every extra they manage on or off; explicit --with-* flags add on top. CI is left
|
|
61
|
+
# to an explicit choice, as are optional integrations. A preset mode of None keeps the sharing mode.
|
|
62
|
+
PRESET_EXTRAS = ("session-start", "guards", "data-guards", "native-settings", "scoped-rules")
|
|
63
|
+
PRESETS = {
|
|
64
|
+
"minimal": {"mode": None, "extras": ()},
|
|
65
|
+
"solo": {"mode": "private", "extras": ("session-start", "native-settings", "scoped-rules")},
|
|
66
|
+
"team": {"mode": "team", "extras": ("session-start", "guards", "native-settings", "scoped-rules")},
|
|
67
|
+
"strict": {"mode": None, "extras": PRESET_EXTRAS},
|
|
68
|
+
}
|
|
69
|
+
CLAUDE_SETTINGS = ".claude/settings.json"
|
|
70
|
+
CLAUDE_LOCAL_SETTINGS = ".claude/settings.local.json"
|
|
71
|
+
GUARDS_SOURCE = "claude-settings.git-ask.json"
|
|
72
|
+
DATA_GUARDS_SOURCE = "claude-settings.data-ask.json"
|
|
73
|
+
SECRETS_SOURCE = "agent-secrets.ignore"
|
|
74
|
+
SCOPED_FORMATS = {"claude-paths": ".md", "cline-paths": ".md", "cursor-globs": ".mdc",
|
|
75
|
+
"copilot-applyto": ".instructions.md", "windsurf-trigger": ".md"}
|
|
76
|
+
MODULE_PATH = re.compile(r"/(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*")
|
|
77
|
+
# Recorded commands become allow rules only when they are one plain command.
|
|
78
|
+
UNSAFE_COMMAND = re.compile(r"[;&|<>`$()*\\\"'\r\n]")
|
|
79
|
+
# tr strips CR so a CRLF checkout of the hook script still runs under sh.
|
|
80
|
+
SESSION_START_COMMAND = "tr -d '\\r' < \"$CLAUDE_PROJECT_DIR/.agents/hooks/session-start.sh\" | sh"
|
|
81
|
+
# Entries written by v0.3.6-v0.4.6, which managed the whole client settings file.
|
|
82
|
+
LEGACY_CLAUDE_ENTRIES = {"permissions.deny": ["Bash(git commit *)", "Bash(git push *)"],
|
|
83
|
+
"hooks.SessionStart": ["cat .agents/hooks/session-start.md"]}
|
|
84
|
+
NODE_MANAGERS = ("npm", "pnpm", "yarn", "bun")
|
|
85
|
+
NODE_LOCKFILES = (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lock", "bun"),
|
|
86
|
+
("bun.lockb", "bun"), ("package-lock.json", "npm"), ("npm-shrinkwrap.json", "npm"))
|
|
87
|
+
PYTHON_RUNNERS = (("uv.lock", "uv run "), ("poetry.lock", "poetry run "))
|
|
88
|
+
PYTHON_TOOL_FILES = ("pyproject.toml", "setup.cfg", "tox.ini", "requirements.txt", "requirements-dev.txt",
|
|
89
|
+
"requirements_dev.txt", "dev-requirements.txt", "requirements-test.txt",
|
|
90
|
+
"test-requirements.txt")
|
|
91
|
+
NODE_IGNORES = ("node_modules/", ".npm/", ".pnpm-store/", ".yarn/cache/", ".yarn/unplugged/",
|
|
92
|
+
".yarn/install-state.gz", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*",
|
|
93
|
+
"pnpm-debug.log*", ".next/", ".nuxt/", ".svelte-kit/", ".turbo/", ".parcel-cache/")
|
|
94
|
+
PYTHON_IGNORES = (".venv/", "venv/", "__pypackages__/", "__pycache__/", "*.py[cod]", "*$py.class",
|
|
95
|
+
".pytest_cache/", ".mypy_cache/", ".ruff_cache/", ".pyre/", ".pytype/",
|
|
96
|
+
".hypothesis/", ".tox/", ".nox/", ".ipynb_checkpoints/", ".coverage", ".coverage.*",
|
|
97
|
+
"htmlcov/", "coverage.xml", "*.egg-info/", ".eggs/", "*.egg", "*.whl",
|
|
98
|
+
"pip-wheel-metadata/", "pip-log.txt", "pip-delete-this-directory.txt", ".pypirc")
|
|
99
|
+
PYTHON_ROOT_IGNORES = (".cache/pip/", ".cache/uv/", ".cache/pypoetry/")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def digest(data: bytes) -> str:
|
|
103
|
+
return hashlib.sha256(data).hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_link(path: Path) -> bool:
|
|
107
|
+
"""Recognize symlinks and Windows junctions on Python 3.10 and later."""
|
|
108
|
+
try:
|
|
109
|
+
info = path.lstat()
|
|
110
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
111
|
+
return False
|
|
112
|
+
return stat.S_ISLNK(info.st_mode) or getattr(info, "st_reparse_tag", None) in {
|
|
113
|
+
getattr(stat, "IO_REPARSE_TAG_SYMLINK", -1),
|
|
114
|
+
getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -2),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def safe_path(target: Path, relative: str, *, allow_obstructions: bool = False) -> Path:
|
|
119
|
+
"""Guard lexical/link boundaries; only read-only audits may allow file/directory obstructions."""
|
|
120
|
+
parts = relative.replace("\\", "/").split("/")
|
|
121
|
+
if not parts or any(p in {"", ".", ".."} or ":" in p for p in parts):
|
|
122
|
+
raise ValueError(f"Unsafe relative path: {relative}")
|
|
123
|
+
path = target.joinpath(*parts)
|
|
124
|
+
path.resolve().relative_to(target.resolve())
|
|
125
|
+
obstruction = None
|
|
126
|
+
for candidate in [path, *path.parents]:
|
|
127
|
+
if candidate == target.parent:
|
|
128
|
+
break
|
|
129
|
+
if is_link(candidate):
|
|
130
|
+
raise ValueError(f"Refusing linked installation path: {candidate}")
|
|
131
|
+
if candidate != path and candidate.exists() and not candidate.is_dir():
|
|
132
|
+
obstruction = candidate
|
|
133
|
+
if obstruction is not None and not allow_obstructions:
|
|
134
|
+
raise NotADirectoryError(f"Installation parent is not a directory: {obstruction}")
|
|
135
|
+
if path.exists() and not path.is_file() and not allow_obstructions:
|
|
136
|
+
raise ValueError(f"Destination is not a regular file: {relative}")
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def read_json(path: Path, default: dict) -> dict:
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return default
|
|
143
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise ValueError(f"Expected JSON object: {path}")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_agent_registry(root: Path = ROOT) -> dict:
|
|
150
|
+
"""Load and validate the data-driven agent registry (integrations/agents.json)."""
|
|
151
|
+
data = json.loads((root / AGENT_REGISTRY_PATH).read_text(encoding="utf-8"))
|
|
152
|
+
if not isinstance(data, dict) or type(data.get("schema")) is not int or data["schema"] != 1:
|
|
153
|
+
raise ValueError("Unsupported agent registry schema; expected schema 1")
|
|
154
|
+
agents = data.get("agents")
|
|
155
|
+
if not isinstance(agents, dict) or not agents:
|
|
156
|
+
raise ValueError("Agent registry must declare a nonempty agents object")
|
|
157
|
+
names: set[str] = set()
|
|
158
|
+
entries: dict[str, str] = {}
|
|
159
|
+
optional: dict[str, list[tuple[str, str]]] = {}
|
|
160
|
+
skills_copies: dict[str, str] = {}
|
|
161
|
+
detect: dict[str, list[str]] = {}
|
|
162
|
+
scoped_rules: dict[str, dict[str, str]] = {}
|
|
163
|
+
ignore_files: dict[str, str] = {}
|
|
164
|
+
metadata: dict[str, dict] = {}
|
|
165
|
+
for name, spec in agents.items():
|
|
166
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
|
|
167
|
+
raise ValueError(f"Invalid agent name: {name}")
|
|
168
|
+
if not isinstance(spec, dict):
|
|
169
|
+
raise ValueError(f"Invalid agent spec for {name}")
|
|
170
|
+
names.add(name)
|
|
171
|
+
for entry in spec.get("entries", []):
|
|
172
|
+
if not isinstance(entry, str) or not entry:
|
|
173
|
+
raise ValueError(f"Invalid entry for {name}")
|
|
174
|
+
if entry in entries:
|
|
175
|
+
raise ValueError(f"Entry owned by multiple agents: {entry}")
|
|
176
|
+
entries[entry] = name
|
|
177
|
+
for item in spec.get("optional", []):
|
|
178
|
+
if not isinstance(item, dict):
|
|
179
|
+
raise ValueError(f"Invalid optional entry for {name}")
|
|
180
|
+
source, destination = item.get("source"), item.get("destination")
|
|
181
|
+
if not isinstance(source, str) or not source or not isinstance(destination, str) or not destination:
|
|
182
|
+
raise ValueError(f"Invalid optional source/destination for {name}")
|
|
183
|
+
optional.setdefault(name, []).append((source, destination))
|
|
184
|
+
prefix = spec.get("skills_copy")
|
|
185
|
+
if prefix is not None:
|
|
186
|
+
if not isinstance(prefix, str) or not prefix.startswith(".") or not prefix.endswith("/"):
|
|
187
|
+
raise ValueError(f"Invalid skills_copy prefix for {name}")
|
|
188
|
+
skills_copies[name] = prefix
|
|
189
|
+
markers = spec.get("detect", [])
|
|
190
|
+
if not isinstance(markers, list) or any(
|
|
191
|
+
not isinstance(marker, str) or
|
|
192
|
+
not re.fullmatch(r"[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*/?", marker) or
|
|
193
|
+
any(part in {".", ".."} for part in marker.rstrip("/").split("/")) for marker in markers):
|
|
194
|
+
raise ValueError(f"Invalid detect markers for {name}")
|
|
195
|
+
if markers:
|
|
196
|
+
detect[name] = markers
|
|
197
|
+
scoped = spec.get("scoped_rules")
|
|
198
|
+
if scoped is not None:
|
|
199
|
+
directory = scoped.get("directory") if isinstance(scoped, dict) else None
|
|
200
|
+
if (not isinstance(directory, str) or not re.fullmatch(r"\.[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*/",
|
|
201
|
+
directory)
|
|
202
|
+
or ".." in directory.split("/") or scoped.get("format") not in SCOPED_FORMATS):
|
|
203
|
+
raise ValueError(f"Invalid scoped_rules for {name}")
|
|
204
|
+
scoped_rules[name] = {"directory": directory, "format": scoped["format"]}
|
|
205
|
+
ignore_file = spec.get("ignore_file")
|
|
206
|
+
if ignore_file is not None:
|
|
207
|
+
if not isinstance(ignore_file, str) or not re.fullmatch(r"\.[A-Za-z0-9._-]+", ignore_file):
|
|
208
|
+
raise ValueError(f"Invalid ignore_file for {name}")
|
|
209
|
+
ignore_files[name] = ignore_file
|
|
210
|
+
documentation = spec.get("documentation")
|
|
211
|
+
if not isinstance(documentation, dict) or set(documentation) != {
|
|
212
|
+
"status", "verified_documentation_date", "sources", "activation"}:
|
|
213
|
+
raise ValueError(f"Invalid documentation metadata for {name}")
|
|
214
|
+
status = documentation["status"]
|
|
215
|
+
verified = documentation["verified_documentation_date"]
|
|
216
|
+
sources = documentation["sources"]
|
|
217
|
+
activation = documentation["activation"]
|
|
218
|
+
if status not in {"verified", "unverified"}:
|
|
219
|
+
raise ValueError(f"Invalid documentation status for {name}")
|
|
220
|
+
if verified is not None and (not isinstance(verified, str) or
|
|
221
|
+
not re.fullmatch(r"\d{4}-\d{2}-\d{2}", verified)):
|
|
222
|
+
raise ValueError(f"Invalid documentation date for {name}")
|
|
223
|
+
if not isinstance(sources, list) or any(not isinstance(source, str) or not source.startswith("https://")
|
|
224
|
+
for source in sources):
|
|
225
|
+
raise ValueError(f"Invalid documentation sources for {name}")
|
|
226
|
+
if activation not in {"verified", "unverified", "not-applicable"}:
|
|
227
|
+
raise ValueError(f"Invalid activation status for {name}")
|
|
228
|
+
if status == "verified" and (verified is None or not sources):
|
|
229
|
+
raise ValueError(f"Verified documentation needs date and sources for {name}")
|
|
230
|
+
metadata[name] = {"status": status, "verified_documentation_date": verified,
|
|
231
|
+
"sources": sources, "activation": activation}
|
|
232
|
+
return {"names": names, "entries": entries, "optional": optional,
|
|
233
|
+
"skills_copies": skills_copies, "detect": detect, "scoped_rules": scoped_rules,
|
|
234
|
+
"ignore_files": ignore_files, "metadata": metadata}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detect_agents(target: Path, registry: dict) -> dict[str, list[str]]:
|
|
238
|
+
"""Map clients to existing native files/directories suggesting they are in use (unverified)."""
|
|
239
|
+
found: dict[str, list[str]] = {}
|
|
240
|
+
for agent, markers in sorted(registry["detect"].items()):
|
|
241
|
+
evidence = []
|
|
242
|
+
for marker in markers:
|
|
243
|
+
path = target.joinpath(*marker.rstrip("/").split("/"))
|
|
244
|
+
if is_link(path):
|
|
245
|
+
continue
|
|
246
|
+
# A trailing slash marks a directory; other markers may be files or directories.
|
|
247
|
+
if path.is_dir() if marker.endswith("/") else path.exists():
|
|
248
|
+
evidence.append(marker)
|
|
249
|
+
if evidence:
|
|
250
|
+
found[agent] = evidence
|
|
251
|
+
return found
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def agent_selection(value: object, agents: set[str], *, unique: bool = False) -> list[str]:
|
|
255
|
+
if not isinstance(value, list) or not value or any(not isinstance(item, str) for item in value):
|
|
256
|
+
raise ValueError("Agents must be a nonempty list of supported strings")
|
|
257
|
+
if set(value) - agents:
|
|
258
|
+
raise ValueError("Unknown agent selection")
|
|
259
|
+
if unique and len(value) != len(set(value)):
|
|
260
|
+
raise ValueError("State agents must be unique")
|
|
261
|
+
return sorted(set(value))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def validate_settings(settings: dict) -> None:
|
|
265
|
+
for field in ("project_language", "chat_language", "sharing_mode", "conventions"):
|
|
266
|
+
if not isinstance(settings.get(field), str):
|
|
267
|
+
raise ValueError(f"Settings {field} must be a string")
|
|
268
|
+
if settings["project_language"] != "English":
|
|
269
|
+
raise ValueError("This distribution requires English project content")
|
|
270
|
+
chat_language = settings["chat_language"]
|
|
271
|
+
if not chat_language.strip() or len(chat_language) > 60 or any(ord(ch) < 32 for ch in chat_language):
|
|
272
|
+
raise ValueError("Chat language must be a nonempty name up to 60 characters without control characters")
|
|
273
|
+
if settings["sharing_mode"] not in {"private", "team"} or settings["conventions"] not in {"owner", "standard"}:
|
|
274
|
+
raise ValueError("Invalid sharing mode or conventions")
|
|
275
|
+
for field in EXTRA_SETTINGS_KEYS.values():
|
|
276
|
+
if field in settings and not isinstance(settings[field], bool):
|
|
277
|
+
raise ValueError(f"Settings {field} must be a boolean")
|
|
278
|
+
upstream = settings.get("upstream")
|
|
279
|
+
if upstream is not None:
|
|
280
|
+
if not isinstance(upstream, dict):
|
|
281
|
+
raise ValueError("Settings upstream must be an object or null")
|
|
282
|
+
for field in ("url", "ref", "source_directory"):
|
|
283
|
+
if field in upstream and not isinstance(upstream[field], str):
|
|
284
|
+
raise ValueError(f"Settings upstream.{field} must be a string")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def security_audit_files(root: Path = ROOT) -> dict[str, bytes]:
|
|
288
|
+
"""Return the verified Cloudflare payload (retained for existing callers)."""
|
|
289
|
+
return upstream_skill_files("security-audit", root)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def upstream_skill_files(name: str, root: Path = ROOT) -> dict[str, bytes]:
|
|
293
|
+
"""Verify a bounded upstream reference tree and return its offline install payload."""
|
|
294
|
+
spec = OPTIONAL_SKILLS[name]
|
|
295
|
+
package_prefix = "ai-kit/vendor/" + name + "/"
|
|
296
|
+
with safe_path(root, "integrations/" + name + ".json").open("rb") as stream:
|
|
297
|
+
manifest_bytes = stream.read(1024 * 1024 + 1)
|
|
298
|
+
if len(manifest_bytes) > 1024 * 1024:
|
|
299
|
+
raise ValueError(f"Oversized {name} upstream manifest")
|
|
300
|
+
manifest = json.loads(manifest_bytes)
|
|
301
|
+
if (not isinstance(manifest, dict) or type(manifest.get("schema")) is not int
|
|
302
|
+
or manifest["schema"] != 1
|
|
303
|
+
or manifest.get("repository") != spec["repository"]
|
|
304
|
+
or not isinstance(manifest.get("revision"), str)
|
|
305
|
+
or not re.fullmatch(r"[0-9a-f]{40}", manifest["revision"])
|
|
306
|
+
or manifest.get("source_directory") != spec["source_directory"]
|
|
307
|
+
or manifest.get("license") != spec["license"]):
|
|
308
|
+
raise ValueError(f"Invalid {name} upstream identity")
|
|
309
|
+
files = manifest.get("files")
|
|
310
|
+
if not isinstance(files, dict) or not spec["required"] <= files.keys() or len(files) > 100:
|
|
311
|
+
raise ValueError(f"Incomplete {name} upstream manifest")
|
|
312
|
+
prefixes: dict[str, str] = {}
|
|
313
|
+
reserved = {"con", "prn", "aux", "nul", *("com" + str(i) for i in range(1, 10)),
|
|
314
|
+
*("lpt" + str(i) for i in range(1, 10))}
|
|
315
|
+
for filename in files:
|
|
316
|
+
parts = filename.split("/") if isinstance(filename, str) else []
|
|
317
|
+
if (not parts or len(parts) > spec["depth"] or filename.casefold() == "upstream.json"
|
|
318
|
+
or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", part)
|
|
319
|
+
or part.endswith(".") or part.split(".")[0].casefold() in reserved for part in parts)):
|
|
320
|
+
raise ValueError(f"Unsafe {name} upstream filename")
|
|
321
|
+
for i in range(1, len(parts) + 1):
|
|
322
|
+
prefix = "/".join(parts[:i])
|
|
323
|
+
folded = prefix.casefold()
|
|
324
|
+
if folded in prefixes and prefixes[folded] != prefix:
|
|
325
|
+
raise ValueError(f"Unsafe {name} upstream filename alias")
|
|
326
|
+
prefixes[folded] = prefix
|
|
327
|
+
package = safe_path(root, "template/" + package_prefix + "LICENSE").parent
|
|
328
|
+
actual = set()
|
|
329
|
+
pending = [package]
|
|
330
|
+
while pending:
|
|
331
|
+
directory = pending.pop()
|
|
332
|
+
for path in directory.iterdir():
|
|
333
|
+
relative = path.relative_to(package).as_posix()
|
|
334
|
+
if is_link(path):
|
|
335
|
+
raise ValueError(f"Linked {name} upstream entry: {relative}")
|
|
336
|
+
if path.is_dir() and len(path.relative_to(package).parts) < spec["depth"]:
|
|
337
|
+
if not any(filename.startswith(relative + "/") for filename in files):
|
|
338
|
+
raise ValueError(f"{name} upstream directories differ from the manifest")
|
|
339
|
+
pending.append(path)
|
|
340
|
+
elif path.is_file():
|
|
341
|
+
actual.add(relative)
|
|
342
|
+
else:
|
|
343
|
+
raise ValueError(f"Non-regular {name} upstream entry: {relative}")
|
|
344
|
+
if actual != set(files):
|
|
345
|
+
raise ValueError(f"{name} upstream files differ from the manifest")
|
|
346
|
+
incoming: dict[str, bytes] = {}
|
|
347
|
+
total = 0
|
|
348
|
+
for filename, record in sorted(files.items()):
|
|
349
|
+
if (not isinstance(record, dict) or type(record.get("size")) is not int
|
|
350
|
+
or not 0 <= record["size"] <= 1024 * 1024
|
|
351
|
+
or record.get("git_mode") not in ("100644", "100755")
|
|
352
|
+
or any(not isinstance(record.get(field), str)
|
|
353
|
+
or not re.fullmatch(r"[0-9a-f]{" + str(length) + "}", record[field])
|
|
354
|
+
for field, length in (("sha256", 64), ("git_blob", 40)))):
|
|
355
|
+
raise ValueError(f"Invalid {name} upstream record: {filename}")
|
|
356
|
+
source = safe_path(root, "template/" + package_prefix + filename)
|
|
357
|
+
with source.open("rb") as stream:
|
|
358
|
+
data = stream.read(1024 * 1024 + 1)
|
|
359
|
+
total += len(data)
|
|
360
|
+
blob = hashlib.sha1(b"blob " + str(len(data)).encode("ascii") + b"\0" + data,
|
|
361
|
+
usedforsecurity=False).hexdigest()
|
|
362
|
+
if (total > 5 * 1024 * 1024 or len(data) != record["size"]
|
|
363
|
+
or digest(data) != record["sha256"] or blob != record["git_blob"]):
|
|
364
|
+
raise ValueError(f"{name} upstream integrity mismatch: {filename}")
|
|
365
|
+
incoming[package_prefix + filename] = data
|
|
366
|
+
incoming[package_prefix + "UPSTREAM.json"] = manifest_bytes
|
|
367
|
+
return incoming
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def validate_state(state: dict, target: Path, agents: set[str] | None = None, *, allow_obstructions: bool = False) -> None:
|
|
371
|
+
if type(state.get("schema")) is not int or state["schema"] != 1:
|
|
372
|
+
raise ValueError("Unsupported installer state schema; expected schema 1")
|
|
373
|
+
if not isinstance(state.get("accepted_version"), str) or not state["accepted_version"].strip():
|
|
374
|
+
raise ValueError("State accepted_version must be a nonempty string")
|
|
375
|
+
agent_selection(state.get("agents"), agents if agents is not None else load_agent_registry()["names"],
|
|
376
|
+
unique=True)
|
|
377
|
+
records = state.get("files")
|
|
378
|
+
if not isinstance(records, dict):
|
|
379
|
+
raise ValueError("State files must be an object")
|
|
380
|
+
for relative, record in records.items():
|
|
381
|
+
if not isinstance(relative, str) or not isinstance(record, dict):
|
|
382
|
+
raise ValueError("State files must map relative paths to objects")
|
|
383
|
+
if allow_obstructions:
|
|
384
|
+
safe_path(target, relative, allow_obstructions=True)
|
|
385
|
+
else:
|
|
386
|
+
safe_path(target, relative)
|
|
387
|
+
for field in ("installed_hash", "source_hash"):
|
|
388
|
+
value = record.get(field)
|
|
389
|
+
if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value):
|
|
390
|
+
raise ValueError(f"Invalid state {field} for {relative}")
|
|
391
|
+
if "local_adaptation" in record and type(record["local_adaptation"]) is not bool:
|
|
392
|
+
raise ValueError(f"State local_adaptation must be boolean for {relative}")
|
|
393
|
+
managed = state.get("managed_json", {})
|
|
394
|
+
if not isinstance(managed, dict):
|
|
395
|
+
raise ValueError("State managed_json must be an object")
|
|
396
|
+
for relative, entries in managed.items():
|
|
397
|
+
if not isinstance(relative, str) or not isinstance(entries, dict):
|
|
398
|
+
raise ValueError("State managed_json must map relative paths to objects")
|
|
399
|
+
if allow_obstructions:
|
|
400
|
+
safe_path(target, relative, allow_obstructions=True)
|
|
401
|
+
else:
|
|
402
|
+
safe_path(target, relative)
|
|
403
|
+
for key, values in entries.items():
|
|
404
|
+
if (not isinstance(key, str) or not re.fullmatch(r"(?:permissions|hooks)\.[A-Za-z]+", key) or
|
|
405
|
+
not isinstance(values, list) or any(not isinstance(value, str) for value in values)):
|
|
406
|
+
raise ValueError(f"Invalid state managed_json entry for {relative}")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def merge_managed_json(current: dict, remove: dict[str, list[str]], add: dict[str, list[str]]) -> dict:
|
|
410
|
+
"""Drop stale AI-KIT-owned entries and add missing ones; all other content is preserved.
|
|
411
|
+
|
|
412
|
+
Keys name a settings list: permissions.<list> holds rule strings, hooks.<event> holds
|
|
413
|
+
command hook groups identified by their command.
|
|
414
|
+
"""
|
|
415
|
+
result = json.loads(json.dumps(current))
|
|
416
|
+
for key, values in remove.items():
|
|
417
|
+
section, name = key.split(".", 1)
|
|
418
|
+
stale = [value for value in values if value not in add.get(key, [])]
|
|
419
|
+
container = result.get(section)
|
|
420
|
+
if not stale or not isinstance(container, dict) or not isinstance(container.get(name), list):
|
|
421
|
+
continue
|
|
422
|
+
items = container[name]
|
|
423
|
+
kept = []
|
|
424
|
+
for item in items:
|
|
425
|
+
if section != "hooks":
|
|
426
|
+
if item not in stale:
|
|
427
|
+
kept.append(item)
|
|
428
|
+
continue
|
|
429
|
+
handlers = item.get("hooks") if isinstance(item, dict) else None
|
|
430
|
+
if isinstance(handlers, list):
|
|
431
|
+
remaining = [h for h in handlers if not (isinstance(h, dict) and h.get("command") in stale)]
|
|
432
|
+
if len(remaining) != len(handlers):
|
|
433
|
+
if not remaining:
|
|
434
|
+
continue
|
|
435
|
+
item = {**item, "hooks": remaining}
|
|
436
|
+
kept.append(item)
|
|
437
|
+
if kept == items:
|
|
438
|
+
continue
|
|
439
|
+
container[name] = kept
|
|
440
|
+
if not kept:
|
|
441
|
+
del container[name]
|
|
442
|
+
if not container:
|
|
443
|
+
del result[section]
|
|
444
|
+
for key, values in add.items():
|
|
445
|
+
section, name = key.split(".", 1)
|
|
446
|
+
container = result.setdefault(section, {})
|
|
447
|
+
if not isinstance(container, dict):
|
|
448
|
+
raise ValueError(f"{section} must be an object")
|
|
449
|
+
items = container.setdefault(name, [])
|
|
450
|
+
if not isinstance(items, list):
|
|
451
|
+
raise ValueError(f"{key} must be a list")
|
|
452
|
+
if section == "hooks":
|
|
453
|
+
present = set()
|
|
454
|
+
for group in items:
|
|
455
|
+
handlers = group.get("hooks") if isinstance(group, dict) else None
|
|
456
|
+
for handler in handlers if isinstance(handlers, list) else []:
|
|
457
|
+
if isinstance(handler, dict):
|
|
458
|
+
present.add(handler.get("command"))
|
|
459
|
+
for value in values:
|
|
460
|
+
if value not in present:
|
|
461
|
+
items.append({"hooks": [{"type": "command", "command": value}]})
|
|
462
|
+
else:
|
|
463
|
+
for value in values:
|
|
464
|
+
if value not in items:
|
|
465
|
+
items.append(value)
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def project_modules(target: Path, facts: dict) -> tuple[list[dict], list[str]]:
|
|
470
|
+
"""Module facts for generated client configuration: project.json when filled, else detection."""
|
|
471
|
+
warnings: list[str] = []
|
|
472
|
+
source = facts["modules"]
|
|
473
|
+
path = target / "ai-kit" / "project.json"
|
|
474
|
+
if path.is_file() and not is_link(path):
|
|
475
|
+
try:
|
|
476
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
477
|
+
if isinstance(value, dict) and isinstance(value.get("modules"), list) and value["modules"]:
|
|
478
|
+
source = value["modules"]
|
|
479
|
+
except (OSError, ValueError, UnicodeError):
|
|
480
|
+
warnings.append("ai-kit/project.json is unreadable; generated client configuration uses detection.")
|
|
481
|
+
modules = []
|
|
482
|
+
for item in source:
|
|
483
|
+
module_path = item.get("path") if isinstance(item, dict) else None
|
|
484
|
+
if isinstance(module_path, str) and not module_path.endswith("/"):
|
|
485
|
+
module_path += "/"
|
|
486
|
+
if not isinstance(module_path, str) or not MODULE_PATH.fullmatch(module_path):
|
|
487
|
+
warnings.append(f"Skipping module path unsupported by generated client configuration: {module_path!r}")
|
|
488
|
+
continue
|
|
489
|
+
profiles = item.get("profiles") if isinstance(item.get("profiles"), list) else []
|
|
490
|
+
commands = item.get("commands") if isinstance(item.get("commands"), dict) else {}
|
|
491
|
+
modules.append({"path": module_path,
|
|
492
|
+
"profiles": sorted({p for p in profiles if isinstance(p, str) and re.fullmatch(r"[A-Z]+", p)}),
|
|
493
|
+
"commands": {k: v for k, v in commands.items() if isinstance(k, str) and isinstance(v, str)}})
|
|
494
|
+
return modules, warnings
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def secret_patterns(root: Path, modules: list[dict]) -> list[str]:
|
|
498
|
+
"""Gitignore-style secret patterns: the shared list plus secrets of detected stacks."""
|
|
499
|
+
text = (root / "templates" / SECRETS_SOURCE).read_text(encoding="utf-8")
|
|
500
|
+
patterns = [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
|
|
501
|
+
for module in modules:
|
|
502
|
+
prefix = module["path"][1:]
|
|
503
|
+
profiles = set(module["profiles"])
|
|
504
|
+
extra = []
|
|
505
|
+
if profiles & {"PHP", "LARAVEL", "MOODLE"}:
|
|
506
|
+
extra.append("auth.json")
|
|
507
|
+
if "LARAVEL" in profiles:
|
|
508
|
+
extra.append("/" + prefix + "storage/*.key")
|
|
509
|
+
if "MOODLE" in profiles:
|
|
510
|
+
extra.append("/" + prefix + "config.php")
|
|
511
|
+
if "PYTHON" in profiles:
|
|
512
|
+
extra.append(".pypirc")
|
|
513
|
+
patterns.extend(pattern for pattern in extra if pattern not in patterns)
|
|
514
|
+
return patterns
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def claude_read_rules(patterns: list[str]) -> list[str]:
|
|
518
|
+
"""Convert gitignore patterns to Claude Code Read rules; negations keep their order."""
|
|
519
|
+
rules = []
|
|
520
|
+
for pattern in patterns:
|
|
521
|
+
negated = pattern.startswith("!")
|
|
522
|
+
body = pattern[1:] if negated else pattern
|
|
523
|
+
if body.endswith("/"):
|
|
524
|
+
body += "**"
|
|
525
|
+
rules.append("Read(" + ("!" if negated else "") + body + ")")
|
|
526
|
+
return rules
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def claude_allow_rules(modules: list[dict]) -> list[str]:
|
|
530
|
+
rules = []
|
|
531
|
+
for module in modules:
|
|
532
|
+
for key in ("test", "lint", "build"):
|
|
533
|
+
command = module["commands"].get(key)
|
|
534
|
+
if command and command == command.strip() and not UNSAFE_COMMAND.search(command):
|
|
535
|
+
# A trailing " *" also matches the bare command.
|
|
536
|
+
rule = f"Bash({command} *)"
|
|
537
|
+
if rule not in rules:
|
|
538
|
+
rules.append(rule)
|
|
539
|
+
return rules
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def claude_managed_entries(root: Path, extras: set[str], modules: list[dict] | None = None) -> dict[str, list[str]]:
|
|
543
|
+
"""Client settings entries AI-KIT owns for the enabled extras."""
|
|
544
|
+
entries: dict[str, list[str]] = {}
|
|
545
|
+
|
|
546
|
+
def add(key: str, values: list[str]) -> None:
|
|
547
|
+
bucket = entries.setdefault(key, [])
|
|
548
|
+
bucket.extend(value for value in values if value not in bucket)
|
|
549
|
+
|
|
550
|
+
modules = modules or []
|
|
551
|
+
if "guards" in extras:
|
|
552
|
+
guards = read_json(root / "integrations" / GUARDS_SOURCE, {})
|
|
553
|
+
for kind, rules in guards.get("permissions", {}).items():
|
|
554
|
+
add("permissions." + kind, list(rules))
|
|
555
|
+
if "data-guards" in extras:
|
|
556
|
+
data_guards = read_json(root / "integrations" / DATA_GUARDS_SOURCE, {})
|
|
557
|
+
for kind, rules in data_guards.get("permissions", {}).items():
|
|
558
|
+
add("permissions." + kind, list(rules))
|
|
559
|
+
stacks = data_guards.get("stacks", {})
|
|
560
|
+
for profile in sorted({p for module in modules for p in module["profiles"]}):
|
|
561
|
+
add("permissions.ask", list(stacks.get(profile, [])))
|
|
562
|
+
if "native-settings" in extras:
|
|
563
|
+
add("permissions.allow", claude_allow_rules(modules))
|
|
564
|
+
add("permissions.deny", claude_read_rules(secret_patterns(root, modules)))
|
|
565
|
+
if "session-start" in extras:
|
|
566
|
+
entries["hooks.SessionStart"] = [SESSION_START_COMMAND]
|
|
567
|
+
return {key: values for key, values in entries.items() if values}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _module_slugs(modules: list[dict]) -> dict[str, str]:
|
|
571
|
+
slugs: dict[str, str] = {}
|
|
572
|
+
for module in modules:
|
|
573
|
+
path = module["path"]
|
|
574
|
+
slug = re.sub(r"[^a-z0-9]+", "-", path.strip("/").lower()).strip("-") or "root"
|
|
575
|
+
if slug in slugs.values():
|
|
576
|
+
slug += "-" + digest(path.encode())[:6]
|
|
577
|
+
slugs[path] = slug
|
|
578
|
+
return slugs
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def render_scoped_rule(fmt: str, relative: str, module: dict) -> bytes:
|
|
582
|
+
"""One module rule pointing to canonical profiles; the root module uses each client's always-on form."""
|
|
583
|
+
path = module["path"]
|
|
584
|
+
root_module = path == "/"
|
|
585
|
+
glob = "**" if root_module else path[1:] + "**"
|
|
586
|
+
up = "../" * relative.count("/")
|
|
587
|
+
links = ", ".join(f"[{profile}]({up}ai-kit/stacks/{profile}.md)" for profile in module["profiles"])
|
|
588
|
+
scope = "this repository" if root_module else f"files under `{path[1:]}`"
|
|
589
|
+
if fmt in {"claude-paths", "cline-paths"}:
|
|
590
|
+
header = "" if root_module else f"---\npaths:\n - {json.dumps(glob)}\n---\n\n"
|
|
591
|
+
elif fmt == "cursor-globs":
|
|
592
|
+
header = "---\nalwaysApply: true\n---\n\n" if root_module else f"---\nglobs: {glob}\nalwaysApply: false\n---\n\n"
|
|
593
|
+
elif fmt == "copilot-applyto":
|
|
594
|
+
header = f"---\napplyTo: {json.dumps(glob)}\n---\n\n"
|
|
595
|
+
else:
|
|
596
|
+
header = "---\ntrigger: always_on\n---\n\n" if root_module else f"---\ntrigger: glob\nglobs: {glob}\n---\n\n"
|
|
597
|
+
body = (f"# AI-KIT module rules: {path}\n\n"
|
|
598
|
+
"Generated by the AI-KIT installer from ai-kit/project.json; update the module facts there and rerun "
|
|
599
|
+
"the installer instead of editing this file.\n"
|
|
600
|
+
f"For {scope} apply {links} with the project's actual versions and conventions.\n")
|
|
601
|
+
if module["commands"]:
|
|
602
|
+
body += "Recorded module commands: " + "; ".join(
|
|
603
|
+
f"{key} `{value}`" for key, value in sorted(module["commands"].items())) + ".\n"
|
|
604
|
+
return (header + body).encode("utf-8")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def scoped_rule_files(root: Path, registry: dict, agents: list[str], modules: list[dict]) -> dict[str, bytes]:
|
|
608
|
+
"""Generate path-scoped rules per module for selected clients that support them."""
|
|
609
|
+
usable = []
|
|
610
|
+
for module in modules:
|
|
611
|
+
profiles = [p for p in module["profiles"] if (root / "template/ai-kit/stacks" / (p + ".md")).is_file()]
|
|
612
|
+
if profiles:
|
|
613
|
+
usable.append({**module, "profiles": profiles})
|
|
614
|
+
slugs = _module_slugs(usable)
|
|
615
|
+
files: dict[str, bytes] = {}
|
|
616
|
+
for agent in agents:
|
|
617
|
+
spec = registry["scoped_rules"].get(agent)
|
|
618
|
+
if spec is None:
|
|
619
|
+
continue
|
|
620
|
+
for module in usable:
|
|
621
|
+
relative = spec["directory"] + "ai-kit-" + slugs[module["path"]] + SCOPED_FORMATS[spec["format"]]
|
|
622
|
+
files[relative] = render_scoped_rule(spec["format"], relative, module)
|
|
623
|
+
return files
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def adapter_owner(relative: str, registry: dict) -> str | None:
|
|
627
|
+
entries = registry["entries"]
|
|
628
|
+
skills_copies = registry["skills_copies"]
|
|
629
|
+
optional = registry["optional"]
|
|
630
|
+
if relative in entries:
|
|
631
|
+
return entries[relative]
|
|
632
|
+
for agent, prefix in skills_copies.items():
|
|
633
|
+
if relative.startswith(prefix):
|
|
634
|
+
return agent
|
|
635
|
+
for agent, items in optional.items():
|
|
636
|
+
if any(destination == relative for _, destination in items):
|
|
637
|
+
return agent
|
|
638
|
+
for agent, spec in registry.get("scoped_rules", {}).items():
|
|
639
|
+
if relative.startswith(spec["directory"] + "ai-kit-"):
|
|
640
|
+
return agent
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def split_ignore(current: str) -> tuple[str, str]:
|
|
645
|
+
if current.count(START) != current.count(END) or current.count(START) > 1:
|
|
646
|
+
raise ValueError("Malformed or repeated AI-KIT ignore markers")
|
|
647
|
+
if START not in current:
|
|
648
|
+
return current, ""
|
|
649
|
+
a, b = current.index(START), current.index(END)
|
|
650
|
+
if b < a:
|
|
651
|
+
raise ValueError("Reversed AI-KIT ignore markers")
|
|
652
|
+
# Markers must occupy complete lines, avoiding matches inside unrelated comments.
|
|
653
|
+
lines = current.splitlines(keepends=True)
|
|
654
|
+
if sum(line.strip() == START for line in lines) != 1 or sum(line.strip() == END for line in lines) != 1:
|
|
655
|
+
raise ValueError("Ignore markers must occupy complete lines")
|
|
656
|
+
end = b + len(END)
|
|
657
|
+
if current[end:end + 2] == "\r\n":
|
|
658
|
+
end += 2
|
|
659
|
+
elif current[end:end + 1] == "\n":
|
|
660
|
+
end += 1
|
|
661
|
+
return current[:a], current[end:]
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def module_ignores(target: Path) -> list[str]:
|
|
665
|
+
rules = []
|
|
666
|
+
excluded = {".git", "node_modules", "vendor", "ai-kit", ".agents", ".claude", ".venv"}
|
|
667
|
+
for directory, dirs, files in os.walk(target, followlinks=False):
|
|
668
|
+
dirs[:] = [d for d in dirs if d not in excluded and not is_link(Path(directory) / d)]
|
|
669
|
+
module = Path(directory)
|
|
670
|
+
prefix = module.relative_to(target).as_posix()
|
|
671
|
+
prefix = "" if prefix == "." else prefix + "/"
|
|
672
|
+
# Deliberate Go vendoring wins over a generic Composer exclusion.
|
|
673
|
+
if "composer.json" in files and "go.mod" not in files and not (module / "vendor/modules.txt").exists():
|
|
674
|
+
rules.append("/" + prefix + "vendor/")
|
|
675
|
+
if "config-dist.php" in files and (module / "lib/moodlelib.php").is_file():
|
|
676
|
+
rules.extend(["/" + prefix + "config.php", "/" + prefix + "behat.yml"])
|
|
677
|
+
# Node/Python generated paths are scoped to modules with verified manifests.
|
|
678
|
+
if "package.json" in files:
|
|
679
|
+
rules.extend(prefix + "**/" + name for name in NODE_IGNORES)
|
|
680
|
+
if any(name in files for name in PROFILE_MANIFESTS["PYTHON"]):
|
|
681
|
+
rules.extend(prefix + "**/" + name for name in PYTHON_IGNORES)
|
|
682
|
+
rules.extend("/" + prefix + name for name in PYTHON_ROOT_IGNORES)
|
|
683
|
+
return sorted(set(rules))
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _manifest_json(path: Path) -> dict | None:
|
|
687
|
+
try:
|
|
688
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
689
|
+
except (OSError, ValueError, UnicodeError):
|
|
690
|
+
return None
|
|
691
|
+
return value if isinstance(value, dict) else None
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _go_version(path: Path) -> str | None:
|
|
695
|
+
try:
|
|
696
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
697
|
+
match = re.match(r"\s*go\s+(\d+\.\d+(?:\.\d+)?)\s*$", line)
|
|
698
|
+
if match:
|
|
699
|
+
return match.group(1)
|
|
700
|
+
except (OSError, UnicodeError):
|
|
701
|
+
pass
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _python_requires(path: Path) -> str | None:
|
|
706
|
+
try:
|
|
707
|
+
match = re.search(r'requires-python\s*=\s*"([^"]+)"', path.read_text(encoding="utf-8"))
|
|
708
|
+
except (OSError, UnicodeError):
|
|
709
|
+
return None
|
|
710
|
+
return match.group(1) if match else None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _upward(module: Path, target: Path):
|
|
714
|
+
"""Yield the module directory and its ancestors up to the project root (workspace roots)."""
|
|
715
|
+
current = module
|
|
716
|
+
while True:
|
|
717
|
+
yield current
|
|
718
|
+
if current == target or target not in current.parents:
|
|
719
|
+
return
|
|
720
|
+
current = current.parent
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _node_manager(module: Path, target: Path) -> str:
|
|
724
|
+
"""Prefer a declared packageManager, then a lockfile, searching up to the workspace root."""
|
|
725
|
+
for directory in _upward(module, target):
|
|
726
|
+
declared = (_manifest_json(directory / "package.json") or {}).get("packageManager")
|
|
727
|
+
if isinstance(declared, str) and declared.split("@", 1)[0] in NODE_MANAGERS:
|
|
728
|
+
return declared.split("@", 1)[0]
|
|
729
|
+
for lockfile, manager in NODE_LOCKFILES:
|
|
730
|
+
if (directory / lockfile).is_file():
|
|
731
|
+
return manager
|
|
732
|
+
return "npm"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _package_facts(pkg: dict, manager: str = "npm") -> dict:
|
|
736
|
+
version = None
|
|
737
|
+
engines = pkg.get("engines")
|
|
738
|
+
if isinstance(engines, dict) and isinstance(engines.get("node"), str):
|
|
739
|
+
version = engines["node"]
|
|
740
|
+
scripts = pkg.get("scripts") if isinstance(pkg.get("scripts"), dict) else {}
|
|
741
|
+
commands = {}
|
|
742
|
+
if "test" in scripts:
|
|
743
|
+
# "bun test" starts Bun's own runner rather than the package script.
|
|
744
|
+
commands["test"] = f"{manager} run test" if manager == "bun" else f"{manager} test"
|
|
745
|
+
if "build" in scripts:
|
|
746
|
+
commands["build"] = f"{manager} run build"
|
|
747
|
+
if "lint" in scripts:
|
|
748
|
+
commands["lint"] = f"{manager} run lint"
|
|
749
|
+
return {"language": "Node", "version": version, "commands": commands}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _python_commands(module: Path, target: Path, files: list[str]) -> dict:
|
|
753
|
+
"""Suggest pytest/ruff only when manifests or config files reference them."""
|
|
754
|
+
texts = []
|
|
755
|
+
for name in PYTHON_TOOL_FILES:
|
|
756
|
+
if name in files:
|
|
757
|
+
try:
|
|
758
|
+
texts.append((module / name).read_text(encoding="utf-8"))
|
|
759
|
+
except (OSError, UnicodeError):
|
|
760
|
+
pass
|
|
761
|
+
evidence = "\n".join(texts)
|
|
762
|
+
runner = next((prefix for directory in _upward(module, target)
|
|
763
|
+
for lockfile, prefix in PYTHON_RUNNERS if (directory / lockfile).is_file()), "")
|
|
764
|
+
commands = {}
|
|
765
|
+
if re.search(r"\bpytest\b", evidence) or "pytest.ini" in files or "conftest.py" in files:
|
|
766
|
+
commands["test"] = runner + "pytest"
|
|
767
|
+
if re.search(r"\bruff\b", evidence) or "ruff.toml" in files or ".ruff.toml" in files:
|
|
768
|
+
commands["lint"] = runner + "ruff check ."
|
|
769
|
+
return commands
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _composer_facts(composer: dict, *, laravel: bool) -> dict:
|
|
773
|
+
require = composer.get("require") if isinstance(composer.get("require"), dict) else {}
|
|
774
|
+
version = require.get("php") if isinstance(require.get("php"), str) else None
|
|
775
|
+
if laravel and isinstance(require.get("laravel/framework"), str):
|
|
776
|
+
version = require["laravel/framework"]
|
|
777
|
+
scripts = composer.get("scripts") if isinstance(composer.get("scripts"), dict) else {}
|
|
778
|
+
commands = {}
|
|
779
|
+
if "test" in scripts:
|
|
780
|
+
commands["test"] = "composer test"
|
|
781
|
+
if laravel:
|
|
782
|
+
commands["test"] = "php artisan test"
|
|
783
|
+
return {"language": "PHP", "version": version, "commands": commands}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _filament_manifests(module: Path, composer: dict) -> list[str]:
|
|
787
|
+
"""Suggest a profile from known first-party dependencies, never from a wrapper or directory name."""
|
|
788
|
+
evidence = []
|
|
789
|
+
for section in ("require", "require-dev"):
|
|
790
|
+
dependencies = composer.get(section, {})
|
|
791
|
+
if isinstance(dependencies, dict) and FILAMENT_PACKAGES.intersection(dependencies):
|
|
792
|
+
evidence.append("composer.json")
|
|
793
|
+
break
|
|
794
|
+
lock_path = module / "composer.lock"
|
|
795
|
+
lock = (_manifest_json(lock_path) if not is_link(lock_path) else None) or {}
|
|
796
|
+
for section in ("packages", "packages-dev"):
|
|
797
|
+
packages = lock.get(section, [])
|
|
798
|
+
if isinstance(packages, list) and any(isinstance(package, dict) and
|
|
799
|
+
isinstance(package.get("name"), str) and
|
|
800
|
+
package["name"] in FILAMENT_PACKAGES for package in packages):
|
|
801
|
+
evidence.append("composer.lock")
|
|
802
|
+
break
|
|
803
|
+
return evidence
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _cargo_facts(module: Path) -> dict:
|
|
807
|
+
version = None
|
|
808
|
+
try:
|
|
809
|
+
match = re.search(r'^\s*rust-version\s*=\s*"([^"]+)"',
|
|
810
|
+
(module / "Cargo.toml").read_text(encoding="utf-8"), re.M)
|
|
811
|
+
if match:
|
|
812
|
+
version = match.group(1)
|
|
813
|
+
except (OSError, UnicodeError):
|
|
814
|
+
pass
|
|
815
|
+
return {"language": "Rust", "version": version,
|
|
816
|
+
"commands": {"test": "cargo test", "build": "cargo build", "lint": "cargo clippy"}}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _dotnet_facts(module: Path, files: list[str]) -> dict:
|
|
820
|
+
project_file = next((name for name in sorted(files)
|
|
821
|
+
if name.endswith((".csproj", ".fsproj", ".vbproj"))), None)
|
|
822
|
+
version = None
|
|
823
|
+
if project_file:
|
|
824
|
+
try:
|
|
825
|
+
match = re.search(r"<TargetFrameworks?>([^<]+)</TargetFrameworks?>",
|
|
826
|
+
(module / project_file).read_text(encoding="utf-8"))
|
|
827
|
+
if match:
|
|
828
|
+
version = match.group(1).split(";")[0].strip()
|
|
829
|
+
except (OSError, UnicodeError):
|
|
830
|
+
pass
|
|
831
|
+
return {"language": ".NET", "version": version,
|
|
832
|
+
"commands": {"test": "dotnet test", "build": "dotnet build"}}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _java_facts(module: Path, files: list[str]) -> dict:
|
|
836
|
+
if "pom.xml" in files or "mvnw" in files:
|
|
837
|
+
version = None
|
|
838
|
+
if "pom.xml" in files:
|
|
839
|
+
try:
|
|
840
|
+
match = re.search(r"<(maven.compiler.release|java.version|maven.compiler.source)>([^<]+)</",
|
|
841
|
+
(module / "pom.xml").read_text(encoding="utf-8"))
|
|
842
|
+
if match:
|
|
843
|
+
version = match.group(2).strip()
|
|
844
|
+
except (OSError, UnicodeError):
|
|
845
|
+
pass
|
|
846
|
+
runner = "./mvnw" if "mvnw" in files else "mvn"
|
|
847
|
+
return {"language": "Java", "version": version,
|
|
848
|
+
"commands": {"test": runner + " test", "build": runner + " compile"}}
|
|
849
|
+
runner = "./gradlew" if "gradlew" in files else "gradle"
|
|
850
|
+
return {"language": "Java", "version": None,
|
|
851
|
+
"commands": {"test": runner + " test", "build": runner + " build"}}
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _infra_facts(module: Path, files: list[str]) -> tuple[list[str], dict]:
|
|
855
|
+
if "Chart.yaml" in files:
|
|
856
|
+
return ["Chart.yaml"], {"language": "Helm", "version": None,
|
|
857
|
+
"commands": {"lint": "helm lint ."}}
|
|
858
|
+
terraform = sorted(name for name in files if name.endswith(".tf"))
|
|
859
|
+
if terraform:
|
|
860
|
+
return terraform, {"language": "Terraform", "version": None,
|
|
861
|
+
"commands": {"lint": "terraform fmt -check", "test": "terraform validate"}}
|
|
862
|
+
return [], {"language": None, "version": None, "commands": {}}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def detect_facts(target: Path) -> dict:
|
|
866
|
+
"""Detect per-module manifests, language, version, and suggested commands (unverified draft)."""
|
|
867
|
+
modules: dict[str, dict] = {}
|
|
868
|
+
excluded = {".git", "node_modules", "vendor", "ai-kit", ".agents", ".claude", ".venv"}
|
|
869
|
+
for directory, dirs, files in os.walk(target, followlinks=False):
|
|
870
|
+
dirs[:] = [d for d in dirs if d not in excluded and not is_link(Path(directory) / d)]
|
|
871
|
+
module = Path(directory)
|
|
872
|
+
prefix = module.relative_to(target).as_posix()
|
|
873
|
+
prefix = "" if prefix == "." else prefix + "/"
|
|
874
|
+
facts: dict = {"path": "/" + prefix if prefix else "/", "manifests": [],
|
|
875
|
+
"language": None, "version": None, "profiles": [], "commands": {}}
|
|
876
|
+
if "go.mod" in files:
|
|
877
|
+
facts["manifests"].append("go.mod")
|
|
878
|
+
facts["profiles"].append("GO")
|
|
879
|
+
facts["language"] = "Go"
|
|
880
|
+
facts["version"] = _go_version(module / "go.mod")
|
|
881
|
+
facts["commands"].update({"test": "go test ./...", "build": "go build ./...",
|
|
882
|
+
"lint": "go vet ./..."})
|
|
883
|
+
py_manifest = next((name for name in PROFILE_MANIFESTS["PYTHON"] if name in files), None)
|
|
884
|
+
if py_manifest:
|
|
885
|
+
facts["manifests"].append(py_manifest)
|
|
886
|
+
facts["profiles"].append("PYTHON")
|
|
887
|
+
if facts["language"] is None:
|
|
888
|
+
facts["language"] = "Python"
|
|
889
|
+
if facts["version"] is None:
|
|
890
|
+
facts["version"] = _python_requires(module / py_manifest)
|
|
891
|
+
facts["commands"].update(_python_commands(module, target, files))
|
|
892
|
+
if "package.json" in files:
|
|
893
|
+
facts["manifests"].append("package.json")
|
|
894
|
+
facts["profiles"].append("FRONTEND")
|
|
895
|
+
package = _package_facts(_manifest_json(module / "package.json") or {},
|
|
896
|
+
_node_manager(module, target))
|
|
897
|
+
if facts["language"] is None:
|
|
898
|
+
facts["language"] = package["language"]
|
|
899
|
+
if facts["version"] is None:
|
|
900
|
+
facts["version"] = package["version"]
|
|
901
|
+
facts["commands"].update(package["commands"])
|
|
902
|
+
if "composer.json" in files:
|
|
903
|
+
facts["manifests"].append("composer.json")
|
|
904
|
+
facts["profiles"].append("PHP")
|
|
905
|
+
laravel = (module / "artisan").is_file()
|
|
906
|
+
if laravel:
|
|
907
|
+
facts["profiles"].append("LARAVEL")
|
|
908
|
+
manifest = _manifest_json(module / "composer.json") or {}
|
|
909
|
+
filament = _filament_manifests(module, manifest)
|
|
910
|
+
if filament:
|
|
911
|
+
facts["profiles"].append("FILAMENT")
|
|
912
|
+
if "composer.lock" in filament:
|
|
913
|
+
facts["manifests"].append("composer.lock")
|
|
914
|
+
composer = _composer_facts(manifest, laravel=laravel)
|
|
915
|
+
if facts["language"] is None:
|
|
916
|
+
facts["language"] = composer["language"]
|
|
917
|
+
if facts["version"] is None:
|
|
918
|
+
facts["version"] = composer["version"]
|
|
919
|
+
facts["commands"].update(composer["commands"])
|
|
920
|
+
if "config-dist.php" in files and (module / "lib/moodlelib.php").is_file():
|
|
921
|
+
facts["manifests"].append("config-dist.php")
|
|
922
|
+
facts["profiles"].append("MOODLE")
|
|
923
|
+
if facts["language"] is None:
|
|
924
|
+
facts["language"] = "PHP"
|
|
925
|
+
facts["commands"].setdefault("test", "vendor/bin/phpunit")
|
|
926
|
+
if "Cargo.toml" in files:
|
|
927
|
+
facts["manifests"].append("Cargo.toml")
|
|
928
|
+
facts["profiles"].append("RUST")
|
|
929
|
+
cargo = _cargo_facts(module)
|
|
930
|
+
if facts["language"] is None:
|
|
931
|
+
facts["language"] = cargo["language"]
|
|
932
|
+
if facts["version"] is None:
|
|
933
|
+
facts["version"] = cargo["version"]
|
|
934
|
+
facts["commands"].update(cargo["commands"])
|
|
935
|
+
if any(name.endswith((".csproj", ".fsproj", ".vbproj")) for name in files) or "global.json" in files:
|
|
936
|
+
project_files = [name for name in sorted(files)
|
|
937
|
+
if name.endswith((".csproj", ".fsproj", ".vbproj"))]
|
|
938
|
+
facts["manifests"].extend(project_files or ["global.json"])
|
|
939
|
+
facts["profiles"].append("DOTNET")
|
|
940
|
+
dotnet = _dotnet_facts(module, files)
|
|
941
|
+
if facts["language"] is None:
|
|
942
|
+
facts["language"] = dotnet["language"]
|
|
943
|
+
if facts["version"] is None:
|
|
944
|
+
facts["version"] = dotnet["version"]
|
|
945
|
+
facts["commands"].update(dotnet["commands"])
|
|
946
|
+
if any(name in files for name in ("pom.xml", "mvnw", "build.gradle", "build.gradle.kts", "gradlew")):
|
|
947
|
+
java_manifest = next(name for name in ("pom.xml", "mvnw", "build.gradle", "build.gradle.kts", "gradlew")
|
|
948
|
+
if name in files)
|
|
949
|
+
facts["manifests"].append(java_manifest)
|
|
950
|
+
facts["profiles"].append("JAVA")
|
|
951
|
+
java = _java_facts(module, files)
|
|
952
|
+
if facts["language"] is None:
|
|
953
|
+
facts["language"] = java["language"]
|
|
954
|
+
if facts["version"] is None:
|
|
955
|
+
facts["version"] = java["version"]
|
|
956
|
+
facts["commands"].update(java["commands"])
|
|
957
|
+
infra_manifests, infra = _infra_facts(module, files)
|
|
958
|
+
if infra_manifests:
|
|
959
|
+
facts["manifests"].extend(infra_manifests)
|
|
960
|
+
facts["profiles"].append("INFRA")
|
|
961
|
+
if facts["language"] is None:
|
|
962
|
+
facts["language"] = infra["language"]
|
|
963
|
+
facts["commands"].update(infra["commands"])
|
|
964
|
+
if facts["manifests"]:
|
|
965
|
+
facts["profiles"] = sorted(set(facts["profiles"]))
|
|
966
|
+
modules[prefix] = facts
|
|
967
|
+
return {"modules": [modules[key] for key in sorted(modules)]}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def project_fact_evidence(target: Path, facts: dict) -> dict:
|
|
971
|
+
"""Return stable, path-safe manifest digests for doctor freshness checks."""
|
|
972
|
+
modules = []
|
|
973
|
+
for module in facts["modules"]:
|
|
974
|
+
prefix = "" if module["path"] == "/" else module["path"].strip("/") + "/"
|
|
975
|
+
manifests = {}
|
|
976
|
+
for name in sorted(module["manifests"]):
|
|
977
|
+
path = safe_path(target, prefix + name)
|
|
978
|
+
if is_link(path):
|
|
979
|
+
raise ValueError("Refusing linked project manifest: " + prefix + name)
|
|
980
|
+
if path.is_file():
|
|
981
|
+
manifests[name] = digest(path.read_bytes())
|
|
982
|
+
modules.append({"path": module["path"], "manifests": manifests,
|
|
983
|
+
"profiles": list(module["profiles"]), "commands": dict(module["commands"])})
|
|
984
|
+
return {"schema": 1, "modules": modules}
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def profiles_from_facts(facts: dict) -> dict[str, list[str]]:
|
|
988
|
+
found: dict[str, set[str]] = {}
|
|
989
|
+
for module in facts["modules"]:
|
|
990
|
+
prefix = "" if module["path"] == "/" else module["path"][1:]
|
|
991
|
+
for profile in module["profiles"]:
|
|
992
|
+
found.setdefault(profile, set()).add(prefix)
|
|
993
|
+
return {profile: sorted(prefixes) for profile, prefixes in sorted(found.items())}
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def detect_profiles(target: Path) -> dict[str, list[str]]:
|
|
997
|
+
"""Map stack profiles to module prefixes evidenced by manifests (unverified draft)."""
|
|
998
|
+
return profiles_from_facts(detect_facts(target))
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def project_context_draft(data: bytes, facts: dict) -> bytes:
|
|
1002
|
+
"""Turn installer-detected manifests into draft module-map rows for a fresh context."""
|
|
1003
|
+
modules = facts["modules"]
|
|
1004
|
+
if not modules:
|
|
1005
|
+
return data
|
|
1006
|
+
text = data.decode("utf-8")
|
|
1007
|
+
placeholder = "| Not established | Not established | Not established | None confirmed | Not established |"
|
|
1008
|
+
if placeholder not in text:
|
|
1009
|
+
return data
|
|
1010
|
+
rows = []
|
|
1011
|
+
for module in modules:
|
|
1012
|
+
manifests = ", ".join(module["manifests"]) + " (installer-detected)"
|
|
1013
|
+
language = module["language"] or "not established"
|
|
1014
|
+
if module["version"]:
|
|
1015
|
+
language += " " + module["version"]
|
|
1016
|
+
language += " (suggested)"
|
|
1017
|
+
profiles = ", ".join(module["profiles"]) + " (suggested, confirm at bootstrap)"
|
|
1018
|
+
commands = module["commands"]
|
|
1019
|
+
if commands:
|
|
1020
|
+
command_text = "; ".join(f"{key}: {value}" for key, value in sorted(commands.items()))
|
|
1021
|
+
else:
|
|
1022
|
+
command_text = "not established"
|
|
1023
|
+
rows.append(f"| {module['path']} | {manifests} | {language} | {profiles} | {command_text} |")
|
|
1024
|
+
return text.replace(placeholder, "\n".join(rows), 1).encode("utf-8")
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def project_json_draft(data: bytes, facts: dict, target: Path) -> bytes:
|
|
1028
|
+
"""Fill the machine-readable project.json modules from detected manifests (unverified draft)."""
|
|
1029
|
+
if not facts["modules"]:
|
|
1030
|
+
return data
|
|
1031
|
+
value = json.loads(data.decode("utf-8"))
|
|
1032
|
+
value["modules"] = [{"path": m["path"], "manifests": m["manifests"], "language": m["language"],
|
|
1033
|
+
"version": m["version"], "profiles": m["profiles"], "commands": m["commands"]}
|
|
1034
|
+
for m in facts["modules"]]
|
|
1035
|
+
value["evidence"] = project_fact_evidence(target, facts)
|
|
1036
|
+
return (json.dumps(value, indent=2) + "\n").encode("utf-8")
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def merge_ignore(current: str, policy: str, extra: list[str]) -> tuple[str, str]:
|
|
1040
|
+
before, after = split_ignore(current)
|
|
1041
|
+
outside = before + after
|
|
1042
|
+
block = START + "\n" + policy.rstrip() + "\n"
|
|
1043
|
+
if extra:
|
|
1044
|
+
block += "\n# Verified module-specific generated paths\n" + "\n".join(extra) + "\n"
|
|
1045
|
+
block += END + "\n"
|
|
1046
|
+
if before and not before.endswith(("\n", "\r")):
|
|
1047
|
+
before += "\n"
|
|
1048
|
+
return before + block + after, outside
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def project_documents(settings: dict) -> set[str]:
|
|
1052
|
+
"""Project-owned documents are created once, including those of enabled modules."""
|
|
1053
|
+
return OWNED | {path for extra, paths in OPTIONAL_DOCUMENTS.items()
|
|
1054
|
+
if settings.get(EXTRA_SETTINGS_KEYS[extra]) is True for path in paths}
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def build_plan(target: Path, *, root: Path = ROOT, mode: str | None = None,
|
|
1058
|
+
language: str | None = None, conventions: str | None = None,
|
|
1059
|
+
agents: list[str] | None = None, accept_local: list[str] | None = None,
|
|
1060
|
+
extras: list[str] | None = None, preset: str | None = None) -> dict:
|
|
1061
|
+
if preset is not None and preset not in PRESETS:
|
|
1062
|
+
raise ValueError(f"Unknown preset: {preset}; choose one of {', '.join(sorted(PRESETS))}")
|
|
1063
|
+
target = target.absolute()
|
|
1064
|
+
if is_link(target):
|
|
1065
|
+
raise ValueError("Refusing a linked target root")
|
|
1066
|
+
target = target.resolve()
|
|
1067
|
+
if target == root.resolve() or root.resolve() in target.parents:
|
|
1068
|
+
raise ValueError("Cannot install an application kit inside the reference distribution")
|
|
1069
|
+
if target.exists() and not target.is_dir():
|
|
1070
|
+
raise ValueError("Target must be a directory")
|
|
1071
|
+
registry = load_agent_registry(root)
|
|
1072
|
+
settings_path = safe_path(target, "ai-kit/settings.json")
|
|
1073
|
+
settings = read_json(settings_path, read_json(root / "template/ai-kit/settings.json", {}))
|
|
1074
|
+
validate_settings(settings)
|
|
1075
|
+
state_path = safe_path(target, "ai-kit/.install-state.json")
|
|
1076
|
+
state = read_json(state_path, {})
|
|
1077
|
+
if state_path.exists():
|
|
1078
|
+
validate_state(state, target, registry["names"])
|
|
1079
|
+
preset_disabled = []
|
|
1080
|
+
if preset is not None:
|
|
1081
|
+
mode = mode or PRESETS[preset]["mode"]
|
|
1082
|
+
for name in PRESET_EXTRAS:
|
|
1083
|
+
key = EXTRA_SETTINGS_KEYS[name]
|
|
1084
|
+
enabled = name in PRESETS[preset]["extras"]
|
|
1085
|
+
if settings.get(key) is True and not enabled and name not in (extras or []):
|
|
1086
|
+
preset_disabled.append(name)
|
|
1087
|
+
settings[key] = enabled
|
|
1088
|
+
mode = mode or settings.get("sharing_mode", "private")
|
|
1089
|
+
language = language or settings.get("chat_language", "Russian")
|
|
1090
|
+
conventions = conventions or settings.get("conventions", "owner")
|
|
1091
|
+
explicit_agents = agents is not None
|
|
1092
|
+
agents = agent_selection(agents if agents is not None else state.get("agents", ["codex"]),
|
|
1093
|
+
registry["names"])
|
|
1094
|
+
detected_agents = detect_agents(target, registry)
|
|
1095
|
+
suggested_agents = sorted(set(agents) | set(detected_agents))
|
|
1096
|
+
if accept_local is not None and (not isinstance(accept_local, list) or
|
|
1097
|
+
any(not isinstance(path, str) for path in accept_local)):
|
|
1098
|
+
raise ValueError("Accept-local paths must be a list of strings")
|
|
1099
|
+
accept = set(accept_local or [])
|
|
1100
|
+
enabled_extras = set(extras or [])
|
|
1101
|
+
if enabled_extras - EXTRAS:
|
|
1102
|
+
raise ValueError("Unknown install extras: " + ", ".join(sorted(enabled_extras - EXTRAS)))
|
|
1103
|
+
for name, key in EXTRA_SETTINGS_KEYS.items():
|
|
1104
|
+
if name not in enabled_extras and settings.get(key) is True:
|
|
1105
|
+
enabled_extras.add(name)
|
|
1106
|
+
for name, key in EXTRA_SETTINGS_KEYS.items():
|
|
1107
|
+
if name in enabled_extras:
|
|
1108
|
+
settings[key] = True
|
|
1109
|
+
settings.update(sharing_mode=mode, chat_language=language, conventions=conventions)
|
|
1110
|
+
validate_settings(settings)
|
|
1111
|
+
owned_documents = project_documents(settings)
|
|
1112
|
+
facts = detect_facts(target)
|
|
1113
|
+
detected = profiles_from_facts(facts)
|
|
1114
|
+
modules, module_warnings = project_modules(target, facts)
|
|
1115
|
+
upstream_files = {}
|
|
1116
|
+
for name in OPTIONAL_SKILLS:
|
|
1117
|
+
if name in enabled_extras:
|
|
1118
|
+
upstream_files.update(upstream_skill_files(name, root))
|
|
1119
|
+
incoming: dict[str, bytes] = {}
|
|
1120
|
+
for src in sorted((root / "template").rglob("*")):
|
|
1121
|
+
if is_link(src):
|
|
1122
|
+
raise ValueError(f"Linked source path: {src}")
|
|
1123
|
+
if not src.is_file():
|
|
1124
|
+
continue
|
|
1125
|
+
relative = src.relative_to(root / "template").as_posix()
|
|
1126
|
+
if any(relative.startswith("ai-kit/vendor/" + name + "/") for name in OPTIONAL_SKILLS):
|
|
1127
|
+
# Only the manifest-verified bytes may enter an optional upstream payload.
|
|
1128
|
+
continue
|
|
1129
|
+
if any(relative.startswith(".agents/skills/" + skill + "/") and name not in enabled_extras
|
|
1130
|
+
for name, skills in OPTIONAL_ENTRY_POINTS.items() for skill in skills):
|
|
1131
|
+
continue
|
|
1132
|
+
if any(relative in paths and name not in enabled_extras
|
|
1133
|
+
for mapping in (OPTIONAL_RESOURCES, OPTIONAL_DOCUMENTS) for name, paths in mapping.items()):
|
|
1134
|
+
continue
|
|
1135
|
+
if relative in registry["entries"] and registry["entries"][relative] not in agents:
|
|
1136
|
+
continue
|
|
1137
|
+
if any(relative in paths for paths in EXTRA_TEMPLATE.values()):
|
|
1138
|
+
continue
|
|
1139
|
+
incoming[relative] = src.read_bytes()
|
|
1140
|
+
incoming.update(upstream_files)
|
|
1141
|
+
for name, skills in OPTIONAL_ENTRY_POINTS.items():
|
|
1142
|
+
if name not in enabled_extras:
|
|
1143
|
+
registry_path = "ai-kit/SKILLS.md"
|
|
1144
|
+
incoming[registry_path] = b"\n".join(
|
|
1145
|
+
line for line in incoming[registry_path].split(b"\n")
|
|
1146
|
+
if not any(("(../.agents/skills/" + skill + "/SKILL.md)").encode() in line for skill in skills))
|
|
1147
|
+
if "web-design" not in enabled_extras:
|
|
1148
|
+
# These navigation lines only exist when their optional destinations are installed.
|
|
1149
|
+
for navigation in ("WIKI.md", "ai-kit/ADAPTERS.md"):
|
|
1150
|
+
incoming[navigation] = b"\n".join(line for line in incoming[navigation].split(b"\n")
|
|
1151
|
+
if b"WEB-DESIGN.md)" not in line)
|
|
1152
|
+
incoming["ai-kit/settings.json"] = (json.dumps(settings, indent=2) + "\n").encode()
|
|
1153
|
+
incoming["ai-kit/VERSION"] = (root / "VERSION").read_bytes()
|
|
1154
|
+
incoming["ai-kit/LICENSE"] = (root / "LICENSE").read_bytes()
|
|
1155
|
+
for agent, prefix in registry["skills_copies"].items():
|
|
1156
|
+
if agent in agents:
|
|
1157
|
+
for relative, data in list(incoming.items()):
|
|
1158
|
+
if relative.startswith(".agents/skills/"):
|
|
1159
|
+
if agent == "claude" and relative in {
|
|
1160
|
+
".agents/skills/" + name + "/SKILL.md" for name in OPTIONAL_SKILLS}:
|
|
1161
|
+
newline = b"\r\n" if data.startswith(b"---\r\n") else b"\n"
|
|
1162
|
+
header = b"---" + newline
|
|
1163
|
+
if not data.startswith(header):
|
|
1164
|
+
raise ValueError("Optional upstream entry point lacks frontmatter")
|
|
1165
|
+
data = header + b"disable-model-invocation: true" + newline + data[len(header):]
|
|
1166
|
+
incoming[relative.replace(".agents/skills/", prefix, 1)] = data
|
|
1167
|
+
for agent, items in registry["optional"].items():
|
|
1168
|
+
if agent in agents:
|
|
1169
|
+
for source, destination in items:
|
|
1170
|
+
incoming[destination] = (root / "integrations" / source).read_bytes()
|
|
1171
|
+
for name in sorted(enabled_extras):
|
|
1172
|
+
if name in EXTRA_TEMPLATE:
|
|
1173
|
+
for relative in EXTRA_TEMPLATE[name]:
|
|
1174
|
+
incoming[relative] = (root / "template" / relative).read_bytes()
|
|
1175
|
+
elif name in EXTRA_INTEGRATIONS:
|
|
1176
|
+
src, dst = EXTRA_INTEGRATIONS[name]
|
|
1177
|
+
incoming[dst] = (root / "integrations" / src).read_bytes()
|
|
1178
|
+
if "scoped-rules" in enabled_extras:
|
|
1179
|
+
# Generated module rules are managed files: unchanged ones follow project.json, edited ones conflict.
|
|
1180
|
+
incoming.update(scoped_rule_files(root, registry, agents, modules))
|
|
1181
|
+
if accept - incoming.keys() or accept & (owned_documents | {"ai-kit/settings.json"}):
|
|
1182
|
+
raise ValueError("Accept-local paths must name selected managed instructions")
|
|
1183
|
+
records = state.get("files", {})
|
|
1184
|
+
if not isinstance(records, dict):
|
|
1185
|
+
raise ValueError("Invalid installer state")
|
|
1186
|
+
actions, conflicts, preserved, accepted = {}, {}, [], {}
|
|
1187
|
+
conflict_local_hashes = {}
|
|
1188
|
+
for relative, data in incoming.items():
|
|
1189
|
+
path = safe_path(target, relative)
|
|
1190
|
+
current = path.read_bytes() if path.exists() else None
|
|
1191
|
+
source_hash = digest(data)
|
|
1192
|
+
previous = records.get(relative, {})
|
|
1193
|
+
if not isinstance(previous, dict):
|
|
1194
|
+
raise ValueError("Invalid baseline record")
|
|
1195
|
+
if relative in owned_documents:
|
|
1196
|
+
if current is None:
|
|
1197
|
+
if relative == "PROJECT_CONTEXT.md":
|
|
1198
|
+
data = project_context_draft(data, facts)
|
|
1199
|
+
elif relative == "ai-kit/project.json":
|
|
1200
|
+
data = project_json_draft(data, facts, target)
|
|
1201
|
+
actions[relative] = {"data": data, "old": None}
|
|
1202
|
+
else:
|
|
1203
|
+
preserved.append(relative)
|
|
1204
|
+
continue
|
|
1205
|
+
if relative in accept:
|
|
1206
|
+
if current is None:
|
|
1207
|
+
raise ValueError(f"Cannot accept a missing file: {relative}")
|
|
1208
|
+
accepted[relative] = {"installed_hash": digest(current), "source_hash": source_hash,
|
|
1209
|
+
"local_adaptation": True}
|
|
1210
|
+
continue
|
|
1211
|
+
if relative == "ai-kit/settings.json" or current is None or current == data:
|
|
1212
|
+
accepted[relative] = {"installed_hash": source_hash, "source_hash": source_hash}
|
|
1213
|
+
if current != data:
|
|
1214
|
+
actions[relative] = {"data": data, "old": current}
|
|
1215
|
+
elif previous.get("source_hash") == source_hash:
|
|
1216
|
+
# No upstream change: retain local adaptations without rewriting them.
|
|
1217
|
+
accepted[relative] = previous
|
|
1218
|
+
preserved.append(relative)
|
|
1219
|
+
elif not previous.get("local_adaptation") and previous.get("installed_hash") == digest(current):
|
|
1220
|
+
actions[relative] = {"data": data, "old": current}
|
|
1221
|
+
accepted[relative] = {"installed_hash": source_hash, "source_hash": source_hash}
|
|
1222
|
+
else:
|
|
1223
|
+
conflicts[relative] = data
|
|
1224
|
+
conflict_local_hashes[relative] = digest(current)
|
|
1225
|
+
ignore_path = safe_path(target, ".gitignore")
|
|
1226
|
+
old_ignore = ignore_path.read_bytes() if ignore_path.exists() else None
|
|
1227
|
+
merged, outside = merge_ignore((old_ignore or b"").decode("utf-8-sig"),
|
|
1228
|
+
(root / "templates" / ("gitignore." + mode)).read_text(encoding="utf-8"),
|
|
1229
|
+
module_ignores(target))
|
|
1230
|
+
# Recognized legacy private exclusions cannot be silently removed for team mode.
|
|
1231
|
+
legacy = {"AGENTS.md", "/AGENTS.md", "/ai-kit/", "ai-kit/", "/.agents/", "/.agents/skills/",
|
|
1232
|
+
"PROJECT_CONTEXT.md", "/PROJECT_CONTEXT.md", "/.claude/", "SKILL.md"}
|
|
1233
|
+
warnings = ["Existing ignore rules outside the managed block need project-specific visibility review."]
|
|
1234
|
+
if "ci" in enabled_extras and mode == "private":
|
|
1235
|
+
warnings.append("The CI check reads installer state that private sharing mode excludes from Git; "
|
|
1236
|
+
"team mode is the intended companion.")
|
|
1237
|
+
if "claude" not in agents and ({"guards", "session-start"} & enabled_extras):
|
|
1238
|
+
warnings.append("guards/session-start client wiring requires the claude agent selection; "
|
|
1239
|
+
"only the shared files were installed.")
|
|
1240
|
+
if mode == "team" and legacy.intersection(line.strip() for line in outside.splitlines()):
|
|
1241
|
+
conflicts[".gitignore"] = merged.encode()
|
|
1242
|
+
conflict_local_hashes[".gitignore"] = digest(old_ignore) if old_ignore is not None else None
|
|
1243
|
+
warnings.append("Remove or reconcile legacy private exclusions explicitly before applying team mode.")
|
|
1244
|
+
elif old_ignore != merged.encode():
|
|
1245
|
+
actions[".gitignore"] = {"data": merged.encode(), "old": old_ignore}
|
|
1246
|
+
missing_agents = sorted(set(detected_agents) - set(agents))
|
|
1247
|
+
if missing_agents and not explicit_agents:
|
|
1248
|
+
evidence = "; ".join(f"{agent} ({', '.join(detected_agents[agent])})" for agent in missing_agents)
|
|
1249
|
+
warnings.append(f"Detected client files for {evidence}; preview with "
|
|
1250
|
+
+ " ".join("--agent " + agent for agent in suggested_agents) + " to wire them.")
|
|
1251
|
+
warnings.extend(module_warnings)
|
|
1252
|
+
if preset_disabled:
|
|
1253
|
+
warnings.append(f"Preset {preset} turns off previously enabled extras: {', '.join(preset_disabled)}; "
|
|
1254
|
+
"add their --with-* flags to keep them.")
|
|
1255
|
+
# AI-KIT owns only its own entries in the client settings file; other settings survive.
|
|
1256
|
+
# Private mode keeps them in the personal local file, team mode in the shared one.
|
|
1257
|
+
managed_json = {relative: dict(entries) for relative, entries in state.get("managed_json", {}).items()}
|
|
1258
|
+
claude_target = CLAUDE_LOCAL_SETTINGS if mode == "private" else CLAUDE_SETTINGS
|
|
1259
|
+
if "claude" in agents:
|
|
1260
|
+
wanted_entries = claude_managed_entries(root, enabled_extras, modules)
|
|
1261
|
+
for relative in (CLAUDE_SETTINGS, CLAUDE_LOCAL_SETTINGS):
|
|
1262
|
+
wanted = wanted_entries if relative == claude_target else {}
|
|
1263
|
+
previous = {key: list(values) for key, values in managed_json.get(relative, {}).items()}
|
|
1264
|
+
if relative == CLAUDE_SETTINGS and CLAUDE_SETTINGS in records:
|
|
1265
|
+
for key, values in LEGACY_CLAUDE_ENTRIES.items():
|
|
1266
|
+
previous.setdefault(key, []).extend(values)
|
|
1267
|
+
if not (wanted or previous):
|
|
1268
|
+
continue
|
|
1269
|
+
settings_file = safe_path(target, relative)
|
|
1270
|
+
old_settings = settings_file.read_bytes() if settings_file.exists() else None
|
|
1271
|
+
try:
|
|
1272
|
+
current = json.loads(old_settings.decode("utf-8-sig")) if old_settings is not None else {}
|
|
1273
|
+
if not isinstance(current, dict):
|
|
1274
|
+
raise ValueError("expected a JSON object")
|
|
1275
|
+
merged_settings = merge_managed_json(current, previous, wanted)
|
|
1276
|
+
except (ValueError, UnicodeError) as exc:
|
|
1277
|
+
fragment = merge_managed_json({}, {}, wanted)
|
|
1278
|
+
conflicts[relative] = (json.dumps(fragment, indent=2) + "\n").encode()
|
|
1279
|
+
conflict_local_hashes[relative] = digest(old_settings)
|
|
1280
|
+
warnings.append(f"Cannot merge AI-KIT entries into {relative} ({exc}); fix the file "
|
|
1281
|
+
"or merge the candidate fragment manually, then rerun the preview.")
|
|
1282
|
+
continue
|
|
1283
|
+
if (old_settings is None and wanted) or (old_settings is not None and merged_settings != current):
|
|
1284
|
+
data = (json.dumps(merged_settings, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
1285
|
+
actions[relative] = {"data": data, "old": old_settings}
|
|
1286
|
+
if wanted:
|
|
1287
|
+
managed_json[relative] = wanted
|
|
1288
|
+
else:
|
|
1289
|
+
managed_json.pop(relative, None)
|
|
1290
|
+
else:
|
|
1291
|
+
for relative in (CLAUDE_SETTINGS, CLAUDE_LOCAL_SETTINGS):
|
|
1292
|
+
if relative in managed_json:
|
|
1293
|
+
warnings.append(f"AI-KIT entries remain in {relative} for the unselected claude client; "
|
|
1294
|
+
"review them before retirement.")
|
|
1295
|
+
# Client ignore files get one marked secret block, like .gitignore; other lines survive.
|
|
1296
|
+
secret_block = None
|
|
1297
|
+
if "native-settings" in enabled_extras:
|
|
1298
|
+
secret_block = ("# Secret files AI clients must not read\n"
|
|
1299
|
+
+ "\n".join(secret_patterns(root, modules)) + "\n")
|
|
1300
|
+
for agent, relative in sorted(registry["ignore_files"].items()):
|
|
1301
|
+
ignore_file = safe_path(target, relative)
|
|
1302
|
+
old_text_bytes = ignore_file.read_bytes() if ignore_file.exists() else None
|
|
1303
|
+
old_text = (old_text_bytes or b"").decode("utf-8-sig")
|
|
1304
|
+
if secret_block is not None and agent in agents:
|
|
1305
|
+
new_text, _ = merge_ignore(old_text, secret_block, [])
|
|
1306
|
+
elif old_text_bytes is not None and START in old_text:
|
|
1307
|
+
before, after = split_ignore(old_text)
|
|
1308
|
+
new_text = before + after
|
|
1309
|
+
else:
|
|
1310
|
+
continue
|
|
1311
|
+
if new_text.encode("utf-8") != old_text_bytes:
|
|
1312
|
+
actions[relative] = {"data": new_text.encode("utf-8"), "old": old_text_bytes}
|
|
1313
|
+
retirements = []
|
|
1314
|
+
for relative, record in records.items():
|
|
1315
|
+
if relative in incoming or relative in owned_documents:
|
|
1316
|
+
continue
|
|
1317
|
+
if relative == CLAUDE_SETTINGS and "claude" in agents:
|
|
1318
|
+
# Earlier versions managed the whole file; its entries now migrate to managed_json.
|
|
1319
|
+
continue
|
|
1320
|
+
if safe_path(target, relative).exists():
|
|
1321
|
+
# Keep old baseline information even when a path leaves the bundle.
|
|
1322
|
+
accepted[relative] = record
|
|
1323
|
+
owner = adapter_owner(relative, registry)
|
|
1324
|
+
if owner is not None and owner not in agents:
|
|
1325
|
+
retirements.append(relative)
|
|
1326
|
+
else:
|
|
1327
|
+
warnings.append(f"Previously managed path retained without a current source: {relative}")
|
|
1328
|
+
if retirements:
|
|
1329
|
+
warnings.append("Agent selection replaces the recorded list, but tracked unselected adapters "
|
|
1330
|
+
"remain on disk and may still load. Review/retire the listed files or retain "
|
|
1331
|
+
"their agent selection; accepted files/state will not change.")
|
|
1332
|
+
unselected = dict(registry["entries"])
|
|
1333
|
+
for agent, items in registry["optional"].items():
|
|
1334
|
+
for _, destination in items:
|
|
1335
|
+
unselected[destination] = agent
|
|
1336
|
+
for relative, owner in unselected.items():
|
|
1337
|
+
if owner in agents or relative in records:
|
|
1338
|
+
continue
|
|
1339
|
+
try:
|
|
1340
|
+
exists = safe_path(target, relative).exists()
|
|
1341
|
+
except NotADirectoryError:
|
|
1342
|
+
# An existing native rule file can occupy a directory adapter's parent.
|
|
1343
|
+
continue
|
|
1344
|
+
if exists:
|
|
1345
|
+
warnings.append(f"Untracked unselected {owner} entry may still load: {relative}")
|
|
1346
|
+
if "claude" not in agents and (target / ".claude/skills").exists():
|
|
1347
|
+
warnings.append("Unselected .claude/skills remains on disk; verify actual client loading "
|
|
1348
|
+
"and review custom/untracked skills before retirement.")
|
|
1349
|
+
candidates = {}
|
|
1350
|
+
for relative, data in conflicts.items():
|
|
1351
|
+
source_hash = digest(data)
|
|
1352
|
+
path = "ai-kit/.upstream-cache/candidates/" + source_hash + "/" + relative
|
|
1353
|
+
candidate = safe_path(target, path)
|
|
1354
|
+
manifest = path + ".metadata.json"
|
|
1355
|
+
safe_path(target, manifest)
|
|
1356
|
+
exists = candidate.exists()
|
|
1357
|
+
matches = digest(candidate.read_bytes()) == source_hash if exists else None
|
|
1358
|
+
candidates[relative] = {"path": path, "manifest": manifest, "source_hash": source_hash,
|
|
1359
|
+
"local_hash": conflict_local_hashes[relative],
|
|
1360
|
+
"exists": exists, "matches_source": matches}
|
|
1361
|
+
if exists and not matches:
|
|
1362
|
+
warnings.append(f"Existing candidate has local edits and will be preserved: {path}")
|
|
1363
|
+
new_state = dict(state)
|
|
1364
|
+
new_state.update(schema=1, accepted_version=(root / "VERSION").read_text().strip(),
|
|
1365
|
+
agents=agents, files=accepted)
|
|
1366
|
+
if managed_json:
|
|
1367
|
+
new_state["managed_json"] = managed_json
|
|
1368
|
+
else:
|
|
1369
|
+
new_state.pop("managed_json", None)
|
|
1370
|
+
state_data = (json.dumps(new_state, indent=2, sort_keys=True) + "\n").encode()
|
|
1371
|
+
old_state = state_path.read_bytes() if state_path.exists() else None
|
|
1372
|
+
if old_state != state_data:
|
|
1373
|
+
actions["ai-kit/.install-state.json"] = {"data": state_data, "old": old_state}
|
|
1374
|
+
return {"target": target, "mode": mode, "version": new_state["accepted_version"],
|
|
1375
|
+
"actions": actions, "conflicts": conflicts, "candidates": candidates,
|
|
1376
|
+
"adapter_conflicts": sorted(retirements), "preserved": preserved,
|
|
1377
|
+
"detected_profiles": detected, "detected_agents": detected_agents,
|
|
1378
|
+
"suggested_agents": suggested_agents, "extras": sorted(enabled_extras), "preset": preset,
|
|
1379
|
+
"warnings": warnings}
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
1383
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1384
|
+
temp = path.with_name(path.name + ".ai-kit-" + uuid.uuid4().hex + ".tmp")
|
|
1385
|
+
try:
|
|
1386
|
+
with temp.open("xb") as stream:
|
|
1387
|
+
stream.write(data)
|
|
1388
|
+
os.replace(temp, path)
|
|
1389
|
+
finally:
|
|
1390
|
+
if temp.exists():
|
|
1391
|
+
temp.unlink()
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def write_once(path: Path, data: bytes) -> bool:
|
|
1395
|
+
"""Create exclusively; preserve every existing file, including an edited draft."""
|
|
1396
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1397
|
+
try:
|
|
1398
|
+
stream = path.open("xb")
|
|
1399
|
+
except FileExistsError:
|
|
1400
|
+
return False
|
|
1401
|
+
with stream:
|
|
1402
|
+
stream.write(data)
|
|
1403
|
+
return True
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def needs_review(plan: dict) -> bool:
|
|
1407
|
+
return bool(plan["conflicts"] or plan["adapter_conflicts"])
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def apply_plan(plan: dict) -> bool:
|
|
1411
|
+
target: Path = plan["target"]
|
|
1412
|
+
if needs_review(plan):
|
|
1413
|
+
for relative, data in plan["conflicts"].items():
|
|
1414
|
+
info = plan["candidates"][relative]
|
|
1415
|
+
write_once(safe_path(target, info["path"]), data)
|
|
1416
|
+
manifest = {"relative_path": relative, "source_hash": info["source_hash"],
|
|
1417
|
+
"local_hash": info["local_hash"], "bundle_version": plan["version"],
|
|
1418
|
+
"created_at": datetime.now(timezone.utc).isoformat()}
|
|
1419
|
+
write_once(safe_path(target, info["manifest"]),
|
|
1420
|
+
(json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode())
|
|
1421
|
+
return False
|
|
1422
|
+
# Recheck every planned write before modifying any project file.
|
|
1423
|
+
for relative, action in plan["actions"].items():
|
|
1424
|
+
path = safe_path(target, relative)
|
|
1425
|
+
actual = path.read_bytes() if path.exists() else None
|
|
1426
|
+
if actual != action["old"]:
|
|
1427
|
+
raise ValueError(f"File changed after preview: {relative}")
|
|
1428
|
+
if not plan["actions"]:
|
|
1429
|
+
return True
|
|
1430
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
|
|
1431
|
+
# Prepare all existing-file backups before writing any instruction.
|
|
1432
|
+
for relative, action in plan["actions"].items():
|
|
1433
|
+
if action["old"] is not None:
|
|
1434
|
+
backup = safe_path(target, "ai-kit/.upstream-cache/backups/" + stamp + "/" + relative)
|
|
1435
|
+
atomic_write(backup, action["old"])
|
|
1436
|
+
completed = []
|
|
1437
|
+
try:
|
|
1438
|
+
for relative, action in plan["actions"].items():
|
|
1439
|
+
path = safe_path(target, relative)
|
|
1440
|
+
actual = path.read_bytes() if path.exists() else None
|
|
1441
|
+
if actual != action["old"]:
|
|
1442
|
+
raise ValueError(f"Concurrent modification: {relative}")
|
|
1443
|
+
atomic_write(path, action["data"])
|
|
1444
|
+
completed.append(relative)
|
|
1445
|
+
except (OSError, ValueError):
|
|
1446
|
+
# Restore only files that still contain our exact write.
|
|
1447
|
+
for relative in reversed(completed):
|
|
1448
|
+
path = safe_path(target, relative)
|
|
1449
|
+
action = plan["actions"][relative]
|
|
1450
|
+
if path.read_bytes() == action["data"]:
|
|
1451
|
+
if action["old"] is None:
|
|
1452
|
+
path.unlink()
|
|
1453
|
+
else:
|
|
1454
|
+
atomic_write(path, action["old"])
|
|
1455
|
+
raise
|
|
1456
|
+
return True
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def interactive_selections(read=input, *, agents_available: set[str], facts: dict,
|
|
1460
|
+
detected_agents: dict[str, list[str]] | None = None) -> dict | None:
|
|
1461
|
+
"""Prompt for selections; returns None when the user declines to continue."""
|
|
1462
|
+
print("AI-KIT interactive installer")
|
|
1463
|
+
for module in facts["modules"]:
|
|
1464
|
+
print(f" Detected: {module['path']} ({', '.join(module['profiles']) or 'no profile'})"
|
|
1465
|
+
f" from {', '.join(module['manifests'])}")
|
|
1466
|
+
detected_agents = detected_agents or {}
|
|
1467
|
+
for agent, evidence in sorted(detected_agents.items()):
|
|
1468
|
+
print(f" Detected client: {agent} ({', '.join(evidence)})")
|
|
1469
|
+
default_agents = sorted({"codex"} | set(detected_agents))
|
|
1470
|
+
preset = read(f"Preset [{'/'.join(PRESETS)}] (none): ").strip().lower() or None
|
|
1471
|
+
spec = PRESETS.get(preset or "", {"mode": None, "extras": ()})
|
|
1472
|
+
default_mode = spec["mode"] or "private"
|
|
1473
|
+
mode = (read(f"Sharing mode [private/team] ({default_mode}): ").strip() or default_mode).lower()
|
|
1474
|
+
language = read("Chat language (Russian): ").strip() or "Russian"
|
|
1475
|
+
conventions = (read("Conventions [owner/standard] (owner): ").strip() or "owner").lower()
|
|
1476
|
+
agent_text = read(f"Agents, comma-separated ({', '.join(sorted(agents_available))}) "
|
|
1477
|
+
f"[{','.join(default_agents)}]: ").strip()
|
|
1478
|
+
agents = [a.strip() for a in agent_text.split(",") if a.strip()] or default_agents
|
|
1479
|
+
default_extras = list(spec["extras"])
|
|
1480
|
+
extra_text = read(f"Extras, comma-separated [{','.join(sorted(EXTRAS))}] "
|
|
1481
|
+
f"({','.join(default_extras) or 'none'}): ").strip()
|
|
1482
|
+
extras = [e.strip() for e in extra_text.split(",") if e.strip()] or default_extras
|
|
1483
|
+
print("\nSummary:")
|
|
1484
|
+
print(f" preset={preset or 'none'}, mode={mode}, language={language}, conventions={conventions}")
|
|
1485
|
+
print(f" agents={', '.join(agents)}, extras={', '.join(extras) or 'none'}")
|
|
1486
|
+
confirm = read("Proceed? [y/N]: ").strip().lower()
|
|
1487
|
+
if confirm not in {"y", "yes"}:
|
|
1488
|
+
print("Aborted.")
|
|
1489
|
+
return None
|
|
1490
|
+
return {"preset": preset, "mode": mode, "language": language, "conventions": conventions,
|
|
1491
|
+
"agents": agents, "extras": extras}
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1495
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1496
|
+
parser.add_argument("target", type=Path)
|
|
1497
|
+
group = parser.add_mutually_exclusive_group()
|
|
1498
|
+
group.add_argument("--apply", action="store_true")
|
|
1499
|
+
group.add_argument("--dry-run", action="store_true")
|
|
1500
|
+
parser.add_argument("--mode", choices=["private", "team"])
|
|
1501
|
+
parser.add_argument("--chat-language")
|
|
1502
|
+
parser.add_argument("--conventions", choices=["owner", "standard"])
|
|
1503
|
+
parser.add_argument("--agent", choices=sorted(load_agent_registry()["names"]), action="append")
|
|
1504
|
+
parser.add_argument("--interactive", action="store_true",
|
|
1505
|
+
help="Prompt for sharing mode, language, conventions, agents, and extras")
|
|
1506
|
+
parser.add_argument("--check", action="store_true",
|
|
1507
|
+
help="Run the project doctor after a successful --apply")
|
|
1508
|
+
parser.add_argument("--accept-local", action="append", default=[],
|
|
1509
|
+
help="Record an explicitly reviewed semantic merge of a managed path")
|
|
1510
|
+
parser.add_argument("--with-session-start", action="store_true",
|
|
1511
|
+
help="Install the session-start hook file and client wiring")
|
|
1512
|
+
parser.add_argument("--with-guards", action="store_true",
|
|
1513
|
+
help="Install client confirmation (ask) rules for Git commit/push")
|
|
1514
|
+
parser.add_argument("--with-ci", action="store_true",
|
|
1515
|
+
help="Install the self-contained AI-KIT project check workflow")
|
|
1516
|
+
parser.add_argument("--with-data-guards", action="store_true",
|
|
1517
|
+
help="Ask before destructive file, Docker, and detected migration commands (Claude Code)")
|
|
1518
|
+
parser.add_argument("--with-native-settings", action="store_true",
|
|
1519
|
+
help="Allow recorded checks, deny secret reads, and write client ignore files")
|
|
1520
|
+
parser.add_argument("--with-scoped-rules", action="store_true",
|
|
1521
|
+
help="Generate path-scoped rules per module for clients that support them")
|
|
1522
|
+
parser.add_argument("--with-security-audit", action="store_true",
|
|
1523
|
+
help="Install the pinned, user-invoked security-audit skill without running an audit")
|
|
1524
|
+
parser.add_argument("--with-strix", action="store_true",
|
|
1525
|
+
help="Install pinned Strix skills and a user-invoked entry point without installing or running Strix")
|
|
1526
|
+
parser.add_argument("--with-context7", action="store_true",
|
|
1527
|
+
help="Install the Context7 documentation skill; MCP connection/authentication stays separate")
|
|
1528
|
+
parser.add_argument("--preset", choices=list(PRESETS),
|
|
1529
|
+
help="Apply a selection bundle; explicit --mode and --with-* flags take precedence")
|
|
1530
|
+
parser.add_argument("--with-web-design", action="store_true",
|
|
1531
|
+
help="Install Web Design workflows and project documents; no external tool setup")
|
|
1532
|
+
args = parser.parse_args(argv)
|
|
1533
|
+
extras = [name for name, flag in (("session-start", args.with_session_start),
|
|
1534
|
+
("guards", args.with_guards),
|
|
1535
|
+
("ci", args.with_ci),
|
|
1536
|
+
("data-guards", args.with_data_guards),
|
|
1537
|
+
("native-settings", args.with_native_settings),
|
|
1538
|
+
("scoped-rules", args.with_scoped_rules),
|
|
1539
|
+
("security-audit", args.with_security_audit), ("strix", args.with_strix),
|
|
1540
|
+
("context7", args.with_context7),
|
|
1541
|
+
("web-design", args.with_web_design)) if flag]
|
|
1542
|
+
mode, language, conventions, agents = args.mode, args.chat_language, args.conventions, args.agent
|
|
1543
|
+
preset = args.preset
|
|
1544
|
+
if args.interactive:
|
|
1545
|
+
target = args.target.absolute().resolve()
|
|
1546
|
+
registry = load_agent_registry()
|
|
1547
|
+
selections = interactive_selections(agents_available=registry["names"], facts=detect_facts(target),
|
|
1548
|
+
detected_agents=detect_agents(target, registry))
|
|
1549
|
+
if selections is None:
|
|
1550
|
+
return 0
|
|
1551
|
+
mode = mode or selections["mode"]
|
|
1552
|
+
language = language or selections["language"]
|
|
1553
|
+
conventions = conventions or selections["conventions"]
|
|
1554
|
+
agents = agents or selections["agents"]
|
|
1555
|
+
extras = sorted(set(extras) | set(selections["extras"]))
|
|
1556
|
+
preset = preset or selections["preset"]
|
|
1557
|
+
try:
|
|
1558
|
+
plan = build_plan(args.target, mode=mode, language=language,
|
|
1559
|
+
conventions=conventions, agents=agents, accept_local=args.accept_local,
|
|
1560
|
+
extras=extras, preset=preset)
|
|
1561
|
+
print(json.dumps({"target": str(plan["target"]), "version": plan["version"], "mode": plan["mode"],
|
|
1562
|
+
"preset": plan["preset"],
|
|
1563
|
+
"preview": not args.apply, "writes": sorted(plan["actions"]),
|
|
1564
|
+
"conflicts": sorted(plan["conflicts"]), "preserved": sorted(plan["preserved"]),
|
|
1565
|
+
"candidates": plan["candidates"], "adapter_conflicts": plan["adapter_conflicts"],
|
|
1566
|
+
"detected_profiles": plan["detected_profiles"],
|
|
1567
|
+
"detected_agents": plan["detected_agents"],
|
|
1568
|
+
"suggested_agents": plan["suggested_agents"], "extras": plan["extras"],
|
|
1569
|
+
"warnings": plan["warnings"]}, indent=2))
|
|
1570
|
+
if args.apply:
|
|
1571
|
+
applied = apply_plan(plan)
|
|
1572
|
+
if applied and args.check:
|
|
1573
|
+
import doctor
|
|
1574
|
+
report = doctor.examine(plan["target"], root=ROOT)
|
|
1575
|
+
print(json.dumps({"doctor": report}, indent=2))
|
|
1576
|
+
return 1 if report["errors"] else 0
|
|
1577
|
+
return 2 if needs_review(plan) else 0
|
|
1578
|
+
except (OSError, ValueError, UnicodeError, json.JSONDecodeError) as exc:
|
|
1579
|
+
print(f"Installation refused: {exc}", file=sys.stderr)
|
|
1580
|
+
return 1
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
if __name__ == "__main__":
|
|
1584
|
+
raise SystemExit(main())
|