delimit-cli 3.12.1 → 3.13.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 CHANGED
@@ -7,6 +7,10 @@ Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, govern
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
8
  [![Glama](https://glama.ai/mcp/servers/delimit-ai/delimit-mcp-server/badges/score.svg)](https://glama.ai/mcp/servers/delimit-ai/delimit-mcp-server)
9
9
 
10
+ <p align="center">
11
+ <img src="docs/demo.gif" alt="Delimit detecting breaking API changes" width="700">
12
+ </p>
13
+
10
14
  Persistent ledger, API governance, security orchestration, and multi-model deliberation — all shared across Claude Code, Codex, Cursor, and Gemini CLI.
11
15
 
12
16
  ---
@@ -111,24 +115,46 @@ When installed into your AI coding assistant, Delimit provides tools across two
111
115
 
112
116
  ---
113
117
 
114
- ## What it catches
115
-
116
- 10 categories of breaking changes:
117
-
118
- | Change | Example |
119
- |--------|---------|
120
- | Endpoint removed | `DELETE /users/{id}` disappeared |
121
- | HTTP method removed | `PATCH /orders` no longer exists |
122
- | Required parameter added | New required header on `GET /items` |
123
- | Field removed from response | `email` dropped from user object |
124
- | Type changed | `id` went from string to integer |
125
- | Enum value removed | `status: "pending"` no longer valid |
126
- | Response code removed | `200 OK` response dropped |
127
- | Parameter removed | `sort` query param removed |
128
- | Required field added to request | Body now requires `tenant_id` |
129
- | Format changed | `date-time` changed to `date` |
130
-
131
- Detection is deterministic rules, not AI inference. Same input always produces the same result.
118
+ ## What It Detects
119
+
120
+ 27 change types (17 breaking, 10 non-breaking) -- deterministic rules, not AI inference. Same input always produces the same result.
121
+
122
+ ### Breaking Changes
123
+
124
+ | # | Change Type | Example |
125
+ |---|-------------|---------|
126
+ | 1 | `endpoint_removed` | `DELETE /users/{id}` removed entirely |
127
+ | 2 | `method_removed` | `PATCH /orders` no longer exists |
128
+ | 3 | `required_param_added` | New required header on `GET /items` |
129
+ | 4 | `param_removed` | `sort` query parameter removed |
130
+ | 5 | `response_removed` | `200 OK` response dropped |
131
+ | 6 | `required_field_added` | Request body now requires `tenant_id` |
132
+ | 7 | `field_removed` | `email` dropped from response object |
133
+ | 8 | `type_changed` | `id` went from `string` to `integer` |
134
+ | 9 | `format_changed` | `date-time` changed to `date` |
135
+ | 10 | `enum_value_removed` | `status: "pending"` no longer valid |
136
+ | 11 | `param_type_changed` | Query param `limit` changed from `integer` to `string` |
137
+ | 12 | `param_required_changed` | `filter` param became required |
138
+ | 13 | `response_type_changed` | Response `data` changed from `array` to `object` |
139
+ | 14 | `security_removed` | OAuth2 security scheme removed |
140
+ | 15 | `security_scope_removed` | `write:pets` scope removed from OAuth2 |
141
+ | 16 | `max_length_decreased` | `name` maxLength reduced from 255 to 100 |
142
+ | 17 | `min_length_increased` | `code` minLength increased from 1 to 5 |
143
+
144
+ ### Non-Breaking Changes
145
+
146
+ | # | Change Type | Example |
147
+ |---|-------------|---------|
148
+ | 18 | `endpoint_added` | New `POST /webhooks` endpoint |
149
+ | 19 | `method_added` | `PATCH /users/{id}` method added |
150
+ | 20 | `optional_param_added` | Optional `format` query param added |
151
+ | 21 | `response_added` | `201 Created` response added |
152
+ | 22 | `optional_field_added` | Optional `nickname` field added to response |
153
+ | 23 | `enum_value_added` | `status: "archived"` value added |
154
+ | 24 | `description_changed` | Updated description for `/health` endpoint |
155
+ | 25 | `security_added` | API key security scheme added |
156
+ | 26 | `deprecated_added` | `GET /v1/users` marked as deprecated |
157
+ | 27 | `default_changed` | Default value for `page_size` changed from 10 to 20 |
132
158
 
133
159
  ---
134
160
 
@@ -947,29 +947,59 @@ rules:
947
947
  `,
948
948
  };
949
949
 
950
- // Init command — scaffold .delimit/ config
950
+ // Init command — guided onboarding wizard (Consensus: Build Next 2026-03-27)
951
951
  program
952
952
  .command('init')
953
953
  .description('Initialize Delimit API governance in this project')
954
- .option('--preset <name>', 'Policy preset: strict, default, or relaxed', 'default')
954
+ .option('--preset <name>', 'Policy preset: strict, default, or relaxed')
955
+ .option('--yes', 'Skip prompts and use defaults')
955
956
  .action(async (options) => {
957
+ const startTime = Date.now();
956
958
  const configDir = path.join(process.cwd(), '.delimit');
957
959
  const policyFile = path.join(configDir, 'policies.yml');
958
960
 
961
+ console.log(chalk.bold('\n Delimit — API Governance Setup\n'));
962
+
959
963
  if (fs.existsSync(policyFile)) {
960
- console.log(chalk.yellow('Already initialized — .delimit/policies.yml exists'));
964
+ console.log(chalk.yellow(' Already initialized — .delimit/policies.yml exists'));
965
+ console.log(` Run ${chalk.bold('delimit lint')} to check your API.\n`);
961
966
  return;
962
967
  }
963
968
 
964
- const preset = options.preset.toLowerCase();
965
- if (!POLICY_PRESETS[preset]) {
966
- console.log(chalk.red(`Unknown preset "${preset}". Choose: strict, default, or relaxed`));
967
- return;
968
- }
969
+ // Step 1: Detect project type
970
+ console.log(chalk.gray(' Scanning project...'));
971
+ const projectDir = process.cwd();
972
+ const projectName = path.basename(projectDir);
973
+ let framework = 'unknown';
974
+ let frameworkLabel = '';
969
975
 
970
- fs.mkdirSync(configDir, { recursive: true });
971
- fs.writeFileSync(policyFile, POLICY_PRESETS[preset]);
972
- console.log(chalk.green(`\n Created .delimit/policies.yml (preset: ${preset})\n`));
976
+ // Check for common frameworks
977
+ const pkgJsonPath = path.join(projectDir, 'package.json');
978
+ const pyprojectPath = path.join(projectDir, 'pyproject.toml');
979
+ const requirementsPath = path.join(projectDir, 'requirements.txt');
980
+
981
+ if (fs.existsSync(pkgJsonPath)) {
982
+ try {
983
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
984
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
985
+ if (allDeps['@nestjs/core']) { framework = 'nestjs'; frameworkLabel = 'NestJS'; }
986
+ else if (allDeps['express']) { framework = 'express'; frameworkLabel = 'Express'; }
987
+ else if (allDeps['fastify']) { framework = 'fastify'; frameworkLabel = 'Fastify'; }
988
+ else if (allDeps['hono']) { framework = 'hono'; frameworkLabel = 'Hono'; }
989
+ else if (allDeps['next']) { framework = 'nextjs'; frameworkLabel = 'Next.js'; }
990
+ } catch {}
991
+ }
992
+ if (framework === 'unknown') {
993
+ const pyFiles = [pyprojectPath, requirementsPath, path.join(projectDir, 'setup.py')];
994
+ for (const f of pyFiles) {
995
+ if (fs.existsSync(f)) {
996
+ const content = fs.readFileSync(f, 'utf-8').toLowerCase();
997
+ if (content.includes('fastapi')) { framework = 'fastapi'; frameworkLabel = 'FastAPI'; break; }
998
+ if (content.includes('django')) { framework = 'django'; frameworkLabel = 'Django'; break; }
999
+ if (content.includes('flask')) { framework = 'flask'; frameworkLabel = 'Flask'; break; }
1000
+ }
1001
+ }
1002
+ }
973
1003
 
974
1004
  // Auto-detect OpenAPI spec files
975
1005
  const specPatterns = [
@@ -982,45 +1012,89 @@ program
982
1012
  'api/openapi.yaml', 'api/openapi.json',
983
1013
  'contrib/openapi.json',
984
1014
  ];
985
- const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(process.cwd(), p)));
1015
+ const foundSpecs = specPatterns.filter(p => fs.existsSync(path.join(projectDir, p)));
1016
+ const specPath = foundSpecs.length > 0 ? foundSpecs[0] : null;
1017
+
1018
+ // Check for CI
1019
+ const hasGitHub = fs.existsSync(path.join(projectDir, '.github'));
1020
+ const hasGitLabCI = fs.existsSync(path.join(projectDir, '.gitlab-ci.yml'));
1021
+ const ciProvider = hasGitHub ? 'github' : hasGitLabCI ? 'gitlab' : 'none';
1022
+
1023
+ // Display detection results
1024
+ console.log(` Project: ${chalk.bold(projectName)}`);
1025
+ if (frameworkLabel) console.log(` Framework: ${chalk.bold(frameworkLabel)}`);
1026
+ if (specPath) console.log(` Spec: ${chalk.bold(specPath)}`);
1027
+ else if (['fastapi', 'nestjs', 'express'].includes(framework))
1028
+ console.log(` Spec: ${chalk.gray('none found')} (Zero-Spec Mode available for ${frameworkLabel})`);
1029
+ else console.log(` Spec: ${chalk.gray('none found')}`);
1030
+ if (ciProvider !== 'none') console.log(` CI: ${chalk.bold(ciProvider === 'github' ? 'GitHub Actions' : 'GitLab CI')}`);
1031
+ console.log('');
986
1032
 
987
- if (foundSpecs.length > 0) {
988
- const specPath = foundSpecs[0];
989
- console.log(` Detected spec: ${chalk.bold(specPath)}`);
990
- console.log('');
991
- console.log(chalk.bold(' Workflow template:\n'));
992
- console.log(chalk.gray(` name: API Governance
993
- on:
994
- pull_request:
995
- paths:
996
- - '${specPath}'
997
- permissions:
998
- contents: read
999
- pull-requests: write
1000
- jobs:
1001
- api-governance:
1002
- runs-on: ubuntu-latest
1003
- steps:
1004
- - uses: actions/checkout@v4
1005
- - uses: actions/checkout@v4
1006
- with:
1007
- ref: \${{ github.event.pull_request.base.sha }}
1008
- path: _base
1009
- - uses: delimit-ai/delimit@v1
1010
- with:
1011
- old_spec: _base/${specPath}
1012
- new_spec: ${specPath}
1013
- mode: advisory`));
1014
- console.log('');
1033
+ // Step 2: Choose preset
1034
+ let preset = options.preset ? options.preset.toLowerCase() : null;
1035
+ if (!preset && !options.yes) {
1036
+ // Suggest preset based on project signals
1037
+ let defaultPreset = 'default';
1038
+ if (specPath) {
1039
+ // If they have a checked-in spec, they probably care about stability
1040
+ try {
1041
+ const specContent = fs.readFileSync(path.join(projectDir, specPath), 'utf-8');
1042
+ if (specContent.includes('/v2') || specContent.includes('/v3')) defaultPreset = 'strict';
1043
+ } catch {}
1044
+ }
1045
+
1046
+ try {
1047
+ const answers = await inquirer.prompt([{
1048
+ type: 'list',
1049
+ name: 'preset',
1050
+ message: 'Policy preset:',
1051
+ choices: [
1052
+ { name: 'strict — Block all breaking changes (public APIs, payment systems)', value: 'strict' },
1053
+ { name: 'default — Balanced for most teams (block critical, warn on others)', value: 'default' },
1054
+ { name: 'relaxed — Warnings only (internal APIs, early-stage projects)', value: 'relaxed' },
1055
+ ],
1056
+ default: defaultPreset,
1057
+ }]);
1058
+ preset = answers.preset;
1059
+ } catch {
1060
+ preset = defaultPreset;
1061
+ }
1062
+ }
1063
+ if (!preset) preset = 'default';
1064
+
1065
+ if (!POLICY_PRESETS[preset]) {
1066
+ console.log(chalk.red(` Unknown preset "${preset}". Choose: strict, default, or relaxed`));
1067
+ return;
1068
+ }
1069
+
1070
+ // Step 3: Create policy file
1071
+ fs.mkdirSync(configDir, { recursive: true });
1072
+ fs.writeFileSync(policyFile, POLICY_PRESETS[preset]);
1073
+ console.log(chalk.green(` Created .delimit/policies.yml (${preset})`));
1015
1074
 
1016
- // Auto-write the workflow file
1017
- const workflowDir = path.join(process.cwd(), '.github', 'workflows');
1075
+ // Step 4: Add GitHub Action workflow if spec found + GitHub CI
1076
+ if (specPath && ciProvider === 'github') {
1077
+ const workflowDir = path.join(projectDir, '.github', 'workflows');
1018
1078
  const workflowFile = path.join(workflowDir, 'api-governance.yml');
1019
1079
 
1020
1080
  if (!fs.existsSync(workflowFile)) {
1021
- try {
1022
- fs.mkdirSync(workflowDir, { recursive: true });
1023
- const workflowContent = `name: API Governance
1081
+ let writeWorkflow = true;
1082
+ if (!options.yes) {
1083
+ try {
1084
+ const ans = await inquirer.prompt([{
1085
+ type: 'confirm',
1086
+ name: 'addWorkflow',
1087
+ message: 'Add GitHub Action for PR governance?',
1088
+ default: true,
1089
+ }]);
1090
+ writeWorkflow = ans.addWorkflow;
1091
+ } catch {}
1092
+ }
1093
+
1094
+ if (writeWorkflow) {
1095
+ try {
1096
+ fs.mkdirSync(workflowDir, { recursive: true });
1097
+ const workflowContent = `name: API Governance
1024
1098
  on:
1025
1099
  pull_request:
1026
1100
  paths:
@@ -1045,27 +1119,81 @@ jobs:
1045
1119
  new_spec: ${specPath}
1046
1120
  mode: advisory
1047
1121
  `;
1048
- fs.writeFileSync(workflowFile, workflowContent);
1049
- console.log(chalk.green(` Created .github/workflows/api-governance.yml\n`));
1050
- } catch (err) {
1051
- console.log(chalk.yellow(` Could not write workflow file: ${err.message}`));
1052
- console.log(chalk.bold(' Add this to .github/workflows/api-governance.yml manually (shown above)\n'));
1122
+ fs.writeFileSync(workflowFile, workflowContent);
1123
+ console.log(chalk.green(' Created .github/workflows/api-governance.yml'));
1124
+ } catch (err) {
1125
+ console.log(chalk.yellow(` Could not write workflow: ${err.message}`));
1126
+ }
1053
1127
  }
1054
1128
  } else {
1055
- console.log(chalk.yellow(' .github/workflows/api-governance.yml already exists — skipped\n'));
1129
+ console.log(chalk.gray(' .github/workflows/api-governance.yml already exists'));
1056
1130
  }
1057
- } else {
1058
- console.log(' No OpenAPI spec file detected.');
1059
- console.log(` Delimit also supports ${chalk.bold('Zero-Spec Mode')} — run ${chalk.bold('delimit lint')} in a FastAPI/NestJS/Express project.`);
1060
- console.log('');
1061
1131
  }
