delimit-cli 4.7.3 → 4.7.5
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/bin/delimit-cli.js +152 -1
- package/bin/delimit-setup.js +87 -118
- package/bin/delimit.js +10 -25
- package/gateway/ai/backends/governance_bridge.py +52 -0
- package/gateway/ai/backends/repo_bridge.py +12 -0
- package/gateway/ai/backends/tools_infra.py +43 -1
- package/gateway/ai/cli_contract.py +12 -0
- package/gateway/ai/custom_gemini_repl.py +80 -0
- package/gateway/ai/delimit_daemon.py +8 -0
- package/gateway/ai/gemini_vertex_shim.py +38 -0
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/release_sync.py +43 -8
- package/gateway/ai/route_daemon.py +98 -0
- package/gateway/ai/server.py +71 -1
- package/gateway/ai/session_phoenix.py +101 -136
- package/gateway/ai/supabase_sync.py +58 -0
- package/gateway/ai/swarm.py +2 -0
- package/gateway/ai/tui.py +143 -0
- package/gateway/core/ci_formatter.py +89 -61
- package/gateway/core/diff_engine_v2.py +208 -627
- package/gateway/core/explainer.py +67 -34
- package/lib/ai-sbom-engine.js +1 -0
- package/lib/auth-setup.js +10 -1
- package/lib/chat-repl.js +247 -0
- package/lib/cross-model-hooks.js +111 -0
- package/lib/timeline-engine.js +60 -0
- package/lib/wrap-engine.js +67 -11
- package/package.json +1 -1
package/bin/delimit.js
CHANGED
|
@@ -211,28 +211,13 @@ if (command === 'pre-commit-check' || command === 'pre-commit') {
|
|
|
211
211
|
execSync(`node ${path.join(__dirname, '../../scripts/install-governance.js')}`);
|
|
212
212
|
|
|
213
213
|
} else {
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
Internal Commands (called by hooks/shims):
|
|
226
|
-
pre-commit-check Run pre-commit governance
|
|
227
|
-
pre-push-check Run pre-push governance
|
|
228
|
-
proxy Proxy AI tool commands
|
|
229
|
-
|
|
230
|
-
After installation, Delimit will:
|
|
231
|
-
• Intercept all AI tool commands (claude, gemini, codex)
|
|
232
|
-
• Validate all Git commits and pushes
|
|
233
|
-
• Record evidence of all AI-assisted development
|
|
234
|
-
• Make ungoverned development impossible
|
|
235
|
-
|
|
236
|
-
Version: 1.0.0
|
|
237
|
-
`);
|
|
238
|
-
}
|
|
214
|
+
// Fall back to the full CLI wrapper for all other commands
|
|
215
|
+
const cliPath = path.join(__dirname, 'delimit-cli.js');
|
|
216
|
+
try {
|
|
217
|
+
const result = spawnSync('node', [cliPath, ...args], { stdio: 'inherit' });
|
|
218
|
+
process.exit(result.status || 0);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
error('Failed to execute delimit-cli: ' + e.message);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -169,6 +169,58 @@ def evaluate_trigger(action: str, context: Optional[Dict] = None, repo: str = ".
|
|
|
169
169
|
if not _is_initialized(repo):
|
|
170
170
|
return _not_init_response("gov.evaluate", action=action, repo=repo)
|
|
171
171
|
|
|
172
|
+
|
|
173
|
+
# LED-3012: execution_plan gate
|
|
174
|
+
if action == "execution_plan":
|
|
175
|
+
plan = (context or {}).get("plan", {})
|
|
176
|
+
if not plan:
|
|
177
|
+
return {
|
|
178
|
+
"tool": "gov.evaluate",
|
|
179
|
+
"status": "evaluated",
|
|
180
|
+
"action": action,
|
|
181
|
+
"verdict": "missing_input",
|
|
182
|
+
"error": "execution_plan action requires context.plan",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Policy: Execution plans for 'strategic' or 'production' targets
|
|
186
|
+
# MUST have risk_level='high' or 'critical'.
|
|
187
|
+
target = plan.get("target", "unknown")
|
|
188
|
+
risk = plan.get("risk_level", "low")
|
|
189
|
+
is_strategic = any(k in target.lower() for k in ("strat", "prod", "deploy"))
|
|
190
|
+
|
|
191
|
+
if is_strategic and risk not in ("high", "critical"):
|
|
192
|
+
return {
|
|
193
|
+
"tool": "gov.evaluate",
|
|
194
|
+
"status": "evaluated",
|
|
195
|
+
"action": action,
|
|
196
|
+
"verdict": "gate",
|
|
197
|
+
"reason": "Strategic execution plan requires 'high' or 'critical' risk level for manual review.",
|
|
198
|
+
"next_action": "Elevate risk_level to 'high' or 'critical' and re-evaluate.",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Policy: Execution plans MUST have at least 3 steps if complexity is 'large'.
|
|
202
|
+
complexity = plan.get("complexity", "medium")
|
|
203
|
+
steps = plan.get("steps", [])
|
|
204
|
+
if complexity == "large" and len(steps) < 3:
|
|
205
|
+
return {
|
|
206
|
+
"tool": "gov.evaluate",
|
|
207
|
+
"status": "evaluated",
|
|
208
|
+
"action": action,
|
|
209
|
+
"verdict": "gate",
|
|
210
|
+
"reason": "Large complexity plan must have at least 3 detailed execution steps.",
|
|
211
|
+
"next_action": "Expand plan steps to include implementation, testing, and validation.",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Default: Allow plan
|
|
215
|
+
return {
|
|
216
|
+
"tool": "gov.evaluate",
|
|
217
|
+
"status": "evaluated",
|
|
218
|
+
"action": action,
|
|
219
|
+
"verdict": "allow",
|
|
220
|
+
"message": "Execution plan passes policy checks.",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
172
224
|
# Pre-flight: external PR submission must check for duplicates first
|
|
173
225
|
if action == "external_pr":
|
|
174
226
|
target_repo = (context or {}).get("target_repo")
|
|
@@ -183,6 +183,18 @@ def evidence_collect(target: str = ".", options: Optional[Dict] = None) -> Dict[
|
|
|
183
183
|
if evidence_type:
|
|
184
184
|
evidence["evidence_type"] = evidence_type
|
|
185
185
|
|
|
186
|
+
|
|
187
|
+
# LED-3012: asset provenance support
|
|
188
|
+
if evidence_type == "asset":
|
|
189
|
+
asset_meta = opts.get("asset_meta", {})
|
|
190
|
+
if asset_meta:
|
|
191
|
+
evidence["asset_provenance"] = asset_meta
|
|
192
|
+
# Basic validation
|
|
193
|
+
required = ["asset_id", "hash", "source_prompt", "model", "rights_declaration"]
|
|
194
|
+
missing = [f for f in required if f not in asset_meta]
|
|
195
|
+
if missing:
|
|
196
|
+
evidence["provenance_warning"] = f"Missing required asset provenance fields: {', '.join(missing)}"
|
|
197
|
+
|
|
186
198
|
if is_remote:
|
|
187
199
|
# Remote/reference target — no filesystem walk, just record metadata.
|
|
188
200
|
evidence["target_type"] = "remote"
|
|
@@ -115,6 +115,36 @@ SCAN_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rb", ".java", "
|
|
|
115
115
|
# Skip directories
|
|
116
116
|
SKIP_DIRS = {"node_modules", ".git", "__pycache__", ".venv", "venv", ".tox", "dist", "build", ".next", ".nuxt", "vendor"}
|
|
117
117
|
|
|
118
|
+
# LED-1680/3008: default repo/root scans must not recurse through local
|
|
119
|
+
# assistant state stores. Those dirs routinely contain credentials, historical
|
|
120
|
+
# backups, chat transcripts, and plugin caches; scanning them as source creates
|
|
121
|
+
# noisy governance tasks and can leak secret snippets into audit output. Explicit
|
|
122
|
+
# scans of one of these directories, or of a single file inside one, still work.
|
|
123
|
+
OPERATIONAL_STATE_DIRS = {
|
|
124
|
+
".delimit",
|
|
125
|
+
".claude",
|
|
126
|
+
".codex",
|
|
127
|
+
".gemini",
|
|
128
|
+
".agents",
|
|
129
|
+
".config",
|
|
130
|
+
".local",
|
|
131
|
+
".cache",
|
|
132
|
+
".cloudflared",
|
|
133
|
+
}
|
|
134
|
+
OPERATIONAL_STATE_FILES = {".delimit.json", ".delimit-mcp.json"}
|
|
135
|
+
# Google service-account key files sometimes sit in the operator home root.
|
|
136
|
+
# Skip this filename shape only for broad home scans; repo scans still catch it.
|
|
137
|
+
HOME_OPERATIONAL_FILE_PATTERNS = (
|
|
138
|
+
re.compile(r"^[a-z][a-z0-9-]+-[0-9a-f]{12}\.json$", re.IGNORECASE),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_home_operational_file(filename: str, root: Path) -> bool:
|
|
143
|
+
return root == Path.home().resolve() and any(
|
|
144
|
+
pattern.match(filename) for pattern in HOME_OPERATIONAL_FILE_PATTERNS
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
118
148
|
# LED-1278 (a): test-tree path patterns excluded by default. The scanner walks # nosec
|
|
119
149
|
# test directories with prod rules, so test fixtures (placeholder tokens, # nosec
|
|
120
150
|
# trivial JWT bodies, code-injection demos) get surfaced as critical findings # nosec
|
|
@@ -156,6 +186,9 @@ KNOWN_DUMMY_PATTERNS = [
|
|
|
156
186
|
# Generic dict-credential placeholder values: fake/test/dummy/example/etc.
|
|
157
187
|
(re.compile(r"['\"](?:fake|test|dummy|example|placeholder|stale|from-)[A-Za-z0-9_\-]*['\"]\s*$", re.IGNORECASE),
|
|
158
188
|
"generic_placeholder_value"),
|
|
189
|
+
# Common documentation placeholder: token = "YOUR_DISCORD_BOT_TOKEN".
|
|
190
|
+
(re.compile(r"YOUR_[A-Z0-9_]*(?:TOKEN|SECRET|KEY)", re.IGNORECASE),
|
|
191
|
+
"your_token_placeholder"),
|
|
159
192
|
# Provider test-key shapes: xai-key-123, google-key-7, claude-key-2 etc.
|
|
160
193
|
(re.compile(r"['\"](?:xai|google|claude|gem|grok|codex|ollama)[-_]?key[-_]?\d+['\"]\s*$", re.IGNORECASE),
|
|
161
194
|
"provider_test_key"),
|
|
@@ -317,8 +350,12 @@ def _scan_files(target: str, include_tests: bool = False) -> List[Path]:
|
|
|
317
350
|
return [root]
|
|
318
351
|
if not root.is_dir():
|
|
319
352
|
return []
|
|
353
|
+
skip_operational_state = root.name not in OPERATIONAL_STATE_DIRS
|
|
320
354
|
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _err: None):
|
|
321
|
-
|
|
355
|
+
skipped_dirs = set(SKIP_DIRS)
|
|
356
|
+
if skip_operational_state:
|
|
357
|
+
skipped_dirs.update(OPERATIONAL_STATE_DIRS)
|
|
358
|
+
dirnames[:] = [d for d in dirnames if d not in skipped_dirs]
|
|
322
359
|
if not include_tests:
|
|
323
360
|
# Prune obvious test directory names before recursing so we don't
|
|
324
361
|
# walk huge __tests__/ trees just to discard them later.
|
|
@@ -327,6 +364,10 @@ def _scan_files(target: str, include_tests: bool = False) -> List[Path]:
|
|
|
327
364
|
if d not in ("tests", "test", "__tests__", "spec", "fixtures", "fixture")
|
|
328
365
|
]
|
|
329
366
|
for filename in filenames:
|
|
367
|
+
if skip_operational_state and filename in OPERATIONAL_STATE_FILES:
|
|
368
|
+
continue
|
|
369
|
+
if skip_operational_state and _is_home_operational_file(filename, root):
|
|
370
|
+
continue
|
|
330
371
|
p = Path(dirpath) / filename
|
|
331
372
|
if p.suffix not in SCAN_EXTENSIONS:
|
|
332
373
|
continue
|
|
@@ -591,6 +632,7 @@ def security_audit(target: str = ".", include_tests: bool = False) -> Dict[str,
|
|
|
591
632
|
"suppressed_findings": suppressed_findings[:20], # LED-1278 (b): allowlist audit log
|
|
592
633
|
"suppressed_count": len(suppressed_findings),
|
|
593
634
|
"include_tests": include_tests, # LED-1278 (a): expose scan scope
|
|
635
|
+
"operational_state_excluded": target_path.name not in OPERATIONAL_STATE_DIRS,
|
|
594
636
|
"env_in_git": env_in_git,
|
|
595
637
|
"severity_summary": severity_counts,
|
|
596
638
|
"tools_used": tools_used,
|
|
@@ -63,6 +63,15 @@ _CONTAMINATION_MARKERS = (
|
|
|
63
63
|
# back: "AGREE" / "DISAGREE" / "REMEDIATE" / "AGREE WITH MODIFICATIONS"
|
|
64
64
|
# all appear in real responses even when the trailing VERDICT line is
|
|
65
65
|
# omitted by a chatty model.
|
|
66
|
+
# LED-1415: specific patterns for common provider-side blocks (rate limits, caps)
|
|
67
|
+
_PROVIDER_BLOCK_RE = re.compile(
|
|
68
|
+
r"\b("
|
|
69
|
+
r"weekly\s+limit|monthly\s+spend\s+limit|rate\s+limit|too\s+many\s+requests|"
|
|
70
|
+
r"quota\s+exhausted|insufficient\s+balance|billing\s+account\s+not\s+active"
|
|
71
|
+
r")\b",
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
|
|
66
75
|
_VERDICT_HINT_RE = re.compile(
|
|
67
76
|
r"\b(VERDICT:|AGREE|DISAGREE|REMEDIATE|APPROVE|REJECT)\b",
|
|
68
77
|
re.IGNORECASE,
|
|
@@ -144,6 +153,9 @@ def validate_cli_contract(
|
|
|
144
153
|
|
|
145
154
|
# 3. Verdict hint — at least one of VERDICT:/AGREE/DISAGREE/REMEDIATE/
|
|
146
155
|
# APPROVE/REJECT must appear. Skip when expect_verdict_hint=False.
|
|
156
|
+
if _PROVIDER_BLOCK_RE.search(scrubbed):
|
|
157
|
+
failures.append("provider_rate_limit_or_cap")
|
|
158
|
+
|
|
147
159
|
if expect_verdict_hint and not _VERDICT_HINT_RE.search(scrubbed):
|
|
148
160
|
failures.append("no_verdict_hint")
|
|
149
161
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
import argparse
|
|
5
|
+
import readline # Enables history and arrow keys
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from google import genai
|
|
9
|
+
except ImportError:
|
|
10
|
+
print("Error: google-genai is not installed.")
|
|
11
|
+
sys.exit(1)
|
|
12
|
+
|
|
13
|
+
def print_banner():
|
|
14
|
+
print("\033[35m\033[1m ____ ________ ______ _____________\033[0m")
|
|
15
|
+
print("\033[35m\033[1m / __ \/ ____/ / / _/ |/ / _/_ __/\033[0m")
|
|
16
|
+
print("\033[91m\033[1m / / / / __/ / / / // /|_/ // / / / \033[0m")
|
|
17
|
+
print("\033[91m\033[1m / /_/ / /___/ /____/ // / / // / / / \033[0m")
|
|
18
|
+
print("\033[33m\033[1m/_____/_____/_____/___/_/ /_/___/ /_/ \033[0m")
|
|
19
|
+
print(" \033[2mNative Vertex AI Edition\033[0m\n")
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
parser = argparse.ArgumentParser(description="Custom Gemini Vertex REPL")
|
|
23
|
+
parser.add_argument("-p", "--prompt", type=str, help="Initial prompt")
|
|
24
|
+
parser.add_argument("-m", "--model", type=str, default="gemini-3.1-pro-preview", help="Model name")
|
|
25
|
+
parser.add_argument("-y", "--yolo", action="store_true", help="YOLO mode")
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
project = os.environ.get("GOOGLE_CLOUD_PROJECT", "jamsons")
|
|
29
|
+
location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
client = genai.Client(vertexai=True, project=project, location=location)
|
|
33
|
+
chat = client.chats.create(model=args.model)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"\n[Vertex API Initialization Error] {e}", file=sys.stderr)
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
if not os.environ.get("DELIMIT_QUIET") == "true":
|
|
39
|
+
print_banner()
|
|
40
|
+
|
|
41
|
+
# If an initial prompt was provided (e.g. from Auto-Phoenix), execute it and return
|
|
42
|
+
if args.prompt:
|
|
43
|
+
try:
|
|
44
|
+
response = chat.send_message_stream(args.prompt)
|
|
45
|
+
for chunk in response:
|
|
46
|
+
if chunk.text:
|
|
47
|
+
sys.stdout.write(chunk.text)
|
|
48
|
+
sys.stdout.flush()
|
|
49
|
+
print()
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(f"\n[Vertex API Error] {e}", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
# Interactive Loop
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
user_input = input("\033[36mgemini>\033[0m ")
|
|
59
|
+
if not user_input.strip():
|
|
60
|
+
continue
|
|
61
|
+
if user_input.strip() in ("/exit", "/quit"):
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
response = chat.send_message_stream(user_input)
|
|
65
|
+
for chunk in response:
|
|
66
|
+
if chunk.text:
|
|
67
|
+
sys.stdout.write(chunk.text)
|
|
68
|
+
sys.stdout.flush()
|
|
69
|
+
print()
|
|
70
|
+
|
|
71
|
+
except EOFError:
|
|
72
|
+
break
|
|
73
|
+
except KeyboardInterrupt:
|
|
74
|
+
print("\n(Ctrl+C) Type /exit to quit.")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f"\n[Vertex API Error] {e}", file=sys.stderr)
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
from ai.inbox_daemon import start_daemon as start_inbox, stop_daemon as stop_inbox
|
|
20
20
|
from ai.social_daemon import start_daemon as start_social, stop_daemon as stop_social
|
|
21
21
|
from ai.self_repair_daemon import start_daemon as start_self_repair, stop_daemon as stop_self_repair
|
|
22
|
+
from ai.route_daemon import start_daemon as start_route, stop_daemon as stop_route
|
|
22
23
|
|
|
23
24
|
logging.basicConfig(
|
|
24
25
|
level=logging.INFO,
|
|
@@ -40,6 +41,10 @@ def _handle_sigterm(signum, frame):
|
|
|
40
41
|
stop_self_repair()
|
|
41
42
|
except Exception as e:
|
|
42
43
|
logger.error(f"Error stopping self_repair: {e}")
|
|
44
|
+
try:
|
|
45
|
+
stop_route()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Error stopping route_daemon: {e}")
|
|
43
48
|
sys.exit(0)
|
|
44
49
|
|
|
45
50
|
def main():
|
|
@@ -56,6 +61,9 @@ def main():
|
|
|
56
61
|
|
|
57
62
|
repair_res = start_self_repair()
|
|
58
63
|
logger.info(f"Self-repair daemon: {repair_res.get('status')}")
|
|
64
|
+
|
|
65
|
+
route_res = start_route()
|
|
66
|
+
logger.info(f"Route daemon: {route_res.get('status')}")
|
|
59
67
|
|
|
60
68
|
try:
|
|
61
69
|
while True:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from google import genai
|
|
8
|
+
except ImportError:
|
|
9
|
+
print("Error: google-genai is not installed.")
|
|
10
|
+
sys.exit(1)
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
parser = argparse.ArgumentParser()
|
|
14
|
+
parser.add_argument("-p", "--prompt", type=str)
|
|
15
|
+
parser.add_argument("-m", "--model", type=str, default="gemini-3.1-pro-preview")
|
|
16
|
+
parser.add_argument("-y", "--yolo", action="store_true")
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
# Try AI Studio (non-Vertex) first, since 3.1 is in preview there.
|
|
20
|
+
# It will automatically pick up GOOGLE_API_KEY from environment or ADC.
|
|
21
|
+
try:
|
|
22
|
+
# vertexai=False targets generativelanguage.googleapis.com
|
|
23
|
+
client = genai.Client(vertexai=False)
|
|
24
|
+
response = client.models.generate_content_stream(
|
|
25
|
+
model=args.model,
|
|
26
|
+
contents=args.prompt,
|
|
27
|
+
)
|
|
28
|
+
for chunk in response:
|
|
29
|
+
if chunk.text:
|
|
30
|
+
sys.stdout.write(chunk.text)
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
print()
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"\n[AI Studio API Error] {e}", file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
Binary file
|
|
@@ -16,6 +16,11 @@ from typing import Any, Dict, List, Optional
|
|
|
16
16
|
|
|
17
17
|
RELEASE_CONFIG = Path.home() / ".delimit" / "release.json"
|
|
18
18
|
|
|
19
|
+
# Known on-host location of the delimit.ai Next.js site (app-router). The
|
|
20
|
+
# UI lives outside the home dir on the build host; kept as a module-level
|
|
21
|
+
# constant so the site-title check can find it and so tests can patch it.
|
|
22
|
+
SITE_ROOT_FALLBACK = Path("/home/delimit/delimit-ui")
|
|
23
|
+
|
|
19
24
|
DEFAULT_CONFIG = {
|
|
20
25
|
"product_name": "Delimit",
|
|
21
26
|
"tagline": "Governance toolkit for AI coding assistants",
|
|
@@ -98,6 +103,16 @@ def audit(config: Optional[Dict] = None) -> Dict[str, Any]:
|
|
|
98
103
|
cfg = config or get_release_config()
|
|
99
104
|
tagline = cfg.get("tagline", "")
|
|
100
105
|
description = cfg.get("description", "")
|
|
106
|
+
# Optional per-surface expected values. Maps a surface label to the
|
|
107
|
+
# expected description (or substring). Surfaces without an entry fall
|
|
108
|
+
# back to the tagline. Backward compatible: configs without this key
|
|
109
|
+
# behave exactly as before (every surface expects the tagline).
|
|
110
|
+
surface_expectations = cfg.get("surface_expectations", {}) or {}
|
|
111
|
+
|
|
112
|
+
def _expected_for(surface: str) -> str:
|
|
113
|
+
"""Per-surface expected value, falling back to the tagline."""
|
|
114
|
+
return surface_expectations.get(surface, tagline)
|
|
115
|
+
|
|
101
116
|
results = []
|
|
102
117
|
|
|
103
118
|
# 1. npm package.json
|
|
@@ -132,6 +147,7 @@ def audit(config: Optional[Dict] = None) -> Dict[str, Any]:
|
|
|
132
147
|
("delimit-ai/delimit-action", "GitHub: delimit-action repo"),
|
|
133
148
|
("delimit-ai/delimit-quickstart", "GitHub: quickstart repo"),
|
|
134
149
|
]:
|
|
150
|
+
expected = _expected_for(surface)
|
|
135
151
|
try:
|
|
136
152
|
r = subprocess.run(
|
|
137
153
|
["gh", "api", f"repos/{repo}", "--jq", ".description"],
|
|
@@ -139,10 +155,10 @@ def audit(config: Optional[Dict] = None) -> Dict[str, Any]:
|
|
|
139
155
|
)
|
|
140
156
|
if r.returncode == 0:
|
|
141
157
|
desc = r.stdout.strip()
|
|
142
|
-
if
|
|
158
|
+
if expected.lower() in desc.lower():
|
|
143
159
|
results.append({"surface": surface, "status": "ok", "current": desc[:100]})
|
|
144
160
|
else:
|
|
145
|
-
results.append({"surface": surface, "status": "stale", "current": desc[:100], "expected":
|
|
161
|
+
results.append({"surface": surface, "status": "stale", "current": desc[:100], "expected": expected})
|
|
146
162
|
else:
|
|
147
163
|
results.append({"surface": surface, "status": "error", "detail": "gh API failed"})
|
|
148
164
|
except Exception:
|
|
@@ -160,16 +176,35 @@ def audit(config: Optional[Dict] = None) -> Dict[str, Any]:
|
|
|
160
176
|
except Exception:
|
|
161
177
|
results.append({"surface": "GitHub: org description", "status": "skipped"})
|
|
162
178
|
|
|
163
|
-
# 5. delimit.ai meta
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
# 5. delimit.ai meta title (Next.js app-router root layout).
|
|
180
|
+
# Verify the site title/metadata mentions the product name. Soft check:
|
|
181
|
+
# skip with a reason if the layout file is genuinely absent. Path is
|
|
182
|
+
# tolerant of both app/ and src/app/ project layouts, and overridable
|
|
183
|
+
# via cfg["site_layout_path"].
|
|
184
|
+
product_name = cfg.get("product_name", "Delimit")
|
|
185
|
+
layout_candidates = []
|
|
186
|
+
configured = cfg.get("site_layout_path") or os.environ.get("DELIMIT_SITE_LAYOUT")
|
|
187
|
+
if configured:
|
|
188
|
+
layout_candidates.append(Path(configured))
|
|
189
|
+
# Search a few known site roots (this host keeps the UI under
|
|
190
|
+
# /home/delimit/delimit-ui, but a customer install may keep it under
|
|
191
|
+
# the home dir). For each root, try both app-router layouts.
|
|
192
|
+
site_roots = [
|
|
193
|
+
Path.home() / "delimit-ui",
|
|
194
|
+
SITE_ROOT_FALLBACK,
|
|
195
|
+
]
|
|
196
|
+
for site_root in site_roots:
|
|
197
|
+
layout_candidates += [
|
|
198
|
+
site_root / "app" / "layout.tsx",
|
|
199
|
+
site_root / "src" / "app" / "layout.tsx",
|
|
200
|
+
]
|
|
201
|
+
for layout_path in layout_candidates:
|
|
167
202
|
if layout_path.exists():
|
|
168
203
|
layout = layout_path.read_text()
|
|
169
|
-
results.append(_check_contains(layout,
|
|
204
|
+
results.append(_check_contains(layout, product_name, "delimit.ai meta title"))
|
|
170
205
|
break
|
|
171
206
|
else:
|
|
172
|
-
results.append({"surface": "delimit.ai meta title", "status": "skipped", "detail": "layout.tsx not found"})
|
|
207
|
+
results.append({"surface": "delimit.ai meta title", "status": "skipped", "detail": "layout.tsx not found (tried app/ and src/app/)"})
|
|
173
208
|
|
|
174
209
|
# 6. Gateway version
|
|
175
210
|
for pyproject_path in [
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import os
|
|
4
|
+
import urllib.request
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Thread
|
|
8
|
+
|
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
|
10
|
+
logger = logging.getLogger("delimit.route_daemon")
|
|
11
|
+
|
|
12
|
+
MODELS_JSON = Path.home() / ".delimit" / "models.json"
|
|
13
|
+
ROUTES_JSON = Path.home() / ".delimit" / "routes.json"
|
|
14
|
+
|
|
15
|
+
def resolve_aliases():
|
|
16
|
+
"""
|
|
17
|
+
Ping /v1/models across providers in models.json and cache the
|
|
18
|
+
resolved models to routes.json to map '-latest' aliases to concrete versions.
|
|
19
|
+
"""
|
|
20
|
+
if not MODELS_JSON.exists():
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with open(MODELS_JSON, "r") as f:
|
|
25
|
+
models_config = json.load(f)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.error(f"Failed to read models.json: {e}")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
routes = {}
|
|
31
|
+
|
|
32
|
+
for provider, config in models_config.items():
|
|
33
|
+
if not isinstance(config, dict) or not config.get("enabled", False):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
api_url = config.get("api_url")
|
|
37
|
+
api_key = config.get("api_key")
|
|
38
|
+
|
|
39
|
+
if not api_url or not api_key:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Parse base URL for /v1/models (e.g. from https://api.openai.com/v1/chat/completions)
|
|
43
|
+
if "/chat/completions" in api_url:
|
|
44
|
+
base_url = api_url.replace("/chat/completions", "/models")
|
|
45
|
+
elif "/messages" in api_url:
|
|
46
|
+
base_url = api_url.replace("/messages", "/models")
|
|
47
|
+
else:
|
|
48
|
+
base_url = api_url + "/models" if not api_url.endswith("/models") else api_url
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
req = urllib.request.Request(base_url, headers={
|
|
52
|
+
"Authorization": f"Bearer {api_key}",
|
|
53
|
+
"x-api-key": api_key,
|
|
54
|
+
"anthropic-version": "2023-06-01"
|
|
55
|
+
})
|
|
56
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
57
|
+
data = json.loads(response.read().decode())
|
|
58
|
+
# Anthropic doesn't currently support /v1/models in the exact same way as OpenAI,
|
|
59
|
+
# but assuming a standard schema for the sake of the task.
|
|
60
|
+
if "data" in data:
|
|
61
|
+
models = [m["id"] for m in data["data"] if "id" in m]
|
|
62
|
+
elif isinstance(data, list):
|
|
63
|
+
models = [m.get("id", m) for m in data]
|
|
64
|
+
else:
|
|
65
|
+
models = []
|
|
66
|
+
|
|
67
|
+
# We want to map '-latest' or find the concrete models
|
|
68
|
+
# Let's just store all available models for this provider
|
|
69
|
+
routes[provider] = models
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Failed to fetch models for {provider} at {base_url}: {e}")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
ROUTES_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
with open(ROUTES_JSON, "w") as f:
|
|
76
|
+
json.dump(routes, f, indent=2)
|
|
77
|
+
logger.info("Successfully updated routes.json")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Failed to write routes.json: {e}")
|
|
80
|
+
|
|
81
|
+
_daemon_running = False
|
|
82
|
+
|
|
83
|
+
def run_loop():
|
|
84
|
+
global _daemon_running
|
|
85
|
+
_daemon_running = True
|
|
86
|
+
while _daemon_running:
|
|
87
|
+
resolve_aliases()
|
|
88
|
+
time.sleep(3600) # Check every hour
|
|
89
|
+
|
|
90
|
+
def start_daemon():
|
|
91
|
+
thread = Thread(target=run_loop, daemon=True)
|
|
92
|
+
thread.start()
|
|
93
|
+
return {"status": "started"}
|
|
94
|
+
|
|
95
|
+
def stop_daemon():
|
|
96
|
+
global _daemon_running
|
|
97
|
+
_daemon_running = False
|
|
98
|
+
return {"status": "stopped"}
|