delimit-cli 3.7.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/delimit-setup.js +7 -6
- package/gateway/ai/backends/tools_infra.py +5 -1
- package/gateway/ai/backends/tools_real.py +14 -8
- package/gateway/ai/deliberation.py +65 -38
- package/gateway/ai/release_sync.py +217 -0
- package/gateway/ai/server.py +251 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ That's it. Delimit auto-fetches the base branch spec, diffs it, and posts a PR c
|
|
|
39
39
|
- Step-by-step migration guide
|
|
40
40
|
- Policy violations
|
|
41
41
|
|
|
42
|
-
[View on GitHub Marketplace →](https://github.com/marketplace/actions/delimit-api-governance) · [See a live
|
|
42
|
+
[View on GitHub Marketplace →](https://github.com/marketplace/actions/delimit-api-governance) · [See a live demo (6 breaking changes) →](https://github.com/delimit-ai/delimit-demo/pull/1)
|
|
43
43
|
|
|
44
44
|
### Example PR comment
|
|
45
45
|
|
package/bin/delimit-setup.js
CHANGED
|
@@ -339,7 +339,7 @@ Run full governance compliance checks. Verify security, policy compliance, evide
|
|
|
339
339
|
models.gemini = { name: 'Gemini', api_url: `https://us-central1-aiplatform.googleapis.com/v1/projects/{project}/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent`, model: 'gemini-2.5-flash', format: 'vertex_ai', enabled: true };
|
|
340
340
|
}
|
|
341
341
|
if (process.env.OPENAI_API_KEY) {
|
|
342
|
-
models.openai = { name: '
|
|
342
|
+
models.openai = { name: 'OpenAI', api_url: 'https://api.openai.com/v1/chat/completions', model: 'gpt-4o', env_key: 'OPENAI_API_KEY', prefer_cli: true, enabled: true };
|
|
343
343
|
}
|
|
344
344
|
if (process.env.ANTHROPIC_API_KEY) {
|
|
345
345
|
models.anthropic = { name: 'Claude', api_url: 'https://api.anthropic.com/v1/messages', model: 'claude-sonnet-4-5-20250514', env_key: 'ANTHROPIC_API_KEY', format: 'anthropic', enabled: true };
|
|
@@ -365,12 +365,12 @@ Run full governance compliance checks. Verify security, policy compliance, evide
|
|
|
365
365
|
log(' Try it now:');
|
|
366
366
|
log(` ${bold('$ claude')}`);
|
|
367
367
|
log('');
|
|
368
|
-
log(` Then say: ${blue('"
|
|
368
|
+
log(` Then say: ${blue('"scan this project"')}`);
|
|
369
369
|
log('');
|
|
370
370
|
log(' Or try:');
|
|
371
|
-
log(` ${dim('-')} "
|
|
372
|
-
log(` ${dim('-')} "
|
|
373
|
-
log(` ${dim('-')} "
|
|
371
|
+
log(` ${dim('-')} "lint my API spec" ${dim('— catch breaking changes')}`);
|
|
372
|
+
log(` ${dim('-')} "add to ledger: set up CI pipeline" ${dim('— track tasks across sessions')}`);
|
|
373
|
+
log(` ${dim('-')} "deliberate [question]" ${dim('— multi-model AI consensus')}`);
|
|
374
374
|
log('');
|
|
375
375
|
log(` ${dim('Config:')} ${MCP_CONFIG}`);
|
|
376
376
|
log(` ${dim('Server:')} ${actualServer}`);
|
|
@@ -387,10 +387,11 @@ function getClaudeMdContent() {
|
|
|
387
387
|
One workspace for every AI coding assistant.
|
|
388
388
|
|
|
389
389
|
## Try these:
|
|
390
|
+
- "scan this project" -- discover what Delimit can do here
|
|
390
391
|
- "lint my API spec" -- catch breaking changes in your OpenAPI spec
|
|
391
392
|
- "add to ledger: [anything]" -- track tasks across sessions
|
|
392
393
|
- "what's on the ledger?" -- pick up where you left off
|
|
393
|
-
- "
|
|
394
|
+
- "deliberate [question]" -- get multi-model AI consensus
|
|
394
395
|
|
|
395
396
|
## What Delimit does:
|
|
396
397
|
- **API governance** -- lint, diff, semver classification, migration guides
|
|
@@ -924,7 +924,11 @@ def deploy_site(project_path: str = ".", message: str = "", env_vars: dict = Non
|
|
|
924
924
|
# 4. Vercel build
|
|
925
925
|
env = {**os.environ}
|
|
926
926
|
if env_vars:
|
|
927
|
-
env.
|
|
927
|
+
# Whitelist safe env var prefixes — block LD_PRELOAD, PATH overrides, etc.
|
|
928
|
+
blocked = {"LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_", "PATH", "HOME", "USER", "SHELL"}
|
|
929
|
+
for k, v in env_vars.items():
|
|
930
|
+
if not any(k.startswith(b) for b in blocked):
|
|
931
|
+
env[str(k)] = str(v)
|
|
928
932
|
|
|
929
933
|
try:
|
|
930
934
|
result = subprocess.run(
|
|
@@ -350,29 +350,35 @@ def test_smoke(project_path: str, test_suite: Optional[str] = None) -> Dict[str,
|
|
|
350
350
|
framework = detected["framework"]
|
|
351
351
|
cmd = detected["cmd"]
|
|
352
352
|
|
|
353
|
-
#
|
|
353
|
+
# Build command as list (never shell=True with user input)
|
|
354
|
+
import shlex
|
|
355
|
+
cmd_list = shlex.split(cmd)
|
|
356
|
+
|
|
357
|
+
# If a specific suite is requested, validate and append
|
|
354
358
|
if test_suite:
|
|
355
|
-
|
|
359
|
+
# Sanitize: only allow alphanumeric, slashes, dots, underscores, hyphens, colons
|
|
360
|
+
import re
|
|
361
|
+
if not re.match(r'^[\w/.\-:*\[\]]+$', test_suite):
|
|
362
|
+
return {"tool": "test.smoke", "status": "error", "error": f"Invalid test_suite: {test_suite}"}
|
|
363
|
+
cmd_list.append(test_suite)
|
|
356
364
|
|
|
357
365
|
# Detect the right Python executable
|
|
358
366
|
if framework == "pytest":
|
|
359
367
|
python_found = False
|
|
360
|
-
# Check for venv
|
|
361
368
|
for venv_dir in ["venv", ".venv", "env"]:
|
|
362
369
|
venv_python = project / venv_dir / "bin" / "python"
|
|
363
370
|
if venv_python.exists():
|
|
364
|
-
|
|
371
|
+
cmd_list[0] = str(venv_python)
|
|
365
372
|
python_found = True
|
|
366
373
|
break
|
|
367
|
-
# Fallback to the current interpreter (handles systems where `python` is missing)
|
|
368
374
|
if not python_found:
|
|
369
375
|
import sys as _sys
|
|
370
|
-
|
|
376
|
+
cmd_list[0] = _sys.executable
|
|
371
377
|
|
|
372
378
|
try:
|
|
373
379
|
result = subprocess.run(
|
|
374
|
-
|
|
375
|
-
shell=
|
|
380
|
+
cmd_list,
|
|
381
|
+
shell=False,
|
|
376
382
|
cwd=str(project),
|
|
377
383
|
capture_output=True,
|
|
378
384
|
text=True,
|
|
@@ -38,13 +38,16 @@ DEFAULT_MODELS = {
|
|
|
38
38
|
"env_key": "GOOGLE_APPLICATION_CREDENTIALS",
|
|
39
39
|
"enabled": False,
|
|
40
40
|
"format": "vertex_ai",
|
|
41
|
+
"prefer_cli": True, # Use gemini CLI if available (Ultra plan), fall back to Vertex AI
|
|
42
|
+
"cli_command": "gemini",
|
|
41
43
|
},
|
|
42
44
|
"openai": {
|
|
43
|
-
"name": "
|
|
45
|
+
"name": "OpenAI",
|
|
44
46
|
"api_url": "https://api.openai.com/v1/chat/completions",
|
|
45
47
|
"model": "gpt-4o",
|
|
46
48
|
"env_key": "OPENAI_API_KEY",
|
|
47
49
|
"enabled": False,
|
|
50
|
+
"prefer_cli": True, # Use Codex CLI if available, fall back to API
|
|
48
51
|
},
|
|
49
52
|
"anthropic": {
|
|
50
53
|
"name": "Claude",
|
|
@@ -53,13 +56,8 @@ DEFAULT_MODELS = {
|
|
|
53
56
|
"env_key": "ANTHROPIC_API_KEY",
|
|
54
57
|
"enabled": False,
|
|
55
58
|
"format": "anthropic",
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"name": "Codex CLI",
|
|
59
|
-
"format": "codex_cli",
|
|
60
|
-
"model": "gpt-5.4",
|
|
61
|
-
"env_key": "CODEX_CLI",
|
|
62
|
-
"enabled": False,
|
|
59
|
+
"prefer_cli": True, # Use claude CLI if available (Pro/Max), fall back to API
|
|
60
|
+
"cli_command": "claude",
|
|
63
61
|
},
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -75,17 +73,31 @@ def get_models_config() -> Dict[str, Any]:
|
|
|
75
73
|
# Auto-detect from environment
|
|
76
74
|
config = {}
|
|
77
75
|
for model_id, defaults in DEFAULT_MODELS.items():
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
key = os.environ.get(defaults.get("env_key", ""), "")
|
|
77
|
+
|
|
78
|
+
if defaults.get("prefer_cli"):
|
|
79
|
+
# Prefer CLI (uses existing subscription) over API (extra cost)
|
|
80
80
|
import shutil
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
cli_cmd = defaults.get("cli_command", "codex")
|
|
82
|
+
cli_path = shutil.which(cli_cmd)
|
|
83
|
+
if cli_path:
|
|
84
|
+
config[model_id] = {
|
|
85
|
+
**defaults,
|
|
86
|
+
"format": "codex_cli",
|
|
87
|
+
"enabled": True,
|
|
88
|
+
"codex_path": cli_path,
|
|
89
|
+
"backend": "cli",
|
|
90
|
+
}
|
|
91
|
+
elif key:
|
|
92
|
+
config[model_id] = {
|
|
93
|
+
**defaults,
|
|
94
|
+
"api_key": key,
|
|
95
|
+
"enabled": True,
|
|
96
|
+
"backend": "api",
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
config[model_id] = {**defaults, "enabled": False}
|
|
87
100
|
else:
|
|
88
|
-
key = os.environ.get(defaults["env_key"], "")
|
|
89
101
|
config[model_id] = {
|
|
90
102
|
**defaults,
|
|
91
103
|
"api_key": key,
|
|
@@ -101,47 +113,62 @@ def configure_models() -> Dict[str, Any]:
|
|
|
101
113
|
available = {k: v for k, v in config.items() if v.get("enabled")}
|
|
102
114
|
missing = {k: v for k, v in config.items() if not v.get("enabled")}
|
|
103
115
|
|
|
116
|
+
model_details = {}
|
|
117
|
+
for k, v in available.items():
|
|
118
|
+
backend = v.get("backend", "api")
|
|
119
|
+
if v.get("format") == "codex_cli":
|
|
120
|
+
backend = "cli"
|
|
121
|
+
model_details[k] = {"name": v.get("name", k), "backend": backend, "model": v.get("model", "")}
|
|
122
|
+
|
|
104
123
|
return {
|
|
105
124
|
"configured_models": list(available.keys()),
|
|
106
|
-
"
|
|
125
|
+
"model_details": model_details,
|
|
126
|
+
"missing_models": {k: f"Set {v.get('env_key', 'key')} or install {v.get('cli_command', '')} CLI" for k, v in missing.items()},
|
|
107
127
|
"config_path": str(MODELS_CONFIG),
|
|
108
|
-
"note": "
|
|
109
|
-
"
|
|
128
|
+
"note": "CLI backends use your existing subscription (no extra API cost). "
|
|
129
|
+
"API backends require separate API keys.",
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
|
|
113
|
-
def
|
|
114
|
-
"""Call
|
|
133
|
+
def _call_cli(prompt: str, system_prompt: str = "", cli_path: str = "", cli_command: str = "codex") -> str:
|
|
134
|
+
"""Call an AI CLI tool (codex or claude) via subprocess. Uses existing subscription — no API cost."""
|
|
115
135
|
import subprocess
|
|
116
|
-
|
|
117
|
-
if not
|
|
118
|
-
|
|
136
|
+
|
|
137
|
+
if not cli_path:
|
|
138
|
+
cli_path = shutil.which(cli_command) or ""
|
|
139
|
+
if not cli_path:
|
|
140
|
+
return f"[{cli_command} unavailable — CLI not found in PATH]"
|
|
119
141
|
|
|
120
142
|
full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
|
|
143
|
+
|
|
144
|
+
# Build command based on which CLI
|
|
145
|
+
if "claude" in cli_command:
|
|
146
|
+
cmd = [cli_path, "--print", "--dangerously-skip-permissions", full_prompt]
|
|
147
|
+
else:
|
|
148
|
+
# codex
|
|
149
|
+
cmd = [cli_path, "exec", "--dangerously-bypass-approvals-and-sandbox", full_prompt]
|
|
150
|
+
|
|
121
151
|
try:
|
|
122
|
-
result = subprocess.run(
|
|
123
|
-
[codex_path, "exec", "--dangerously-bypass-approvals-and-sandbox", full_prompt],
|
|
124
|
-
capture_output=True,
|
|
125
|
-
text=True,
|
|
126
|
-
timeout=120,
|
|
127
|
-
)
|
|
152
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
128
153
|
output = result.stdout.strip()
|
|
129
154
|
if not output and result.stderr:
|
|
130
|
-
return f"[
|
|
131
|
-
return output or "[
|
|
155
|
+
return f"[{cli_command} error: {result.stderr[:300]}]"
|
|
156
|
+
return output or f"[{cli_command} returned empty response]"
|
|
132
157
|
except subprocess.TimeoutExpired:
|
|
133
|
-
return "[
|
|
158
|
+
return f"[{cli_command} timed out after 120s]"
|
|
134
159
|
except Exception as e:
|
|
135
|
-
return f"[
|
|
160
|
+
return f"[{cli_command} error: {e}]"
|
|
136
161
|
|
|
137
162
|
|
|
138
163
|
def _call_model(model_id: str, config: Dict, prompt: str, system_prompt: str = "") -> str:
|
|
139
|
-
"""Call any supported model — OpenAI-compatible API, Vertex AI, or
|
|
164
|
+
"""Call any supported model — OpenAI-compatible API, Vertex AI, or CLI (codex/claude)."""
|
|
140
165
|
fmt = config.get("format", "openai")
|
|
141
166
|
|
|
142
|
-
#
|
|
167
|
+
# CLI-based models (codex, claude) — uses existing subscription, no API cost
|
|
143
168
|
if fmt == "codex_cli":
|
|
144
|
-
|
|
169
|
+
cli_path = config.get("codex_path", "")
|
|
170
|
+
cli_command = config.get("cli_command", "codex")
|
|
171
|
+
return _call_cli(prompt, system_prompt, cli_path=cli_path, cli_command=cli_command)
|
|
145
172
|
|
|
146
173
|
api_key = config.get("api_key") or os.environ.get(config.get("env_key", ""), "")
|
|
147
174
|
# Vertex AI uses service account auth, not API key
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Delimit Release Sync — single source of truth for all public surfaces.
|
|
3
|
+
|
|
4
|
+
Audit mode: scans all surfaces and reports inconsistencies.
|
|
5
|
+
Apply mode: fixes what it can automatically.
|
|
6
|
+
|
|
7
|
+
Central config: ~/.delimit/release.json
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
RELEASE_CONFIG = Path.home() / ".delimit" / "release.json"
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG = {
|
|
20
|
+
"product_name": "Delimit",
|
|
21
|
+
"tagline": "Governance toolkit for AI coding assistants",
|
|
22
|
+
"description": "Governance toolkit for AI coding assistants — API checks, persistent memory, consensus, security.",
|
|
23
|
+
"version": {
|
|
24
|
+
"cli": "", # filled dynamically
|
|
25
|
+
"action": "",
|
|
26
|
+
"gateway": "",
|
|
27
|
+
},
|
|
28
|
+
"urls": {
|
|
29
|
+
"homepage": "https://delimit.ai",
|
|
30
|
+
"docs": "https://delimit.ai/docs",
|
|
31
|
+
"github": "https://github.com/delimit-ai/delimit",
|
|
32
|
+
"action": "https://github.com/marketplace/actions/delimit-api-governance",
|
|
33
|
+
"npm": "https://www.npmjs.com/package/delimit-cli",
|
|
34
|
+
"quickstart": "https://github.com/delimit-ai/delimit-quickstart",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_release_config() -> Dict[str, Any]:
|
|
40
|
+
"""Load or create the release config."""
|
|
41
|
+
if RELEASE_CONFIG.exists():
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(RELEASE_CONFIG.read_text())
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
return DEFAULT_CONFIG.copy()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_release_config(config: Dict[str, Any]) -> None:
|
|
50
|
+
"""Save the release config."""
|
|
51
|
+
RELEASE_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
RELEASE_CONFIG.write_text(json.dumps(config, indent=2))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_file(path: str) -> Optional[str]:
|
|
56
|
+
"""Read a file, return None if missing."""
|
|
57
|
+
try:
|
|
58
|
+
return Path(path).read_text()
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_contains(content: str, expected: str, surface: str) -> Dict:
|
|
64
|
+
"""Check if content contains expected string."""
|
|
65
|
+
if content is None:
|
|
66
|
+
return {"surface": surface, "status": "missing", "detail": "File not found"}
|
|
67
|
+
if expected.lower() in content.lower():
|
|
68
|
+
return {"surface": surface, "status": "ok"}
|
|
69
|
+
return {
|
|
70
|
+
"surface": surface,
|
|
71
|
+
"status": "stale",
|
|
72
|
+
"expected": expected,
|
|
73
|
+
"detail": f"Does not contain: {expected[:80]}",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_npm_version(pkg_path: str) -> str:
|
|
78
|
+
"""Read version from package.json."""
|
|
79
|
+
try:
|
|
80
|
+
pkg = json.loads(Path(pkg_path).read_text())
|
|
81
|
+
return pkg.get("version", "")
|
|
82
|
+
except Exception:
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_pyproject_version(path: str) -> str:
|
|
87
|
+
"""Read version from pyproject.toml."""
|
|
88
|
+
try:
|
|
89
|
+
content = Path(path).read_text()
|
|
90
|
+
m = re.search(r'version\s*=\s*"([^"]+)"', content)
|
|
91
|
+
return m.group(1) if m else ""
|
|
92
|
+
except Exception:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def audit(config: Optional[Dict] = None) -> Dict[str, Any]:
|
|
97
|
+
"""Audit all public surfaces for consistency with the release config."""
|
|
98
|
+
cfg = config or get_release_config()
|
|
99
|
+
tagline = cfg.get("tagline", "")
|
|
100
|
+
description = cfg.get("description", "")
|
|
101
|
+
results = []
|
|
102
|
+
|
|
103
|
+
# 1. npm package.json
|
|
104
|
+
npm_pkg = _read_file(os.path.expanduser("~/.delimit/server/../../../npm-delimit/package.json"))
|
|
105
|
+
# Try common locations
|
|
106
|
+
for candidate in [
|
|
107
|
+
Path.home() / "npm-delimit" / "package.json",
|
|
108
|
+
]:
|
|
109
|
+
if candidate.exists():
|
|
110
|
+
npm_pkg = candidate.read_text()
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if npm_pkg:
|
|
114
|
+
try:
|
|
115
|
+
pkg = json.loads(npm_pkg)
|
|
116
|
+
pkg_desc = pkg.get("description", "")
|
|
117
|
+
if tagline.lower() not in pkg_desc.lower():
|
|
118
|
+
results.append({"surface": "npm package.json description", "status": "stale", "current": pkg_desc[:100], "expected": description})
|
|
119
|
+
else:
|
|
120
|
+
results.append({"surface": "npm package.json description", "status": "ok"})
|
|
121
|
+
cfg.setdefault("version", {})["cli"] = pkg.get("version", "")
|
|
122
|
+
except Exception:
|
|
123
|
+
results.append({"surface": "npm package.json", "status": "error", "detail": "Could not parse"})
|
|
124
|
+
|
|
125
|
+
# 2. CLAUDE.md
|
|
126
|
+
claude_md = _read_file(str(Path.home() / "CLAUDE.md"))
|
|
127
|
+
results.append(_check_contains(claude_md, tagline, "CLAUDE.md"))
|
|
128
|
+
|
|
129
|
+
# 3. GitHub repo descriptions (requires gh CLI)
|
|
130
|
+
for repo, surface in [
|
|
131
|
+
("delimit-ai/delimit", "GitHub: delimit repo"),
|
|
132
|
+
("delimit-ai/delimit-action", "GitHub: delimit-action repo"),
|
|
133
|
+
("delimit-ai/delimit-quickstart", "GitHub: quickstart repo"),
|
|
134
|
+
]:
|
|
135
|
+
try:
|
|
136
|
+
r = subprocess.run(
|
|
137
|
+
["gh", "api", f"repos/{repo}", "--jq", ".description"],
|
|
138
|
+
capture_output=True, text=True, timeout=10,
|
|
139
|
+
)
|
|
140
|
+
if r.returncode == 0:
|
|
141
|
+
desc = r.stdout.strip()
|
|
142
|
+
if tagline.lower() in desc.lower() or "governance" in desc.lower():
|
|
143
|
+
results.append({"surface": surface, "status": "ok", "current": desc[:100]})
|
|
144
|
+
else:
|
|
145
|
+
results.append({"surface": surface, "status": "stale", "current": desc[:100], "expected": tagline})
|
|
146
|
+
else:
|
|
147
|
+
results.append({"surface": surface, "status": "error", "detail": "gh API failed"})
|
|
148
|
+
except Exception:
|
|
149
|
+
results.append({"surface": surface, "status": "skipped", "detail": "gh CLI not available"})
|
|
150
|
+
|
|
151
|
+
# 4. GitHub org description
|
|
152
|
+
try:
|
|
153
|
+
r = subprocess.run(
|
|
154
|
+
["gh", "api", "orgs/delimit-ai", "--jq", ".description"],
|
|
155
|
+
capture_output=True, text=True, timeout=10,
|
|
156
|
+
)
|
|
157
|
+
if r.returncode == 0:
|
|
158
|
+
org_desc = r.stdout.strip()
|
|
159
|
+
results.append(_check_contains(org_desc, "governance" if "governance" in tagline.lower() else tagline[:30], "GitHub: org description"))
|
|
160
|
+
except Exception:
|
|
161
|
+
results.append({"surface": "GitHub: org description", "status": "skipped"})
|
|
162
|
+
|
|
163
|
+
# 5. delimit.ai meta tags
|
|
164
|
+
for layout_path in [
|
|
165
|
+
Path.home() / "delimit-ui" / "app" / "layout.tsx",
|
|
166
|
+
]:
|
|
167
|
+
if layout_path.exists():
|
|
168
|
+
layout = layout_path.read_text()
|
|
169
|
+
results.append(_check_contains(layout, tagline, "delimit.ai meta title"))
|
|
170
|
+
break
|
|
171
|
+
else:
|
|
172
|
+
results.append({"surface": "delimit.ai meta title", "status": "skipped", "detail": "layout.tsx not found"})
|
|
173
|
+
|
|
174
|
+
# 6. Gateway version
|
|
175
|
+
for pyproject_path in [
|
|
176
|
+
Path.home() / "delimit-gateway" / "pyproject.toml",
|
|
177
|
+
]:
|
|
178
|
+
if pyproject_path.exists():
|
|
179
|
+
gw_version = _get_pyproject_version(str(pyproject_path))
|
|
180
|
+
cfg.setdefault("version", {})["gateway"] = gw_version
|
|
181
|
+
results.append({"surface": "gateway pyproject.toml", "status": "ok", "version": gw_version})
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
# 7. GitHub releases
|
|
185
|
+
try:
|
|
186
|
+
r = subprocess.run(
|
|
187
|
+
["gh", "release", "list", "--repo", "delimit-ai/delimit", "--limit", "1", "--json", "tagName"],
|
|
188
|
+
capture_output=True, text=True, timeout=10,
|
|
189
|
+
)
|
|
190
|
+
if r.returncode == 0:
|
|
191
|
+
releases = json.loads(r.stdout)
|
|
192
|
+
if releases:
|
|
193
|
+
release_ver = releases[0].get("tagName", "").lstrip("v")
|
|
194
|
+
cli_ver = cfg.get("version", {}).get("cli", "")
|
|
195
|
+
if release_ver == cli_ver:
|
|
196
|
+
results.append({"surface": "GitHub release", "status": "ok", "version": release_ver})
|
|
197
|
+
else:
|
|
198
|
+
results.append({"surface": "GitHub release", "status": "stale", "current": release_ver, "expected": cli_ver})
|
|
199
|
+
except Exception:
|
|
200
|
+
results.append({"surface": "GitHub release", "status": "skipped"})
|
|
201
|
+
|
|
202
|
+
# Summary
|
|
203
|
+
ok = sum(1 for r in results if r["status"] == "ok")
|
|
204
|
+
stale = sum(1 for r in results if r["status"] == "stale")
|
|
205
|
+
errors = sum(1 for r in results if r["status"] in ("error", "missing"))
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
"config": cfg,
|
|
209
|
+
"surfaces": results,
|
|
210
|
+
"summary": {
|
|
211
|
+
"total": len(results),
|
|
212
|
+
"ok": ok,
|
|
213
|
+
"stale": stale,
|
|
214
|
+
"errors": errors,
|
|
215
|
+
},
|
|
216
|
+
"all_synced": stale == 0 and errors == 0,
|
|
217
|
+
}
|
package/gateway/ai/server.py
CHANGED
|
@@ -1497,6 +1497,13 @@ async def delimit_sensor_github_issue(
|
|
|
1497
1497
|
issue_number: The issue number to monitor.
|
|
1498
1498
|
since_comment_id: Last seen comment ID. Pass 0 to get all comments.
|
|
1499
1499
|
"""
|
|
1500
|
+
import re as _re
|
|
1501
|
+
# Validate inputs to prevent injection
|
|
1502
|
+
if not _re.match(r'^[\w.-]+/[\w.-]+$', repo):
|
|
1503
|
+
return _with_next_steps("sensor_github_issue", {"error": f"Invalid repo format: {repo}. Use owner/repo."})
|
|
1504
|
+
if not isinstance(issue_number, int) or issue_number <= 0:
|
|
1505
|
+
return _with_next_steps("sensor_github_issue", {"error": f"Invalid issue number: {issue_number}"})
|
|
1506
|
+
|
|
1500
1507
|
try:
|
|
1501
1508
|
# Fetch comments
|
|
1502
1509
|
comments_jq = (
|
|
@@ -1982,17 +1989,110 @@ def delimit_ventures() -> Dict[str, Any]:
|
|
|
1982
1989
|
|
|
1983
1990
|
|
|
1984
1991
|
@mcp.tool()
|
|
1985
|
-
def delimit_models(
|
|
1992
|
+
def delimit_models(
|
|
1993
|
+
action: str = "list",
|
|
1994
|
+
provider: str = "",
|
|
1995
|
+
api_key: str = "",
|
|
1996
|
+
model_name: str = "",
|
|
1997
|
+
) -> Dict[str, Any]:
|
|
1986
1998
|
"""View and configure AI models for multi-model deliberation.
|
|
1987
1999
|
|
|
1988
|
-
|
|
1989
|
-
|
|
2000
|
+
Actions:
|
|
2001
|
+
- "list": show configured models and what's available
|
|
2002
|
+
- "detect": auto-detect API keys from environment and configure
|
|
2003
|
+
- "add": add a model provider (set provider + api_key)
|
|
2004
|
+
- "remove": remove a model provider (set provider)
|
|
2005
|
+
|
|
2006
|
+
Supported providers: grok, gemini, openai, anthropic, codex
|
|
1990
2007
|
|
|
1991
2008
|
Args:
|
|
1992
|
-
action:
|
|
2009
|
+
action: list, detect, add, or remove.
|
|
2010
|
+
provider: Model provider for add/remove (grok, gemini, openai, anthropic, codex).
|
|
2011
|
+
api_key: API key for the provider (only used with action=add).
|
|
2012
|
+
model_name: Optional model name override (e.g. "gpt-4o", "claude-sonnet-4-5-20250514").
|
|
1993
2013
|
"""
|
|
1994
|
-
from ai.deliberation import configure_models
|
|
1995
|
-
|
|
2014
|
+
from ai.deliberation import configure_models, get_models_config, MODELS_CONFIG, DEFAULT_MODELS
|
|
2015
|
+
import json as _json
|
|
2016
|
+
|
|
2017
|
+
if action == "list":
|
|
2018
|
+
return configure_models()
|
|
2019
|
+
|
|
2020
|
+
if action == "detect":
|
|
2021
|
+
# Auto-detect from env vars and save
|
|
2022
|
+
config = get_models_config()
|
|
2023
|
+
detected = []
|
|
2024
|
+
env_map = {
|
|
2025
|
+
"grok": "XAI_API_KEY",
|
|
2026
|
+
"gemini": "GOOGLE_APPLICATION_CREDENTIALS",
|
|
2027
|
+
"openai": "OPENAI_API_KEY",
|
|
2028
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
2029
|
+
}
|
|
2030
|
+
for pid, env_key in env_map.items():
|
|
2031
|
+
if os.environ.get(env_key) and pid not in config:
|
|
2032
|
+
defaults = DEFAULT_MODELS.get(pid, {})
|
|
2033
|
+
config[pid] = {**defaults, "enabled": True}
|
|
2034
|
+
if "api_key" in defaults:
|
|
2035
|
+
config[pid]["api_key"] = os.environ[env_key]
|
|
2036
|
+
detected.append(pid)
|
|
2037
|
+
# Check codex CLI
|
|
2038
|
+
import shutil
|
|
2039
|
+
if shutil.which("codex") and "codex" not in config:
|
|
2040
|
+
config["codex"] = {**DEFAULT_MODELS.get("codex", {}), "enabled": True}
|
|
2041
|
+
detected.append("codex")
|
|
2042
|
+
|
|
2043
|
+
if detected:
|
|
2044
|
+
MODELS_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
2045
|
+
MODELS_CONFIG.write_text(_json.dumps(config, indent=2))
|
|
2046
|
+
return {"action": "detect", "detected": detected, "total_models": len(config), "config_path": str(MODELS_CONFIG)}
|
|
2047
|
+
return {"action": "detect", "detected": [], "note": "No new API keys found in environment."}
|
|
2048
|
+
|
|
2049
|
+
if action == "add":
|
|
2050
|
+
if not provider:
|
|
2051
|
+
return {"error": "Specify provider: grok, gemini, openai, anthropic, or codex"}
|
|
2052
|
+
|
|
2053
|
+
config = {}
|
|
2054
|
+
if MODELS_CONFIG.exists():
|
|
2055
|
+
try:
|
|
2056
|
+
config = _json.loads(MODELS_CONFIG.read_text())
|
|
2057
|
+
except Exception:
|
|
2058
|
+
pass
|
|
2059
|
+
|
|
2060
|
+
# Provider templates
|
|
2061
|
+
templates = {
|
|
2062
|
+
"grok": {"name": "Grok", "api_url": "https://api.x.ai/v1/chat/completions", "model": model_name or "grok-4-0709", "env_key": "XAI_API_KEY"},
|
|
2063
|
+
"openai": {"name": "OpenAI", "api_url": "https://api.openai.com/v1/chat/completions", "model": model_name or "gpt-4o", "env_key": "OPENAI_API_KEY", "prefer_cli": True},
|
|
2064
|
+
"anthropic": {"name": "Claude", "api_url": "https://api.anthropic.com/v1/messages", "model": model_name or "claude-sonnet-4-5-20250514", "env_key": "ANTHROPIC_API_KEY", "format": "anthropic"},
|
|
2065
|
+
"gemini": {"name": "Gemini", "api_url": "https://us-central1-aiplatform.googleapis.com/v1/projects/{project}/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent", "model": model_name or "gemini-2.5-flash", "format": "vertex_ai"},
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
if provider not in templates:
|
|
2069
|
+
return {"error": f"Unknown provider '{provider}'. Supported: {', '.join(templates.keys())}"}
|
|
2070
|
+
|
|
2071
|
+
entry = {**templates[provider], "enabled": True}
|
|
2072
|
+
if api_key:
|
|
2073
|
+
entry["api_key"] = api_key
|
|
2074
|
+
|
|
2075
|
+
config[provider] = entry
|
|
2076
|
+
MODELS_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
2077
|
+
MODELS_CONFIG.write_text(_json.dumps(config, indent=2))
|
|
2078
|
+
return {"action": "add", "provider": provider, "model": entry.get("model"), "config_path": str(MODELS_CONFIG)}
|
|
2079
|
+
|
|
2080
|
+
if action == "remove":
|
|
2081
|
+
if not provider:
|
|
2082
|
+
return {"error": "Specify provider to remove"}
|
|
2083
|
+
config = {}
|
|
2084
|
+
if MODELS_CONFIG.exists():
|
|
2085
|
+
try:
|
|
2086
|
+
config = _json.loads(MODELS_CONFIG.read_text())
|
|
2087
|
+
except Exception:
|
|
2088
|
+
pass
|
|
2089
|
+
if provider in config:
|
|
2090
|
+
del config[provider]
|
|
2091
|
+
MODELS_CONFIG.write_text(_json.dumps(config, indent=2))
|
|
2092
|
+
return {"action": "remove", "provider": provider, "remaining": list(config.keys())}
|
|
2093
|
+
return {"action": "remove", "provider": provider, "note": "Provider not found in config"}
|
|
2094
|
+
|
|
2095
|
+
return {"error": f"Unknown action '{action}'. Use: list, detect, add, remove"}
|
|
1996
2096
|
|
|
1997
2097
|
|
|
1998
2098
|
@mcp.tool()
|
|
@@ -2128,6 +2228,151 @@ def _extract_deliberation_actions(result: Dict, question: str) -> List[Dict[str,
|
|
|
2128
2228
|
return actions[:10]
|
|
2129
2229
|
|
|
2130
2230
|
|
|
2231
|
+
@mcp.tool()
|
|
2232
|
+
def delimit_release_sync(action: str = "audit") -> Dict[str, Any]:
|
|
2233
|
+
"""Audit or sync all public surfaces for consistency.
|
|
2234
|
+
|
|
2235
|
+
Checks GitHub repos, npm, site meta tags, CLAUDE.md, and releases
|
|
2236
|
+
against a central config. Reports what's stale and what needs updating.
|
|
2237
|
+
|
|
2238
|
+
Args:
|
|
2239
|
+
action: "audit" to check all surfaces, "config" to view/edit the release config.
|
|
2240
|
+
"""
|
|
2241
|
+
from ai.release_sync import audit, get_release_config
|
|
2242
|
+
if action == "config":
|
|
2243
|
+
return get_release_config()
|
|
2244
|
+
return _with_next_steps("release_sync", audit())
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
@mcp.tool()
|
|
2248
|
+
def delimit_scan(project_path: str = ".") -> Dict[str, Any]:
|
|
2249
|
+
"""Scan a project and show what Delimit can do for it.
|
|
2250
|
+
|
|
2251
|
+
First-run discovery tool. Finds OpenAPI specs, checks for security issues,
|
|
2252
|
+
detects frameworks, and suggests what to track. Use this when you first
|
|
2253
|
+
install Delimit or open a new project.
|
|
2254
|
+
|
|
2255
|
+
Args:
|
|
2256
|
+
project_path: Path to the project to scan.
|
|
2257
|
+
"""
|
|
2258
|
+
import glob as _glob
|
|
2259
|
+
p = Path(project_path).resolve()
|
|
2260
|
+
findings = []
|
|
2261
|
+
suggestions = []
|
|
2262
|
+
|
|
2263
|
+
# 1. Find OpenAPI specs
|
|
2264
|
+
spec_patterns = ["**/openapi.yaml", "**/openapi.yml", "**/openapi.json",
|
|
2265
|
+
"**/swagger.yaml", "**/swagger.yml", "**/swagger.json",
|
|
2266
|
+
"**/*api*.yaml", "**/*api*.yml"]
|
|
2267
|
+
specs_found = []
|
|
2268
|
+
for pattern in spec_patterns:
|
|
2269
|
+
for match in p.glob(pattern):
|
|
2270
|
+
rel = str(match.relative_to(p))
|
|
2271
|
+
if "node_modules" not in rel and ".next" not in rel and "venv" not in rel:
|
|
2272
|
+
specs_found.append(rel)
|
|
2273
|
+
specs_found = list(set(specs_found))[:10]
|
|
2274
|
+
|
|
2275
|
+
if specs_found:
|
|
2276
|
+
findings.append({"type": "openapi_specs", "count": len(specs_found), "files": specs_found})
|
|
2277
|
+
suggestions.append({"action": "lint", "detail": f"Run delimit_lint on {specs_found[0]} to check for issues"})
|
|
2278
|
+
suggestions.append({"action": "github_action", "detail": "Add the Delimit GitHub Action to catch breaking changes on PRs"})
|
|
2279
|
+
else:
|
|
2280
|
+
# Check for framework that could generate a spec
|
|
2281
|
+
framework = None
|
|
2282
|
+
if (p / "requirements.txt").exists() or (p / "pyproject.toml").exists():
|
|
2283
|
+
for py_file in p.rglob("*.py"):
|
|
2284
|
+
if "node_modules" in str(py_file):
|
|
2285
|
+
continue
|
|
2286
|
+
try:
|
|
2287
|
+
content = py_file.read_text(errors="ignore")[:2000]
|
|
2288
|
+
if "FastAPI" in content or "fastapi" in content:
|
|
2289
|
+
framework = "FastAPI"
|
|
2290
|
+
break
|
|
2291
|
+
except Exception:
|
|
2292
|
+
pass
|
|
2293
|
+
if (p / "package.json").exists():
|
|
2294
|
+
try:
|
|
2295
|
+
pkg = json.loads((p / "package.json").read_text())
|
|
2296
|
+
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
2297
|
+
if "@nestjs/core" in deps:
|
|
2298
|
+
framework = "NestJS"
|
|
2299
|
+
elif "express" in deps:
|
|
2300
|
+
framework = "Express"
|
|
2301
|
+
except Exception:
|
|
2302
|
+
pass
|
|
2303
|
+
|
|
2304
|
+
if framework:
|
|
2305
|
+
findings.append({"type": "framework_detected", "framework": framework, "has_spec": False})
|
|
2306
|
+
suggestions.append({"action": "zero_spec", "detail": f"Run delimit_zero_spec to extract an OpenAPI spec from your {framework} code"})
|
|
2307
|
+
else:
|
|
2308
|
+
findings.append({"type": "no_api_detected", "note": "No OpenAPI spec or supported framework found"})
|
|
2309
|
+
|
|
2310
|
+
# 2. Check for security patterns (quick scan)
|
|
2311
|
+
security_issues = []
|
|
2312
|
+
for pattern_name, pattern_glob, check in [
|
|
2313
|
+
("env_file_in_git", ".env", lambda f: True),
|
|
2314
|
+
("hardcoded_key", "**/*.py", lambda f: "API_KEY" in f.read_text(errors="ignore")[:5000] and "os.environ" not in f.read_text(errors="ignore")[:5000]),
|
|
2315
|
+
("hardcoded_key_js", "**/*.js", lambda f: "apiKey" in f.read_text(errors="ignore")[:5000] and "process.env" not in f.read_text(errors="ignore")[:5000]),
|
|
2316
|
+
]:
|
|
2317
|
+
try:
|
|
2318
|
+
for match in p.glob(pattern_glob):
|
|
2319
|
+
rel = str(match.relative_to(p))
|
|
2320
|
+
if "node_modules" in rel or ".next" in rel or "venv" in rel or "__pycache__" in rel:
|
|
2321
|
+
continue
|
|
2322
|
+
if check(match):
|
|
2323
|
+
security_issues.append({"issue": pattern_name, "file": rel})
|
|
2324
|
+
break # One per pattern is enough
|
|
2325
|
+
except Exception:
|
|
2326
|
+
pass
|
|
2327
|
+
|
|
2328
|
+
if security_issues:
|
|
2329
|
+
findings.append({"type": "security_concerns", "count": len(security_issues), "issues": security_issues})
|
|
2330
|
+
suggestions.append({"action": "security_audit", "detail": "Run delimit_security_audit for a full scan"})
|
|
2331
|
+
|
|
2332
|
+
# 3. Check git status
|
|
2333
|
+
try:
|
|
2334
|
+
import subprocess
|
|
2335
|
+
result = subprocess.run(["git", "log", "--oneline", "-1"], capture_output=True, text=True, timeout=5, cwd=str(p))
|
|
2336
|
+
if result.returncode == 0:
|
|
2337
|
+
findings.append({"type": "git_repo", "latest_commit": result.stdout.strip()})
|
|
2338
|
+
except Exception:
|
|
2339
|
+
pass
|
|
2340
|
+
|
|
2341
|
+
# 4. Check for existing tests
|
|
2342
|
+
test_files = list(p.glob("**/test_*.py")) + list(p.glob("**/*.test.js")) + list(p.glob("**/*.test.ts")) + list(p.glob("**/*.spec.js"))
|
|
2343
|
+
test_files = [f for f in test_files if "node_modules" not in str(f)]
|
|
2344
|
+
if test_files:
|
|
2345
|
+
findings.append({"type": "tests_found", "count": len(test_files)})
|
|
2346
|
+
suggestions.append({"action": "test_coverage", "detail": "Run delimit_test_smoke to verify tests pass and measure coverage"})
|
|
2347
|
+
|
|
2348
|
+
# 5. Check ledger
|
|
2349
|
+
from ai.ledger_manager import list_items
|
|
2350
|
+
ledger = list_items(project_path=str(p))
|
|
2351
|
+
open_items = [i for i in ledger.get("items", []) if isinstance(i, dict) and i.get("status") == "open"]
|
|
2352
|
+
if open_items:
|
|
2353
|
+
findings.append({"type": "ledger_active", "open_items": len(open_items), "top": [i.get("title", "") for i in open_items[:3]]})
|
|
2354
|
+
else:
|
|
2355
|
+
suggestions.append({"action": "ledger", "detail": "Say 'add to ledger: [task]' to start tracking work across sessions"})
|
|
2356
|
+
|
|
2357
|
+
# 6. Check deliberation models
|
|
2358
|
+
from ai.deliberation import get_models_config
|
|
2359
|
+
models = get_models_config()
|
|
2360
|
+
enabled = [v.get("name", k) for k, v in models.items() if v.get("enabled")]
|
|
2361
|
+
if len(enabled) >= 2:
|
|
2362
|
+
findings.append({"type": "deliberation_ready", "models": enabled})
|
|
2363
|
+
elif len(enabled) == 1:
|
|
2364
|
+
suggestions.append({"action": "models", "detail": f"Add 1 more AI model for multi-model deliberation (have {enabled[0]})"})
|
|
2365
|
+
else:
|
|
2366
|
+
suggestions.append({"action": "models", "detail": "Configure AI models for deliberation: say 'configure delimit models'"})
|
|
2367
|
+
|
|
2368
|
+
return _with_next_steps("scan", {
|
|
2369
|
+
"project": str(p),
|
|
2370
|
+
"findings": findings,
|
|
2371
|
+
"suggestions": suggestions,
|
|
2372
|
+
"summary": f"Found {len(findings)} things, {len(suggestions)} suggestions",
|
|
2373
|
+
})
|
|
2374
|
+
|
|
2375
|
+
|
|
2131
2376
|
# ═══════════════════════════════════════════════════════════════════════
|
|
2132
2377
|
# ENTRY POINT
|
|
2133
2378
|
# ═══════════════════════════════════════════════════════════════════════
|
package/package.json
CHANGED