1062
1132
 
1063
- console.log(` ${chalk.bold('Presets')}: strict | default | relaxed`);
1064
- console.log(` Switch: ${chalk.bold('delimit init --preset strict')}\n`);
1065
- console.log('Next steps:');
1066
- console.log(` ${chalk.bold('delimit lint')} old.yaml new.yaml — check for breaking changes`);
1067
- console.log(` ${chalk.bold('delimit diff')} old.yaml new.yaml — see all changes`);
1068
- console.log(` ${chalk.bold('delimit explain')} old.yaml new.yaml — human-readable summary`);
1133
+ // Step 5: Run first lint to show immediate value
1134
+ console.log('');
1135
+ if (specPath) {
1136
+ console.log(chalk.bold(' Running first lint...'));
1137
+ try {
1138
+ const result = apiEngine.lint(
1139
+ path.join(projectDir, specPath),
1140
+ path.join(projectDir, specPath),
1141
+ { policy: preset }
1142
+ );
1143
+ if (result && result.summary) {
1144
+ const s = result.summary;
1145
+ const breaking = s.breaking || 0;
1146
+ const warnings = s.warnings || 0;
1147
+ const safe = s.safe || s.non_breaking || 0;
1148
+ if (breaking === 0 && warnings === 0) {
1149
+ console.log(chalk.green(' PASS — No breaking changes detected'));
1150
+ } else if (breaking > 0) {
1151
+ console.log(chalk.red(` FAIL — ${breaking} breaking change(s), ${warnings} warning(s)`));
1152
+ } else {
1153
+ console.log(chalk.yellow(` WARN — ${warnings} warning(s)`));
1154
+ }
1155
+ if (result.paths_analyzed) {
1156
+ console.log(chalk.gray(` Analyzed ${result.paths_analyzed} endpoint(s)`));
1157
+ }
1158
+ } else {
1159
+ console.log(chalk.green(' Spec validated successfully'));
1160
+ }
1161
+ } catch (err) {
1162
+ // Lint comparing same file = no changes, which is expected
1163
+ console.log(chalk.green(' Spec validated — baseline set'));
1164
+ }
1165
+ } else if (['fastapi', 'nestjs', 'express'].includes(framework)) {
1166
+ console.log(chalk.bold(' Running Zero-Spec lint...'));
1167
+ try {
1168
+ const zeroResult = apiEngine.zeroSpec(projectDir);
1169
+ if (zeroResult && zeroResult.success) {
1170
+ console.log(chalk.green(` Extracted: ${zeroResult.paths_count} paths, ${zeroResult.schemas_count} schemas`));
1171
+ // Save baseline
1172
+ const baselinePath = path.join(configDir, 'baseline.yaml');
1173
+ if (!fs.existsSync(baselinePath)) {
1174
+ fs.writeFileSync(baselinePath, yaml.dump(zeroResult.spec));
1175
+ console.log(chalk.green(' Saved baseline to .delimit/baseline.yaml'));
1176
+ }
1177
+ } else {
1178
+ console.log(chalk.gray(' Zero-Spec extraction skipped — run `delimit lint` manually'));
1179
+ }
1180
+ } catch {
1181
+ console.log(chalk.gray(' Zero-Spec extraction skipped — run `delimit lint` manually'));
1182
+ }
1183
+ }
1184
+
1185
+ // Summary
1186
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1187
+ console.log(chalk.bold(`\n Setup complete in ${elapsed}s\n`));
1188
+ console.log(' Next steps:');
1189
+ if (specPath) {
1190
+ console.log(` ${chalk.bold('delimit lint')} ${specPath} ${specPath} — lint on every PR`);
1191
+ } else {
1192
+ console.log(` ${chalk.bold('delimit lint')} — zero-spec mode`);
1193
+ }
1194
+ console.log(` ${chalk.bold('delimit doctor')} — verify setup`);
1195
+ console.log(` ${chalk.bold('delimit explain')} — human-readable report`);
1196
+ console.log('');
1069
1197
  });
