@trac3er/oh-my-god 2.0.0 → 2.0.2
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.
- package/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Config Discovery Framework for AI Coding Tools
|
|
4
|
+
|
|
5
|
+
Scans a project directory for configuration files from 8 AI coding tools
|
|
6
|
+
and produces a JSON discovery report. Read-only operation.
|
|
7
|
+
|
|
8
|
+
Feature flag: OMG_CONFIG_DISCOVERY_ENABLED (default: off)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List
|
|
17
|
+
|
|
18
|
+
# Feature flag
|
|
19
|
+
OMG_CONFIG_DISCOVERY_ENABLED = os.getenv("OMG_CONFIG_DISCOVERY_ENABLED", "false").lower() == "true"
|
|
20
|
+
|
|
21
|
+
# Tool detection patterns
|
|
22
|
+
TOOL_PATTERNS = {
|
|
23
|
+
"claude_code": [".claude/", ".claude/CLAUDE.md", "CLAUDE.md"],
|
|
24
|
+
"cursor": [".cursorrules", ".cursor/rules/", ".cursor/"],
|
|
25
|
+
"windsurf": [".windsurf/", ".windsurfrules"],
|
|
26
|
+
"gemini": ["system.md", ".gemini/"],
|
|
27
|
+
"codex": ["AGENTS.md"],
|
|
28
|
+
"cline": [".clinerules"],
|
|
29
|
+
"github_copilot": [".github/copilot-instructions.md"],
|
|
30
|
+
"vscode": [".vscode/settings.json", ".vscode/"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_file_size(path: Path) -> int:
|
|
35
|
+
"""Get file size in bytes. Returns 0 if not a file."""
|
|
36
|
+
try:
|
|
37
|
+
if path.is_file():
|
|
38
|
+
return path.stat().st_size
|
|
39
|
+
return 0
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_readable(path: Path) -> bool:
|
|
45
|
+
"""Check if path is readable."""
|
|
46
|
+
try:
|
|
47
|
+
return os.access(str(path), os.R_OK)
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_format(path: Path) -> str:
|
|
53
|
+
"""Determine file format from extension or path."""
|
|
54
|
+
if path.is_dir():
|
|
55
|
+
return "directory"
|
|
56
|
+
|
|
57
|
+
suffix = path.suffix.lower()
|
|
58
|
+
if suffix == ".md":
|
|
59
|
+
return "markdown"
|
|
60
|
+
elif suffix == ".json":
|
|
61
|
+
return "json"
|
|
62
|
+
elif suffix == ".yaml" or suffix == ".yml":
|
|
63
|
+
return "yaml"
|
|
64
|
+
elif suffix == ".txt":
|
|
65
|
+
return "text"
|
|
66
|
+
else:
|
|
67
|
+
return "unknown"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def discover_configs(project_dir: str) -> Dict[str, Any]:
|
|
71
|
+
"""
|
|
72
|
+
Scan project_dir for AI tool configs.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
{
|
|
76
|
+
"discovered": [
|
|
77
|
+
{
|
|
78
|
+
"tool": "claude_code",
|
|
79
|
+
"paths": [".claude/CLAUDE.md"],
|
|
80
|
+
"format": "markdown",
|
|
81
|
+
"size_bytes": 1234,
|
|
82
|
+
"readable": true
|
|
83
|
+
},
|
|
84
|
+
...
|
|
85
|
+
],
|
|
86
|
+
"scan_dir": "/path/to/project",
|
|
87
|
+
"timestamp": "2025-03-02T10:30:45.123456"
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
project_path = Path(project_dir).resolve()
|
|
91
|
+
|
|
92
|
+
if not project_path.exists():
|
|
93
|
+
return {
|
|
94
|
+
"discovered": [],
|
|
95
|
+
"scan_dir": str(project_path),
|
|
96
|
+
"timestamp": datetime.now().isoformat(),
|
|
97
|
+
"error": f"Project directory does not exist: {project_dir}"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
discovered = []
|
|
101
|
+
|
|
102
|
+
for tool_name, patterns in TOOL_PATTERNS.items():
|
|
103
|
+
tool_paths = []
|
|
104
|
+
|
|
105
|
+
for pattern in patterns:
|
|
106
|
+
# Handle directory patterns (ending with /)
|
|
107
|
+
if pattern.endswith("/"):
|
|
108
|
+
dir_path = project_path / pattern.rstrip("/")
|
|
109
|
+
if dir_path.exists() and dir_path.is_dir():
|
|
110
|
+
tool_paths.append(pattern.rstrip("/"))
|
|
111
|
+
else:
|
|
112
|
+
# Handle file patterns
|
|
113
|
+
file_path = project_path / pattern
|
|
114
|
+
if file_path.exists():
|
|
115
|
+
tool_paths.append(pattern)
|
|
116
|
+
|
|
117
|
+
if tool_paths:
|
|
118
|
+
# Get info from first discovered path
|
|
119
|
+
first_path = project_path / tool_paths[0]
|
|
120
|
+
size_bytes = get_file_size(first_path)
|
|
121
|
+
readable = is_readable(first_path)
|
|
122
|
+
format_type = get_format(first_path)
|
|
123
|
+
|
|
124
|
+
discovered.append({
|
|
125
|
+
"tool": tool_name,
|
|
126
|
+
"paths": tool_paths,
|
|
127
|
+
"format": format_type,
|
|
128
|
+
"size_bytes": size_bytes,
|
|
129
|
+
"readable": readable
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"discovered": discovered,
|
|
134
|
+
"scan_dir": str(project_path),
|
|
135
|
+
"timestamp": datetime.now().isoformat()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main():
|
|
140
|
+
"""CLI entry point."""
|
|
141
|
+
if len(sys.argv) < 3 or sys.argv[1] != "--scan":
|
|
142
|
+
print("Usage: python3 config_discovery.py --scan <directory>", file=sys.stderr)
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
project_dir = sys.argv[2]
|
|
146
|
+
result = discover_configs(project_dir)
|
|
147
|
+
print(json.dumps(result, indent=2))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Config Merging Framework for OMG
|
|
4
|
+
|
|
5
|
+
Merges discovered AI tool configurations into a unified OMG config with
|
|
6
|
+
priority-based conflict resolution.
|
|
7
|
+
|
|
8
|
+
Priority order (highest to lowest):
|
|
9
|
+
1. OMG config (.omg/state/omg_config.json)
|
|
10
|
+
2. Project-level configs (discovered in project directory)
|
|
11
|
+
3. User-level configs (discovered in home directory)
|
|
12
|
+
4. Tool defaults
|
|
13
|
+
|
|
14
|
+
Feature flag: OMG_CONFIG_DISCOVERY_ENABLED (default: off)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any, Dict, List, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Priority levels — lower number = higher priority
|
|
25
|
+
PRIORITY_OMG = 0
|
|
26
|
+
PRIORITY_PROJECT = 10
|
|
27
|
+
PRIORITY_USER = 20
|
|
28
|
+
PRIORITY_DEFAULT = 30
|
|
29
|
+
|
|
30
|
+
# Source type labels
|
|
31
|
+
SOURCE_OMG = "omg_config"
|
|
32
|
+
SOURCE_PROJECT = "project"
|
|
33
|
+
SOURCE_USER = "user"
|
|
34
|
+
SOURCE_DEFAULT = "default"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_feature_flag_enabled() -> bool:
|
|
38
|
+
"""Check if config discovery feature is enabled.
|
|
39
|
+
|
|
40
|
+
Resolution: env var → _common.get_feature_flag() → False.
|
|
41
|
+
"""
|
|
42
|
+
env_val = os.environ.get("OMG_CONFIG_DISCOVERY_ENABLED", "").lower()
|
|
43
|
+
if env_val in ("0", "false", "no"):
|
|
44
|
+
return False
|
|
45
|
+
if env_val in ("1", "true", "yes"):
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
# Lazy import from hooks
|
|
49
|
+
hooks_dir = os.path.normpath(
|
|
50
|
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
|
|
51
|
+
)
|
|
52
|
+
if hooks_dir not in sys.path:
|
|
53
|
+
sys.path.insert(0, hooks_dir)
|
|
54
|
+
try:
|
|
55
|
+
from _common import get_feature_flag # type: ignore[import-untyped]
|
|
56
|
+
|
|
57
|
+
return get_feature_flag("CONFIG_DISCOVERY", default=False)
|
|
58
|
+
except ImportError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_atomic_json_write():
|
|
63
|
+
"""Lazy-import atomic_json_write from hooks/_common.py."""
|
|
64
|
+
hooks_dir = os.path.normpath(
|
|
65
|
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks")
|
|
66
|
+
)
|
|
67
|
+
if hooks_dir not in sys.path:
|
|
68
|
+
sys.path.insert(0, hooks_dir)
|
|
69
|
+
try:
|
|
70
|
+
from _common import atomic_json_write # type: ignore[import-untyped]
|
|
71
|
+
|
|
72
|
+
return atomic_json_write
|
|
73
|
+
except ImportError:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_config_values(config_path: str, fmt: str) -> Dict[str, Any]:
|
|
78
|
+
"""Extract key-value pairs from a config file.
|
|
79
|
+
|
|
80
|
+
Handles JSON, YAML (if available), TOML (if available), and
|
|
81
|
+
markdown (extracts frontmatter).
|
|
82
|
+
|
|
83
|
+
Returns empty dict on any parse error — never crashes.
|
|
84
|
+
"""
|
|
85
|
+
if not config_path or not os.path.isfile(config_path):
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with open(config_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
90
|
+
content = f.read(256 * 1024) # 256KB limit
|
|
91
|
+
except (OSError, IOError):
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
if not content.strip():
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
# JSON
|
|
98
|
+
if fmt == "json":
|
|
99
|
+
return _parse_json(content)
|
|
100
|
+
|
|
101
|
+
# YAML
|
|
102
|
+
if fmt in ("yaml", "yml"):
|
|
103
|
+
return _parse_yaml(content)
|
|
104
|
+
|
|
105
|
+
# TOML
|
|
106
|
+
if fmt == "toml":
|
|
107
|
+
return _parse_toml(content)
|
|
108
|
+
|
|
109
|
+
# Markdown — extract frontmatter
|
|
110
|
+
if fmt == "markdown":
|
|
111
|
+
return _parse_markdown_frontmatter(content)
|
|
112
|
+
|
|
113
|
+
# Unknown format — try JSON first, then YAML
|
|
114
|
+
result = _parse_json(content)
|
|
115
|
+
if result:
|
|
116
|
+
return result
|
|
117
|
+
return _parse_yaml(content)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_json(content: str) -> Dict[str, Any]:
|
|
121
|
+
"""Parse JSON content into a dict."""
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(content)
|
|
124
|
+
return data if isinstance(data, dict) else {}
|
|
125
|
+
except (json.JSONDecodeError, ValueError):
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_yaml(content: str) -> Dict[str, Any]:
|
|
130
|
+
"""Parse YAML content. Returns empty dict if PyYAML not available."""
|
|
131
|
+
try:
|
|
132
|
+
import yaml # type: ignore[import-untyped]
|
|
133
|
+
|
|
134
|
+
data = yaml.safe_load(content)
|
|
135
|
+
return data if isinstance(data, dict) else {}
|
|
136
|
+
except ImportError:
|
|
137
|
+
return {}
|
|
138
|
+
except Exception:
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_toml(content: str) -> Dict[str, Any]:
|
|
143
|
+
"""Parse TOML content. Tries tomllib (3.11+), then tomli, then fails gracefully."""
|
|
144
|
+
try:
|
|
145
|
+
import tomllib # type: ignore[import-not-found] # Python 3.11+
|
|
146
|
+
|
|
147
|
+
data = tomllib.loads(content)
|
|
148
|
+
return data if isinstance(data, dict) else {}
|
|
149
|
+
except ImportError:
|
|
150
|
+
pass
|
|
151
|
+
except Exception:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
import tomli # type: ignore[import-untyped]
|
|
156
|
+
|
|
157
|
+
data = tomli.loads(content)
|
|
158
|
+
return data if isinstance(data, dict) else {}
|
|
159
|
+
except ImportError:
|
|
160
|
+
return {}
|
|
161
|
+
except Exception:
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_markdown_frontmatter(content: str) -> Dict[str, Any]:
|
|
166
|
+
"""Extract YAML frontmatter from markdown (between --- delimiters)."""
|
|
167
|
+
content = content.strip()
|
|
168
|
+
if not content.startswith("---"):
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
# Find the closing ---
|
|
172
|
+
end_idx = content.find("---", 3)
|
|
173
|
+
if end_idx == -1:
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
frontmatter = content[3:end_idx].strip()
|
|
177
|
+
if not frontmatter:
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
return _parse_yaml(frontmatter)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _classify_source(config: Dict[str, Any]) -> str:
|
|
184
|
+
"""Classify a discovered config as project-level or user-level.
|
|
185
|
+
|
|
186
|
+
Heuristic: configs from home directory paths are user-level,
|
|
187
|
+
everything else is project-level.
|
|
188
|
+
"""
|
|
189
|
+
paths = config.get("paths", [])
|
|
190
|
+
if not paths:
|
|
191
|
+
return SOURCE_PROJECT
|
|
192
|
+
|
|
193
|
+
first_path = str(paths[0])
|
|
194
|
+
home = os.path.expanduser("~")
|
|
195
|
+
|
|
196
|
+
# If the path is absolute and under home dir (not in project), it's user-level
|
|
197
|
+
if os.path.isabs(first_path) and first_path.startswith(home):
|
|
198
|
+
return SOURCE_USER
|
|
199
|
+
|
|
200
|
+
return SOURCE_PROJECT
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _get_priority(source_type: str) -> int:
|
|
204
|
+
"""Get numeric priority for a source type. Lower = higher priority."""
|
|
205
|
+
return {
|
|
206
|
+
SOURCE_OMG: PRIORITY_OMG,
|
|
207
|
+
SOURCE_PROJECT: PRIORITY_PROJECT,
|
|
208
|
+
SOURCE_USER: PRIORITY_USER,
|
|
209
|
+
SOURCE_DEFAULT: PRIORITY_DEFAULT,
|
|
210
|
+
}.get(source_type, PRIORITY_DEFAULT)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_conflict(
|
|
214
|
+
key: str,
|
|
215
|
+
existing_val: Any,
|
|
216
|
+
new_val: Any,
|
|
217
|
+
existing_source: str,
|
|
218
|
+
new_source: str,
|
|
219
|
+
) -> Tuple[Any, Dict[str, Any]]:
|
|
220
|
+
"""Resolve a config key conflict between two sources.
|
|
221
|
+
|
|
222
|
+
Priority rules: higher priority source (lower number) wins.
|
|
223
|
+
If same priority, last-write-wins (new_val wins).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
(winning_value, conflict_record) where conflict_record is a dict
|
|
227
|
+
documenting the conflict for logging.
|
|
228
|
+
"""
|
|
229
|
+
existing_priority = _get_priority(existing_source)
|
|
230
|
+
new_priority = _get_priority(new_source)
|
|
231
|
+
|
|
232
|
+
conflict_record = {
|
|
233
|
+
"key": key,
|
|
234
|
+
"existing_value": existing_val,
|
|
235
|
+
"existing_source": existing_source,
|
|
236
|
+
"new_value": new_val,
|
|
237
|
+
"new_source": new_source,
|
|
238
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if new_priority < existing_priority:
|
|
242
|
+
# New source has higher priority (lower number) — it wins
|
|
243
|
+
conflict_record["winner"] = new_source
|
|
244
|
+
conflict_record["resolution"] = "higher_priority"
|
|
245
|
+
return new_val, conflict_record
|
|
246
|
+
elif new_priority > existing_priority:
|
|
247
|
+
# Existing source has higher priority — it wins
|
|
248
|
+
conflict_record["winner"] = existing_source
|
|
249
|
+
conflict_record["resolution"] = "higher_priority"
|
|
250
|
+
return existing_val, conflict_record
|
|
251
|
+
else:
|
|
252
|
+
# Same priority — last-write-wins
|
|
253
|
+
conflict_record["winner"] = new_source
|
|
254
|
+
conflict_record["resolution"] = "last_write_wins"
|
|
255
|
+
return new_val, conflict_record
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _load_omg_config(omg_config_path: str) -> Dict[str, Any]:
|
|
259
|
+
"""Load OMG config from disk. Returns empty dict on any error."""
|
|
260
|
+
if not omg_config_path or not os.path.isfile(omg_config_path):
|
|
261
|
+
return {}
|
|
262
|
+
try:
|
|
263
|
+
with open(omg_config_path, "r", encoding="utf-8") as f:
|
|
264
|
+
data = json.load(f)
|
|
265
|
+
return data if isinstance(data, dict) else {}
|
|
266
|
+
except (json.JSONDecodeError, OSError, IOError):
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def merge_configs(
|
|
271
|
+
discovered_configs: List[Dict[str, Any]],
|
|
272
|
+
omg_config_path: str = ".omg/state/omg_config.json",
|
|
273
|
+
) -> Dict[str, Any]:
|
|
274
|
+
"""Merge discovered AI tool configs into a unified OMG config.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
discovered_configs: List of config dicts from discover_configs()["discovered"].
|
|
278
|
+
Each dict has: tool, paths, format, size_bytes, readable.
|
|
279
|
+
omg_config_path: Path to existing OMG config (highest priority).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
{
|
|
283
|
+
"merged": dict — the merged configuration,
|
|
284
|
+
"conflicts": list — conflict records,
|
|
285
|
+
"sources": list — source descriptions,
|
|
286
|
+
"timestamp": str — ISO 8601 timestamp,
|
|
287
|
+
}
|
|
288
|
+
or {"skipped": True} if feature flag is disabled.
|
|
289
|
+
"""
|
|
290
|
+
if not _get_feature_flag_enabled():
|
|
291
|
+
return {"skipped": True}
|
|
292
|
+
|
|
293
|
+
merged: Dict[str, Any] = {}
|
|
294
|
+
conflicts: List[Dict[str, Any]] = []
|
|
295
|
+
sources: List[Dict[str, Any]] = []
|
|
296
|
+
source_map: Dict[str, str] = {} # key → source label for conflict tracking
|
|
297
|
+
|
|
298
|
+
# Phase 1: Load OMG config (highest priority)
|
|
299
|
+
omg_values = _load_omg_config(omg_config_path)
|
|
300
|
+
if omg_values:
|
|
301
|
+
sources.append({
|
|
302
|
+
"type": SOURCE_OMG,
|
|
303
|
+
"path": omg_config_path,
|
|
304
|
+
"keys_count": len(omg_values),
|
|
305
|
+
})
|
|
306
|
+
for key, val in omg_values.items():
|
|
307
|
+
merged[key] = val
|
|
308
|
+
source_map[key] = SOURCE_OMG
|
|
309
|
+
|
|
310
|
+
# Phase 2: Process discovered configs by priority
|
|
311
|
+
# Sort: project-level first, then user-level
|
|
312
|
+
sorted_configs = sorted(
|
|
313
|
+
discovered_configs or [],
|
|
314
|
+
key=lambda c: _get_priority(_classify_source(c)),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
for config in sorted_configs:
|
|
318
|
+
tool = config.get("tool", "unknown")
|
|
319
|
+
paths = config.get("paths", [])
|
|
320
|
+
fmt = config.get("format", "unknown")
|
|
321
|
+
readable = config.get("readable", False)
|
|
322
|
+
|
|
323
|
+
if not paths or not readable:
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
first_path = paths[0]
|
|
327
|
+
source_type = _classify_source(config)
|
|
328
|
+
source_label = f"{source_type}:{tool}:{first_path}"
|
|
329
|
+
|
|
330
|
+
# Extract values from the config file
|
|
331
|
+
values = _extract_config_values(first_path, fmt)
|
|
332
|
+
if not values:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
sources.append({
|
|
336
|
+
"type": source_type,
|
|
337
|
+
"tool": tool,
|
|
338
|
+
"path": first_path,
|
|
339
|
+
"format": fmt,
|
|
340
|
+
"keys_count": len(values),
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
for key, val in values.items():
|
|
344
|
+
if key in merged:
|
|
345
|
+
# Conflict — resolve with priority rules
|
|
346
|
+
winning_val, conflict_record = _resolve_conflict(
|
|
347
|
+
key,
|
|
348
|
+
merged[key],
|
|
349
|
+
val,
|
|
350
|
+
source_map.get(key, SOURCE_DEFAULT),
|
|
351
|
+
source_label,
|
|
352
|
+
)
|
|
353
|
+
conflicts.append(conflict_record)
|
|
354
|
+
merged[key] = winning_val
|
|
355
|
+
if conflict_record["winner"] == source_label:
|
|
356
|
+
source_map[key] = source_label
|
|
357
|
+
else:
|
|
358
|
+
merged[key] = val
|
|
359
|
+
source_map[key] = source_label
|
|
360
|
+
|
|
361
|
+
result = {
|
|
362
|
+
"merged": merged,
|
|
363
|
+
"conflicts": conflicts,
|
|
364
|
+
"sources": sources,
|
|
365
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Persist merged config
|
|
369
|
+
_persist_merged_config(result)
|
|
370
|
+
|
|
371
|
+
# Persist conflict log if any conflicts
|
|
372
|
+
if conflicts:
|
|
373
|
+
_persist_conflicts(conflicts)
|
|
374
|
+
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _persist_merged_config(result: Dict[str, Any]) -> None:
|
|
379
|
+
"""Persist merged config to .omg/state/merged_config.json."""
|
|
380
|
+
writer = _get_atomic_json_write()
|
|
381
|
+
if writer is None:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
merged_path = os.path.join(".omg", "state", "merged_config.json")
|
|
385
|
+
writer(merged_path, result)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass # Never crash on persistence
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _persist_conflicts(conflicts: List[Dict[str, Any]]) -> None:
|
|
391
|
+
"""Persist conflict log to .omg/state/config_conflicts.json."""
|
|
392
|
+
writer = _get_atomic_json_write()
|
|
393
|
+
if writer is None:
|
|
394
|
+
return
|
|
395
|
+
try:
|
|
396
|
+
conflicts_path = os.path.join(".omg", "state", "config_conflicts.json")
|
|
397
|
+
writer(conflicts_path, conflicts)
|
|
398
|
+
except Exception:
|
|
399
|
+
pass # Never crash on persistence
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_merged_config() -> Dict[str, Any]:
|
|
403
|
+
"""Load and return the persisted merged config.
|
|
404
|
+
|
|
405
|
+
Returns the full merged config dict from .omg/state/merged_config.json,
|
|
406
|
+
or an empty dict if the file doesn't exist or is unreadable.
|
|
407
|
+
"""
|
|
408
|
+
merged_path = os.path.join(".omg", "state", "merged_config.json")
|
|
409
|
+
if not os.path.isfile(merged_path):
|
|
410
|
+
return {}
|
|
411
|
+
try:
|
|
412
|
+
with open(merged_path, "r", encoding="utf-8") as f:
|
|
413
|
+
data = json.load(f)
|
|
414
|
+
return data if isinstance(data, dict) else {}
|
|
415
|
+
except (json.JSONDecodeError, OSError, IOError):
|
|
416
|
+
return {}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main():
|
|
420
|
+
"""CLI entry point."""
|
|
421
|
+
if len(sys.argv) < 3 or sys.argv[1] != "--merge":
|
|
422
|
+
print(
|
|
423
|
+
"Usage: python3 config_merger.py --merge <directory>",
|
|
424
|
+
file=sys.stderr,
|
|
425
|
+
)
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
|
|
428
|
+
project_dir = sys.argv[2]
|
|
429
|
+
|
|
430
|
+
# Lazy import discover_configs
|
|
431
|
+
tools_dir = os.path.dirname(os.path.abspath(__file__))
|
|
432
|
+
if tools_dir not in sys.path:
|
|
433
|
+
sys.path.insert(0, tools_dir)
|
|
434
|
+
try:
|
|
435
|
+
from config_discovery import discover_configs # type: ignore[import-untyped]
|
|
436
|
+
except ImportError:
|
|
437
|
+
print("Error: config_discovery module not available", file=sys.stderr)
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
discovery = discover_configs(project_dir)
|
|
441
|
+
discovered = discovery.get("discovered", [])
|
|
442
|
+
|
|
443
|
+
omg_config_path = os.path.join(project_dir, ".omg", "state", "omg_config.json")
|
|
444
|
+
result = merge_configs(discovered, omg_config_path)
|
|
445
|
+
print(json.dumps(result, indent=2))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
main()
|