delimit-cli 4.1.44 → 4.1.47

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.
@@ -723,6 +723,9 @@ if [ "$DELIMIT_WRAPPED" = "true" ] || [ ! -t 1 ]; then
723
723
  [ -x "$c" ] && exec "$c" "$@"
724
724
  done
725
725
  fi
726
+ # Record session start for exit screen
727
+ SESSION_START=\$(date +%s)
728
+ SESSION_CWD="\$(pwd)"
726
729
  # Auto-update in background (non-blocking)
727
730
  ( CURR=\$(delimit-cli --version 2>/dev/null); LATE=\$(npm view delimit-cli version 2>/dev/null); \\
728
731
  if [ -n "\$LATE" ] && [ "\$LATE" != "\$CURR" ]; then \\
@@ -754,18 +757,93 @@ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════
754
757
  sleep 0.08
755
758
  printf " \${GREEN}\${BOLD}[Delimit]\${RESET} \${GREEN}✓ Allowed\${RESET}\\n"
756
759
  echo ""
757
- # Find real binary — check renamed binary first, then common paths
758
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
759
- # 1. Check for renamed binary (tool-real) next to this shim
760
- [ -x "$SCRIPT_DIR/${toolName}-real" ] && exec "$SCRIPT_DIR/${toolName}-real" "$@"
761
- # 2. Check common paths (skip self)
762
- SELF="$(readlink -f "$0" 2>/dev/null || echo "$0")"
763
- for c in /usr/local/bin/${toolName}-real /usr/bin/${toolName}-real "$HOME/.local/bin/${toolName}-real" /usr/bin/${toolName} /usr/local/bin/${toolName} "$HOME/.local/bin/${toolName}" "$(npm bin -g 2>/dev/null)/${toolName}"; do
764
- [ -x "$c" ] && [ "$(readlink -f "$c" 2>/dev/null)" != "$SELF" ] && exec "$c" "$@"
765
- done
766
- # 3. Last resort: search PATH excluding shim directory
760
+
761
+ # --- Exit screen: session summary after AI exits ---
762
+ delimit_exit_screen() {
763
+ _EXIT_CODE=\$1
764
+ # Only show exit screen on interactive terminals
765
+ [ ! -t 1 ] && return
766
+ SESSION_END=\$(date +%s)
767
+ ELAPSED=\$((SESSION_END - SESSION_START))
768
+ # Format duration
769
+ if [ \$ELAPSED -ge 3600 ]; then
770
+ HOURS=\$((ELAPSED / 3600))
771
+ MINS=\$(( (ELAPSED % 3600) / 60 ))
772
+ DURATION="\${HOURS}h \${MINS}m"
773
+ elif [ \$ELAPSED -ge 60 ]; then
774
+ MINS=\$((ELAPSED / 60))
775
+ SECS=\$((ELAPSED % 60))
776
+ DURATION="\${MINS}m \${SECS}s"
777
+ else
778
+ DURATION="\${ELAPSED}s"
779
+ fi
780
+ # Count git commits made during session
781
+ COMMITS=0
782
+ if [ -d "\$SESSION_CWD/.git" ] || git -C "\$SESSION_CWD" rev-parse --git-dir >/dev/null 2>&1; then
783
+ COMMITS=\$(git -C "\$SESSION_CWD" log --oneline --after="\$SESSION_START" --format="%H" 2>/dev/null | wc -l | tr -d ' ')
784
+ fi
785
+ # Count ledger items created during session (by timestamp)
786
+ LEDGER_DIR="\$DELIMIT_HOME/ledger"
787
+ LEDGER_ITEMS=0
788
+ if [ -d "\$LEDGER_DIR" ]; then
789
+ for lf in "\$LEDGER_DIR"/*.jsonl; do
790
+ [ -f "\$lf" ] || continue
791
+ COUNT=\$(awk -v start="\$SESSION_START" '
792
+ BEGIN { n=0 }
793
+ {
794
+ if (match(\$0, /"(created_at|ts)":"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}/)) {
795
+ n++
796
+ } else if (match(\$0, /"(created_at|ts)":([0-9]+)/, arr)) {
797
+ if (arr[2]+0 >= start+0) n++
798
+ }
799
+ }
800
+ END { print n }
801
+ ' "\$lf" 2>/dev/null || echo "0")
802
+ LEDGER_ITEMS=\$((LEDGER_ITEMS + COUNT))
803
+ done
804
+ fi
805
+ # Count deliberations (governance decisions)
806
+ DELIBERATIONS=0
807
+ if [ -f "\$DELIMIT_HOME/deliberations.jsonl" ]; then
808
+ DELIBERATIONS=\$(awk -v start="\$SESSION_START" '
809
+ BEGIN { n=0 }
810
+ { if (match(\$0, /"ts":([0-9]+)/, arr)) { if (arr[1]+0 >= start+0) n++ } }
811
+ END { print n }
812
+ ' "\$DELIMIT_HOME/deliberations.jsonl" 2>/dev/null || echo "0")
813
+ fi
814
+ # Determine exit status label
815
+ if [ "\$_EXIT_CODE" -eq 0 ]; then
816
+ STATUS_LABEL="\${GREEN}clean exit\${RESET}"
817
+ else
818
+ STATUS_LABEL="\${ORANGE}exit code \${_EXIT_CODE}\${RESET}"
819
+ fi
820
+ echo ""
821
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
822
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${PURPLE}<\${MAGENTA}/\${ORANGE}>\${RESET} \${BOLD}SESSION COMPLETE: ${displayName.toUpperCase()}\${RESET}\\n"
823
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
824
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Duration:\${RESET} \${WHITE}\${DURATION}\${RESET}\\n"
825
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Status:\${RESET} \${STATUS_LABEL}\\n"
826
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Git commits:\${RESET} \${WHITE}\${COMMITS}\${RESET}\\n"
827
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Ledger items:\${RESET} \${WHITE}\${LEDGER_ITEMS}\${RESET}\\n"
828
+ printf " \${PURPLE}\${BOLD}[Delimit]\${RESET} \${DIM}Deliberations:\${RESET} \${WHITE}\${DELIBERATIONS}\${RESET}\\n"
829
+ printf " \${MAGENTA}\${BOLD}[Delimit]\${RESET} \${MAGENTA}═══════════════════════════════════════════\${RESET}\\n"
830
+ echo ""
831
+ }
832
+
833
+ # Run real binary and show exit screen (replaces exec to allow post-exit code)
834
+ delimit_run_and_exit() {
835
+ "\$@"
836
+ _RC=\$?
837
+ delimit_exit_screen \$_RC
838
+ exit \$_RC
839
+ }
840
+
841
+ # Find real binary by stripping shim dir from PATH.
842
+ # We rely on PATH ordering ($HOME/.delimit/shims first) — no rename hack,
843
+ # no race with npm reinstalls. Previously we used mv tool→tool-real + cp shim,
844
+ # which broke whenever npm/brew clobbered the binary mid-operation.
767
845
  REAL=$(PATH=$(echo "$PATH" | tr ':' '\\n' | grep -v '.delimit/shims' | tr '\\n' ':') command -v ${toolName} 2>/dev/null)
768
- [ -x "$REAL" ] && exec "$REAL" "$@"
846
+ [ -x "$REAL" ] && delimit_run_and_exit "$REAL" "$@"
769
847
  echo "[Delimit] ${toolName} not found in PATH" >&2
770
848
  case "${toolName}" in
771
849
  claude) echo " Install: npm install -g @anthropic-ai/claude-code" >&2 ;;
@@ -782,59 +860,17 @@ exit 127
782
860
  fs.chmodSync(shimPath, '755');
783
861
  }
784
862
 
785
- // Rename+wrap: place shim at the real binary's location
786
- // so it works immediately without PATH changes
787
- for (const tool of ['claude', 'codex', 'gemini']) {
788
- try {
789
- // Find real binary location
790
- let realPath = null;
791
- const searchPaths = [
792
- `/usr/local/bin/${tool}`,
793
- `/usr/bin/${tool}`,
794
- path.join(os.homedir(), '.local', 'bin', tool),
795
- ];
796
- // Also check npm global bin
797
- try {
798
- const npmBin = execSync('npm bin -g 2>/dev/null', { encoding: 'utf-8', timeout: 3000 }).trim();
799
- if (npmBin) searchPaths.push(path.join(npmBin, tool));
800
- } catch {}
801
-
802
- for (const p of searchPaths) {
803
- try {
804
- if (fs.existsSync(p) && fs.statSync(p).isFile()) {
805
- // Check it's the real binary, not already our shim
806
- const content = fs.readFileSync(p, 'utf-8').substring(0, 200);
807
- if (!content.includes('Delimit Governance Shim')) {
808
- realPath = p;
809
- break;
810
- }
811
- }
812
- } catch {}
813
- }
814
-
815
- if (realPath) {
816
- const dir = path.dirname(realPath);
817
- const realDest = path.join(dir, `${tool}-real`);
818
- // Only rename if not already renamed
819
- if (!fs.existsSync(realDest)) {
820
- fs.renameSync(realPath, realDest);
821
- }
822
- // Place our shim at the original location
823
- const shimContent = fs.readFileSync(path.join(shimsDir, tool), 'utf-8');
824
- // Update shim to also check for tool-real in same directory
825
- const patchedShim = shimContent.replace(
826
- `echo "[Delimit] ${tool} not found in PATH"`,
827
- `# Check for renamed binary next to shim\n` +
828
- `SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"\n` +
829
- `[ -x "$SCRIPT_DIR/${tool}-real" ] && exec "$SCRIPT_DIR/${tool}-real" "$@"\n` +
830
- `echo "[Delimit] ${tool} not found in PATH"`
831
- );
832
- fs.writeFileSync(realPath, patchedShim);
833
- fs.chmodSync(realPath, '755');
834
- log(` ${green('✓')} ${tool}: wrapped at ${dim(realPath)}`);
835
- }
836
- } catch {}
837
- }
863
+ // Governance is enforced via PATH ordering $HOME/.delimit/shims
864
+ // is prepended to PATH (see below), so `claude`/`codex`/`gemini`
865
+ // resolve to our shim first, and the shim then PATH-strips itself
866
+ // and execs the real binary.
867
+ //
868
+ // We deliberately do NOT mv /usr/bin/claude → claude-real and copy
869
+ // the shim into /usr/bin/claude. That rename+wrap approach raced
870
+ // with npm reinstalls (which clobber /usr/bin/claude back to a
871
+ // symlink), leaving users with "[Delimit] claude not found in PATH"
872
+ // when the rename ran but the shim copy failed or the symlink got
873
+ // re-created mid-operation. PATH ordering is the durable contract.
838
874
 
839
875
  // Add to PATH in shell rc files (create if missing)
840
876
  const pathLine = `export PATH="${shimsDir}:$PATH" # Delimit governance wrapping`;
@@ -6,8 +6,9 @@ heavy MCP decorator dependencies).
6
6
 
7
7
  import json
8
8
  import os
9
+ import stat
9
10
  from pathlib import Path
10
- from typing import Dict, Any
11
+ from typing import Dict, Any, List
11
12
 
12
13
 
13
14
  def activate_auto_permissions(auto_permissions: bool) -> dict:
@@ -96,6 +97,228 @@ def configure_codex_permissions(config_path: Path) -> dict:
96
97
  return {"item": "Permissions", "status": "Pass", "detail": "Codex: delimit section exists"}
97
98
 
98
99
 
100
+ # ─── LED-269: Filesystem permission auto-config for delimit_init ──────
101
+ #
102
+ # Safety contract:
103
+ # - Never chmod 777
104
+ # - Never touch anything outside <project>/.delimit/ or
105
+ # <project>/.claude/settings.json
106
+ # - Never modify existing permissions on files we did not create
107
+ # (we only chmod files/dirs we just created or that match our
108
+ # known-safe set: .delimit/ itself, .delimit/secrets/* files)
109
+ # - Idempotent: running twice is a no-op for already-correct state
110
+ # - Backwards compatible: existing installs work without re-running init
111
+
112
+
113
+ # Reasonable default permission allowlist for AI assistants working
114
+ # inside a Delimit-governed project. Mirrors what most projects already
115
+ # grant manually. Conservative — no `Bash(rm:*)`, no wildcards on dangerous
116
+ # commands, no network egress beyond what tools need.
117
+ _DEFAULT_CLAUDE_PROJECT_ALLOW: List[str] = [
118
+ "Edit",
119
+ "Write",
120
+ "Read",
121
+ "Glob",
122
+ "Grep",
123
+ "Bash(git status)",
124
+ "Bash(git diff:*)",
125
+ "Bash(git log:*)",
126
+ "Bash(git add:*)",
127
+ "Bash(ls:*)",
128
+ "Bash(cat:*)",
129
+ "Bash(pwd)",
130
+ "Bash(delimit:*)",
131
+ "Bash(npm test:*)",
132
+ "Bash(npm run:*)",
133
+ "Bash(pytest:*)",
134
+ "Bash(python:*)",
135
+ "Bash(python3:*)",
136
+ "mcp__delimit__*",
137
+ ]
138
+
139
+
140
+ def _safe_chmod(path: Path, mode: int) -> bool:
141
+ """chmod a path defensively. Returns True if applied, False on any error.
142
+
143
+ Refuses world-writable bits (0o002) outright. Never raises.
144
+ """
145
+ if mode & 0o002:
146
+ return False
147
+ try:
148
+ path.chmod(mode)
149
+ return True
150
+ except (OSError, PermissionError):
151
+ return False
152
+
153
+
154
+ def _detect_target_owner(project_root: Path) -> tuple:
155
+ """If running as root, detect the (uid, gid) of the project owner so
156
+ we can chown anything we create back to them. Returns (None, None)
157
+ if not running as root or detection fails.
158
+ """
159
+ try:
160
+ if os.geteuid() != 0:
161
+ return (None, None)
162
+ except AttributeError:
163
+ # Windows or platform without geteuid
164
+ return (None, None)
165
+
166
+ try:
167
+ st = project_root.stat()
168
+ # Don't chown to root-owned projects (no-op)
169
+ if st.st_uid == 0 and st.st_gid == 0:
170
+ return (None, None)
171
+ return (st.st_uid, st.st_gid)
172
+ except OSError:
173
+ return (None, None)
174
+
175
+
176
+ def _safe_chown(path: Path, uid, gid) -> None:
177
+ """chown a path defensively. Never raises."""
178
+ if uid is None or gid is None:
179
+ return
180
+ try:
181
+ os.chown(str(path), uid, gid)
182
+ except (OSError, PermissionError, AttributeError):
183
+ pass
184
+
185
+
186
+ def setup_init_permissions(project_root: Path, no_permissions: bool = False) -> Dict[str, Any]:
187
+ """LED-269: Configure filesystem permissions for a freshly-initialized
188
+ Delimit project.
189
+
190
+ Performs:
191
+ 1. chmod 755 on <project>/.delimit/ (idempotent)
192
+ 2. chmod 600 on every file under <project>/.delimit/secrets/ (if dir exists)
193
+ 3. Creates <project>/.claude/settings.json with a reasonable Edit/Write/
194
+ Bash allowlist if it does not already exist (never overwrites)
195
+ 4. If running as root, chowns anything we created back to the project
196
+ owner so the user can still access their own files
197
+
198
+ Args:
199
+ project_root: Resolved absolute path to the project root.
200
+ no_permissions: If True, skip everything and return a 'skipped' result.
201
+
202
+ Returns:
203
+ Dict with keys: status, applied (list of changes), skipped (list of
204
+ items skipped with reason), warnings (list).
205
+ """
206
+ result: Dict[str, Any] = {
207
+ "status": "skipped" if no_permissions else "ok",
208
+ "applied": [],
209
+ "skipped": [],
210
+ "warnings": [],
211
+ }
212
+
213
+ if no_permissions:
214
+ result["skipped"].append("--no-permissions flag set")
215
+ return result
216
+
217
+ project_root = Path(project_root).resolve()
218
+ delimit_dir = project_root / ".delimit"
219
+
220
+ # Hard safety guard: refuse to operate if .delimit/ doesn't exist.
221
+ # delimit_init creates it before calling us — if it's missing something
222
+ # is wrong and we should not silently chmod random paths.
223
+ if not delimit_dir.is_dir():
224
+ result["status"] = "error"
225
+ result["warnings"].append(f".delimit/ not found at {delimit_dir} — refusing to set permissions")
226
+ return result
227
+
228
+ target_uid, target_gid = _detect_target_owner(project_root)
229
+ running_as_root = target_uid is not None
230
+
231
+ # 1. chmod 755 on .delimit/
232
+ try:
233
+ current_mode = stat.S_IMODE(delimit_dir.stat().st_mode)
234
+ if current_mode != 0o755:
235
+ if _safe_chmod(delimit_dir, 0o755):
236
+ result["applied"].append(f"chmod 755 {delimit_dir}")
237
+ else:
238
+ result["warnings"].append(f"Could not chmod {delimit_dir}")
239
+ else:
240
+ result["skipped"].append(f"{delimit_dir} already 755")
241
+ except OSError as e:
242
+ result["warnings"].append(f"stat failed on {delimit_dir}: {e}")
243
+
244
+ # 2. chmod 600 on secrets files (only if secrets dir exists)
245
+ secrets_dir = delimit_dir / "secrets"
246
+ if secrets_dir.is_dir():
247
+ # Lock the secrets dir itself to 700
248
+ try:
249
+ current_mode = stat.S_IMODE(secrets_dir.stat().st_mode)
250
+ if current_mode != 0o700:
251
+ if _safe_chmod(secrets_dir, 0o700):
252
+ result["applied"].append(f"chmod 700 {secrets_dir}")
253
+ except OSError:
254
+ pass
255
+
256
+ for entry in secrets_dir.iterdir():
257
+ if not entry.is_file():
258
+ continue
259
+ try:
260
+ current_mode = stat.S_IMODE(entry.stat().st_mode)
261
+ if current_mode != 0o600:
262
+ if _safe_chmod(entry, 0o600):
263
+ result["applied"].append(f"chmod 600 {entry}")
264
+ else:
265
+ result["warnings"].append(f"Could not chmod {entry}")
266
+ except OSError:
267
+ continue
268
+ else:
269
+ result["skipped"].append("no secrets/ directory present")
270
+
271
+ # 3. Create project-local .claude/settings.json with reasonable allowlist
272
+ claude_dir = project_root / ".claude"
273
+ claude_settings = claude_dir / "settings.json"
274
+ if claude_settings.exists():
275
+ result["skipped"].append(f"{claude_settings} already exists (not modified)")
276
+ else:
277
+ try:
278
+ claude_dir.mkdir(parents=True, exist_ok=True)
279
+ settings_payload = {
280
+ "permissions": {
281
+ "allow": list(_DEFAULT_CLAUDE_PROJECT_ALLOW),
282
+ "deny": [
283
+ "Bash(rm -rf:*)",
284
+ "Bash(sudo:*)",
285
+ ],
286
+ },
287
+ "_generated_by": "delimit_init",
288
+ "_note": "Edit freely. Delimit will never overwrite this file.",
289
+ }
290
+ claude_settings.write_text(json.dumps(settings_payload, indent=2) + "\n")
291
+ # Lock to 644 (or 600 if it lives in a secrets dir, which it doesn't)
292
+ _safe_chmod(claude_settings, 0o644)
293
+ result["applied"].append(f"created {claude_settings}")
294
+
295
+ if running_as_root:
296
+ _safe_chown(claude_dir, target_uid, target_gid)
297
+ _safe_chown(claude_settings, target_uid, target_gid)
298
+ except OSError as e:
299
+ result["warnings"].append(f"Could not create {claude_settings}: {e}")
300
+
301
+ # 4. Re-chown .delimit/ tree if we're root and there's a non-root owner
302
+ if running_as_root:
303
+ try:
304
+ for path in [delimit_dir, *delimit_dir.rglob("*")]:
305
+ # Only chown if currently owned by root — never override
306
+ # an existing non-root owner
307
+ try:
308
+ if path.stat().st_uid == 0:
309
+ _safe_chown(path, target_uid, target_gid)
310
+ except OSError:
311
+ continue
312
+ result["applied"].append(f"chown -R {target_uid}:{target_gid} {delimit_dir} (root-owned entries)")
313
+ except OSError:
314
+ pass
315
+
316
+ if not result["applied"] and not result["warnings"]:
317
+ result["status"] = "noop"
318
+
319
+ return result
320
+
321
+
99
322
  def build_checklist(
100
323
  license_key: str,
101
324
  project_path: str,
@@ -185,26 +408,49 @@ def build_checklist(
185
408
  checklist.append({"item": label, "status": "Skip (Pro)", "detail": "Requires Delimit Pro"})
186
409
 
187
410
  # --- Score: only count applicable checks (exclude skips) ---
411
+ # LED-270: explicitly distinguish passed / failed / skipped so callers
412
+ # (CI, dashboards, CLI summaries) can render them separately. Skipped
413
+ # checks (premium on free tier, no test framework, no AI assistant)
414
+ # never count as failures and are excluded from the score denominator.
188
415
  applicable = [c for c in checklist if not c["status"].startswith("Skip")]
189
416
  passed_total = sum(1 for c in applicable if c["status"] == "Pass")
417
+ failed_total = sum(1 for c in applicable if c["status"] == "Fail")
418
+ skipped_total = len(checklist) - len(applicable)
419
+ # Break out skip reasons so the UI can show "X Pro features locked"
420
+ # without conflating them with "no test framework"-style skips.
421
+ skipped_premium = sum(1 for c in checklist if c["status"] == "Skip (Pro)")
422
+ skipped_other = skipped_total - skipped_premium
190
423
  total = len(applicable)
191
424
  score = f"{passed_total}/{total}"
192
425
 
426
+ tier = get_license().get("tier", "free")
427
+
193
428
  result: Dict[str, Any] = {
194
429
  "tool": "activate",
195
430
  "status": "complete",
196
431
  "score": score,
197
432
  "passed": passed_total,
433
+ "failed": failed_total,
198
434
  "total": total,
199
- "skipped": len(checklist) - total,
435
+ "skipped": skipped_total,
436
+ "skipped_premium": skipped_premium,
437
+ "skipped_other": skipped_other,
200
438
  "checklist": checklist,
201
- "tier": get_license().get("tier", "free"),
439
+ "tier": tier,
202
440
  "project": str(p),
203
441
  }
204
- if passed_total == total and total > 0:
205
- result["message"] = f"All {total} checks passed. Delimit is fully operational."
206
- elif passed_total < total:
442
+ if failed_total == 0 and total > 0:
443
+ msg = f"All {total} checks passed. Delimit is fully operational."
444
+ if skipped_premium > 0 and tier == "free":
445
+ msg += f" ({skipped_premium} Pro features available with upgrade.)"
446
+ result["message"] = msg
447
+ elif failed_total > 0:
207
448
  failed_items = [c["item"] for c in applicable if c["status"] == "Fail"]
208
- result["message"] = f"{passed_total}/{total} checks passed. Fix: {', '.join(failed_items)}"
449
+ result["message"] = (
450
+ f"{passed_total}/{total} checks passed, {failed_total} failed. "
451
+ f"Fix: {', '.join(failed_items)}"
452
+ )
453
+ else:
454
+ result["message"] = f"{passed_total}/{total} checks passed."
209
455
 
210
456
  return result