1070
1198
 
1071
1199
  // Doctor command — verify setup is correct
@@ -0,0 +1,568 @@
1
+ """
2
+ Delimit MCP Rate Limiter — per-tool call limits and session cost controls.
3
+
4
+ Provides sliding-window rate limiting and cumulative cost tracking for all
5
+ MCP tools. Designed to prevent runaway agent loops from burning through
6
+ expensive API calls.
7
+
8
+ Configuration:
9
+ ~/.delimit/rate_limits.yml — per-tool overrides
10
+ Defaults: 100 calls/hr (free), 20 calls/hr (Pro), 5 calls/hr (deliberation)
11
+
12
+ Usage:
13
+ from ai.rate_limiter import limiter
14
+
15
+ block = limiter.check("delimit_lint")
16
+ if block:
17
+ return block # contains error message + wait hint
18
+ # ... execute tool ...
19
+ limiter.record("delimit_lint", cost=0.001)
20
+ """
21
+
22
+ import logging
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ logger = logging.getLogger("delimit.rate_limiter")
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Tool tier classification
31
+ # ---------------------------------------------------------------------------
32
+
33
+ # Tools that invoke multi-model deliberation (most expensive)
34
+ DELIBERATION_TOOLS = frozenset({
35
+ "delimit_deliberate",
36
+ "delimit_security_deliberate",
37
+ })
38
+
39
+ # Pro tools that do significant computation but aren't deliberation
40
+ # Mirrors the PRO_TOOLS set from ai/license.py
41
+ PRO_TOOLS = frozenset({
42
+ "delimit_gov_evaluate", "delimit_gov_policy", "delimit_gov_run",
43
+ "delimit_gov_verify", "delimit_gov_new_task",
44
+ "delimit_os_plan", "delimit_os_status", "delimit_os_gates",
45
+ "delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
46
+ "delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
47
+ "delimit_deploy_site", "delimit_deploy_npm",
48
+ "delimit_memory_search",
49
+ "delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
50
+ "delimit_evidence_collect", "delimit_evidence_verify",
51
+ "delimit_models",
52
+ "delimit_security_ingest",
53
+ "delimit_obs_metrics", "delimit_obs_logs", "delimit_obs_status",
54
+ "delimit_release_plan", "delimit_release_status", "delimit_release_sync",
55
+ "delimit_cost_analyze", "delimit_cost_optimize", "delimit_cost_alert",
56
+ "delimit_social_post", "delimit_social_generate", "delimit_social_history",
57
+ "delimit_repo_analyze", "delimit_repo_config_audit",
58
+ "delimit_repo_config_validate", "delimit_repo_diagnose",
59
+ "delimit_test_coverage",
60
+ "delimit_screen_record", "delimit_screenshot",
61
+ "delimit_notify",
62
+ "delimit_agent_dispatch", "delimit_agent_status",
63
+ "delimit_agent_complete", "delimit_agent_handoff",
64
+ })
65
+
66
+ # Per-tool cost estimates (USD). Tools not listed default to 0.
67
+ DEFAULT_COST_ESTIMATES: Dict[str, float] = {
68
+ # Deliberation — multiple LLM calls
69
+ "delimit_deliberate": 0.01,
70
+ "delimit_security_deliberate": 0.01,
71
+ # Lint / diff / semver — local computation, minimal cost
72
+ "delimit_lint": 0.001,
73
+ "delimit_diff": 0.001,
74
+ "delimit_semver": 0.001,
75
+ # Deploy actions — infrastructure cost
76
+ "delimit_deploy_publish": 0.005,
77
+ "delimit_deploy_build": 0.003,
78
+ "delimit_deploy_site": 0.005,
79
+ "delimit_deploy_npm": 0.005,
80
+ # Agent dispatch — orchestrates sub-agents
81
+ "delimit_agent_dispatch": 0.008,
82
+ # Social posting — API calls to external services
83
+ "delimit_social_post": 0.002,
84
+ "delimit_social_generate": 0.003,
85
+ # Screen recording / screenshots — browser automation
86
+ "delimit_screen_record": 0.005,
87
+ "delimit_screenshot": 0.002,
88
+ # Everything else is effectively free (local computation)
89
+ }
90
+
91
+ # Default hourly limits by tier
92
+ DEFAULT_LIMIT_FREE = 100
93
+ DEFAULT_LIMIT_PRO = 20
94
+ DEFAULT_LIMIT_DELIBERATION = 5
95
+
96
+ # Session cost cap
97
+ DEFAULT_SESSION_COST_CAP = 5.0
98
+
99
+ # Warning threshold (fraction of limit used before emitting a warning)
100
+ WARNING_THRESHOLD = 0.80
101
+
102
+ # Sliding window duration in seconds (1 hour)
103
+ WINDOW_SECONDS = 3600
104
+
105
+
106
+ def _classify_tool(tool_name: str) -> str:
107
+ """Return the tier for a tool: 'deliberation', 'pro', or 'free'."""
108
+ if tool_name in DELIBERATION_TOOLS:
109
+ return "deliberation"
110
+ if tool_name in PRO_TOOLS:
111
+ return "pro"
112
+ return "free"
113
+
114
+
115
+ def _default_limit_for(tool_name: str) -> int:
116
+ """Return the default hourly call limit based on tool tier."""
117
+ tier = _classify_tool(tool_name)
118
+ if tier == "deliberation":
119
+ return DEFAULT_LIMIT_DELIBERATION
120
+ if tier == "pro":
121
+ return DEFAULT_LIMIT_PRO
122
+ return DEFAULT_LIMIT_FREE
123
+
124
+
125
+ def _load_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
126
+ """Load rate limit overrides from YAML config.
127
+
128
+ Returns a dict with optional keys:
129
+ session_cost_cap: float
130
+ tools: {tool_name: {limit: int, cost: float}}
131
+ tiers: {free: int, pro: int, deliberation: int}
132
+ """
133
+ if config_path is None:
134
+ config_path = Path.home() / ".delimit" / "rate_limits.yml"
135
+
136
+ if not config_path.exists():
137
+ return {}
138
+
139
+ try:
140
+ # Use PyYAML if available; fall back to a simple parser
141
+ try:
142
+ import yaml
143
+ with open(config_path) as f:
144
+ data = yaml.safe_load(f)
145
+ return data if isinstance(data, dict) else {}
146
+ except ImportError:
147
+ return _parse_simple_yaml(config_path)
148
+ except Exception as exc:
149
+ logger.warning("Failed to load rate_limits.yml: %s", exc)
150
+ return {}
151
+
152
+
153
+ def _parse_simple_yaml(path: Path) -> Dict[str, Any]:
154
+ """Minimal YAML-subset parser for flat key-value and one level of nesting.
155
+
156
+ Handles the structure we actually emit in the default config file without
157
+ requiring PyYAML as a hard dependency.
158
+ """
159
+ result: Dict[str, Any] = {}
160
+ current_section: Optional[str] = None
161
+ current_dict: Dict[str, Any] = {}
162
+
163
+ for raw_line in path.read_text().splitlines():
164
+ # Strip comments
165
+ line = raw_line.split("#")[0].rstrip()
166
+ if not line or not line.strip():
167
+ continue
168
+
169
+ indent = len(line) - len(line.lstrip())
170
+ stripped = line.strip()
171
+
172
+ if stripped.startswith("-"):
173
+ continue # skip list items for now
174
+
175
+ if ":" not in stripped:
176
+ continue
177
+
178
+ key, _, val = stripped.partition(":")
179
+ key = key.strip()
180
+ val = val.strip()
181
+
182
+ if indent == 0:
183
+ # Top-level key
184
+ if current_section and current_dict:
185
+ result[current_section] = current_dict
186
+ current_dict = {}
187
+ if val:
188
+ result[key] = _coerce_value(val)
189
+ current_section = None
190
+ else:
191
+ current_section = key
192
+ current_dict = {}
193
+ elif indent >= 2 and current_section:
194
+ if val:
195
+ current_dict[key] = _coerce_value(val)
196
+ else:
197
+ # Nested dict (two levels deep) — store sub-dict
198
+ current_dict[key] = {}
199
+
200
+ if current_section and current_dict:
201
+ result[current_section] = current_dict
202
+
203
+ return result
204
+
205
+
206
+ def _coerce_value(val: str) -> Any:
207
+ """Coerce a YAML scalar string to int, float, or str."""
208
+ if not val:
209
+ return val
210
+ # Remove quotes
211
+ if (val.startswith('"') and val.endswith('"')) or \
212
+ (val.startswith("'") and val.endswith("'")):
213
+ return val[1:-1]
214
+ try:
215
+ return int(val)
216
+ except ValueError:
217
+ pass
218
+ try:
219
+ return float(val)
220
+ except ValueError:
221
+ pass
222
+ if val.lower() in ("true", "yes"):
223
+ return True
224
+ if val.lower() in ("false", "no"):
225
+ return False
226
+ return val
227
+
228
+
229
+ class RateLimiter:
230
+ """Per-tool sliding-window rate limiter with session cost tracking.
231
+
232
+ Thread-safety: NOT thread-safe. MCP servers are single-threaded per
233
+ session, so this is fine. If that changes, add a lock.
234
+ """
235
+
236
+ def __init__(self, config_path: Optional[Path] = None):
237
+ # {tool_name: [timestamp, timestamp, ...]} — sorted ascending
238
+ self._calls: Dict[str, List[float]] = {}
239
+ # {tool_name: float} — cumulative cost per tool this session
240
+ self._costs: Dict[str, float] = {}
241
+ # Total session cost
242
+ self._session_cost: float = 0.0
243
+ # Session start time
244
+ self._session_start: float = time.time()
245
+ # Load config
246
+ self._config = _load_config(config_path)
247
+ self._custom_limits: Dict[str, int] = {}
248
+ self._load_custom_limits()
249
+
250
+ def _load_custom_limits(self) -> None:
251
+ """Extract per-tool limit overrides from the config."""
252
+ # Tier-level overrides
253
+ tiers = self._config.get("tiers", {})
254
+ if isinstance(tiers, dict):
255
+ self._tier_overrides = {
256
+ "free": int(tiers["free"]) if "free" in tiers else None,
257
+ "pro": int(tiers["pro"]) if "pro" in tiers else None,
258
+ "deliberation": int(tiers["deliberation"]) if "deliberation" in tiers else None,
259
+ }
260
+ else:
261
+ self._tier_overrides = {}
262
+
263
+ # Per-tool overrides
264
+ tools = self._config.get("tools", {})
265
+ if isinstance(tools, dict):
266
+ for tool_name, settings in tools.items():
267
+ if isinstance(settings, dict) and "limit" in settings:
268
+ self._custom_limits[tool_name] = int(settings["limit"])
269
+ elif isinstance(settings, (int, float)):
270
+ self._custom_limits[tool_name] = int(settings)
271
+
272
+ @property
273
+ def session_cost_cap(self) -> float:
274
+ """The maximum cost allowed per session."""
275
+ cap = self._config.get("session_cost_cap")
276
+ if cap is not None:
277
+ try:
278
+ return float(cap)
279
+ except (TypeError, ValueError):
280
+ pass
281
+ return DEFAULT_SESSION_COST_CAP
282
+
283
+ def _get_limit(self, tool_name: str) -> int:
284
+ """Resolve the effective hourly limit for a tool."""
285
+ # Per-tool override takes priority
286
+ if tool_name in self._custom_limits:
287
+ return self._custom_limits[tool_name]
288
+
289
+ # Tier override
290
+ tier = _classify_tool(tool_name)
291
+ tier_override = getattr(self, "_tier_overrides", {}).get(tier)
292
+ if tier_override is not None:
293
+ return tier_override
294
+
295
+ return _default_limit_for(tool_name)
296
+
297
+ def _get_cost_estimate(self, tool_name: str) -> float:
298
+ """Return the estimated cost per call for a tool."""
299
+ # Config override
300
+ tools = self._config.get("tools", {})
301
+ if isinstance(tools, dict) and tool_name in tools:
302
+ settings = tools[tool_name]
303
+ if isinstance(settings, dict) and "cost" in settings:
304
+ return float(settings["cost"])
305
+
306
+ return DEFAULT_COST_ESTIMATES.get(tool_name, 0.0)
307
+
308
+ def _prune_window(self, tool_name: str, now: float) -> List[float]:
309
+ """Remove call timestamps outside the sliding window, return remaining."""
310
+ if tool_name not in self._calls:
311
+ return []
312
+ cutoff = now - WINDOW_SECONDS
313
+ calls = self._calls[tool_name]
314
+ # Binary-ish prune: find first index >= cutoff
315
+ pruned = [t for t in calls if t >= cutoff]
316
+ self._calls[tool_name] = pruned
317
+ return pruned
318
+
319
+ def check(self, tool_name: str) -> Optional[Dict[str, Any]]:
320
+ """Check if calling tool_name is allowed right now.
321
+
322
+ Returns None if the call is permitted.
323
+ Returns an error dict if the call should be blocked.
324
+ """
325
+ now = time.time()
326
+
327
+ # 1. Session cost cap
328
+ if self._session_cost >= self.session_cost_cap:
329
+ return {
330
+ "error": "session_cost_exceeded",
331
+ "message": (
332
+ f"Session cost cap reached (${self._session_cost:.3f} / "
333
+ f"${self.session_cost_cap:.2f}). "
334
+ "To continue, increase the cap in ~/.delimit/rate_limits.yml "
335
+ "or call delimit_cost_controls to adjust."
336
+ ),
337
+ "session_cost": round(self._session_cost, 4),
338
+ "session_cost_cap": self.session_cost_cap,
339
+ }
340
+
341
+ # 2. Per-tool rate limit
342
+ recent = self._prune_window(tool_name, now)
343
+ limit = self._get_limit(tool_name)
344
+ count = len(recent)
345
+
346
+ if count >= limit:
347
+ # Calculate when the oldest call in the window will expire
348
+ oldest = recent[0] if recent else now
349
+ wait_seconds = int(oldest + WINDOW_SECONDS - now) + 1
350
+ wait_minutes = max(1, wait_seconds // 60)
351
+ tier = _classify_tool(tool_name)
352
+ return {
353
+ "error": "rate_limit_exceeded",
354
+ "message": (
355
+ f"Rate limit exceeded for '{tool_name}': "
356
+ f"{count}/{limit} calls/hour ({tier} tier). "
357
+ f"Try again in ~{wait_minutes} minute(s), or increase the "
358
+ f"limit in ~/.delimit/rate_limits.yml"
359
+ ),
360
+ "tool": tool_name,
361
+ "tier": tier,
362
+ "calls_used": count,
363
+ "calls_limit": limit,
364
+ "retry_after_seconds": wait_seconds,
365
+ }
366
+
367
+ # 3. Prospective cost check — would this call push us over?
368
+ estimated_cost = self._get_cost_estimate(tool_name)
369
+ if estimated_cost > 0 and (self._session_cost + estimated_cost) > self.session_cost_cap:
370
+ return {
371
+ "error": "session_cost_would_exceed",
372
+ "message": (
373
+ f"Executing '{tool_name}' (~${estimated_cost:.4f}) would "
374
+ f"exceed the session cost cap "
375
+ f"(${self._session_cost:.3f} + ${estimated_cost:.4f} > "
376
+ f"${self.session_cost_cap:.2f}). "
377
+ "Increase session_cost_cap in ~/.delimit/rate_limits.yml "
378
+ "or call delimit_cost_controls to adjust."
379
+ ),
380
+ "tool": tool_name,
381
+ "estimated_cost": estimated_cost,
382
+ "session_cost": round(self._session_cost, 4),
383
+ "session_cost_cap": self.session_cost_cap,
384
+ }
385
+
386
+ # 4. Warning at 80% usage
387
+ if count >= int(limit * WARNING_THRESHOLD) and count < limit:
388
+ remaining = limit - count
389
+ logger.warning(
390
+ "Rate limit warning: '%s' at %d/%d calls/hour (%d remaining)",
391
+ tool_name, count, limit, remaining,
392
+ )
393
+
394
+ return None # Allowed
395
+
396
+ def record(self, tool_name: str, cost: Optional[float] = None) -> None:
397
+ """Record a tool call and its cost.
398
+
399
+ Args:
400
+ tool_name: The MCP tool that was called.
401
+ cost: Actual cost in USD. If None, uses the default estimate.
402
+ """
403
+ now = time.time()
404
+
405
+ # Record timestamp
406
+ if tool_name not in self._calls:
407
+ self._calls[tool_name] = []
408
+ self._calls[tool_name].append(now)
409
+
410
+ # Record cost
411
+ if cost is None:
412
+ cost = self._get_cost_estimate(tool_name)
413
+ if cost > 0:
414
+ self._costs[tool_name] = self._costs.get(tool_name, 0.0) + cost
415
+ self._session_cost += cost
416
+
417
+ # Log periodic warnings
418
+ recent = self._prune_window(tool_name, now)
419
+ limit = self._get_limit(tool_name)
420
+ if len(recent) == int(limit * WARNING_THRESHOLD):
421
+ logger.warning(
422
+ "Rate limit 80%% reached for '%s': %d/%d calls this hour",
423
+ tool_name, len(recent), limit,
424
+ )
425
+
426
+ def get_usage(self) -> Dict[str, Any]:
427
+ """Return current session usage summary.
428
+
429
+ Returns a dict with:
430
+ session_cost: total cost this session
431
+ session_cost_cap: the configured cap
432
+ session_duration_seconds: how long the session has been active
433
+ tools: {tool_name: {calls_this_hour, limit, cost, tier, remaining}}
434
+ """
435
+ now = time.time()
436
+ tools_usage: Dict[str, Dict[str, Any]] = {}
437
+
438
+ # Collect all tools that have been called
439
+ all_tools = set(self._calls.keys()) | set(self._costs.keys())
440
+
441
+ for tool_name in sorted(all_tools):
442
+ recent = self._prune_window(tool_name, now)
443
+ limit = self._get_limit(tool_name)
444
+ count = len(recent)
445
+ tools_usage[tool_name] = {
446
+ "calls_this_hour": count,
447
+ "limit": limit,
448
+ "remaining": max(0, limit - count),
449
+ "cost_this_session": round(self._costs.get(tool_name, 0.0), 6),
450
+ "cost_per_call": self._get_cost_estimate(tool_name),
451
+ "tier": _classify_tool(tool_name),
452
+ }
453
+
454
+ return {
455
+ "session_cost": round(self._session_cost, 4),
456
+ "session_cost_cap": self.session_cost_cap,
457
+ "session_cost_remaining": round(
458
+ max(0, self.session_cost_cap - self._session_cost), 4
459
+ ),
460
+ "session_duration_seconds": int(now - self._session_start),
461
+ "tools": tools_usage,
462
+ }
463
+
464
+ def get_quota(self, tool_name: str) -> Dict[str, Any]:
465
+ """Return quota info for a single tool."""
466
+ now = time.time()
467
+ recent = self._prune_window(tool_name, now)
468
+ limit = self._get_limit(tool_name)
469
+ count = len(recent)
470
+ return {
471
+ "tool": tool_name,
472
+ "tier": _classify_tool(tool_name),
473
+ "calls_this_hour": count,
474
+ "limit": limit,
475
+ "remaining": max(0, limit - count),
476
+ "cost_this_session": round(self._costs.get(tool_name, 0.0), 6),
477
+ "cost_per_call": self._get_cost_estimate(tool_name),
478
+ }
479
+
480
+ def set_limit(self, tool_name: str, limit: int) -> None:
481
+ """Override the hourly limit for a tool (session-scoped, not persisted)."""
482
+ if limit < 0:
483
+ raise ValueError("Limit must be non-negative")
484
+ self._custom_limits[tool_name] = limit
485
+ logger.info("Rate limit for '%s' set to %d calls/hour", tool_name, limit)
486
+
487
+ def set_session_cost_cap(self, cap: float) -> None:
488
+ """Override the session cost cap (session-scoped, not persisted)."""
489
+ if cap < 0:
490
+ raise ValueError("Cost cap must be non-negative")
491
+ self._config["session_cost_cap"] = cap
492
+ logger.info("Session cost cap set to $%.2f", cap)
493
+
494
+ def reset(self) -> None:
495
+ """Reset all tracking state. Starts a fresh session."""
496
+ self._calls.clear()
497
+ self._costs.clear()
498
+ self._session_cost = 0.0
499
+ self._session_start = time.time()
500
+ logger.info("Rate limiter reset — new session started")
501
+
502
+ def reset_tool(self, tool_name: str) -> None:
503
+ """Reset tracking for a single tool."""
504
+ self._calls.pop(tool_name, None)
505
+ cost = self._costs.pop(tool_name, 0.0)
506
+ self._session_cost = max(0, self._session_cost - cost)
507
+ logger.info("Rate limiter reset for '%s'", tool_name)
508
+
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # Module-level singleton
512
+ # ---------------------------------------------------------------------------
513
+
514
+ limiter = RateLimiter()
515
+
516
+
517
+ def create_cost_controls_response(
518
+ action: str = "status",
519
+ tool_name: str = "",
520
+ limit: Optional[int] = None,
521
+ cost_cap: Optional[float] = None,
522
+ ) -> Dict[str, Any]:
523
+ """Handler logic for the delimit_cost_controls MCP tool.
524
+
525
+ Actions:
526
+ status — show full session usage
527
+ quota — show quota for a specific tool
528
+ set — set a custom limit for a tool or session cost cap
529
+ reset — reset all tracking
530
+ """
531
+ if action == "status":
532
+ return {
533
+ "status": "ok",
534
+ **limiter.get_usage(),
535
+ "hint": (
536
+ "Use action='quota' with tool_name to check a specific tool, "
537
+ "or action='set' to adjust limits."
538
+ ),
539
+ }
540
+
541
+ if action == "quota":
542
+ if not tool_name:
543
+ return {"error": "tool_name is required for action='quota'"}
544
+ return {"status": "ok", **limiter.get_quota(tool_name)}
545
+
546
+ if action == "set":
547
+ changes = []
548
+ if tool_name and limit is not None:
549
+ limiter.set_limit(tool_name, limit)
550
+ changes.append(f"{tool_name} limit set to {limit}/hour")
551
+ if cost_cap is not None:
552
+ limiter.set_session_cost_cap(cost_cap)
553
+ changes.append(f"session cost cap set to ${cost_cap:.2f}")
554
+ if not changes:
555
+ return {
556
+ "error": "Provide tool_name+limit to set a tool limit, "
557
+ "or cost_cap to set the session cost cap."
558
+ }
559
+ return {"status": "ok", "changes": changes}
560
+
561
+ if action == "reset":
562
+ limiter.reset()
563
+ return {"status": "ok", "message": "All rate limit tracking reset."}
564
+
565
+ return {
566
+ "error": f"Unknown action '{action}'",
567
+ "valid_actions": ["status", "quota", "set", "reset"],
568
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "3.12.1",
4
+ "version": "3.13.0",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [