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.
- package/CHANGELOG.md +6 -0
- package/bin/delimit-cli.js +365 -30
- package/bin/delimit-setup.js +100 -64
- package/gateway/ai/activate_helpers.py +253 -7
- package/gateway/ai/backends/gateway_core.py +236 -13
- package/gateway/ai/backends/repo_bridge.py +80 -16
- package/gateway/ai/backends/tools_infra.py +49 -32
- package/gateway/ai/checksums.sha256 +6 -0
- package/gateway/ai/continuity.py +462 -0
- package/gateway/ai/deliberation.pyi +53 -0
- package/gateway/ai/governance.pyi +32 -0
- package/gateway/ai/governance_hardening.py +569 -0
- package/gateway/ai/inbox_daemon_runner.py +217 -0
- package/gateway/ai/ledger_manager.py +40 -0
- package/gateway/ai/license.py +104 -3
- package/gateway/ai/license_core.py +177 -36
- package/gateway/ai/license_core.pyi +50 -0
- package/gateway/ai/loop_engine.py +786 -22
- package/gateway/ai/reddit_scanner.py +150 -5
- package/gateway/ai/server.py +254 -19
- package/gateway/ai/swarm.py +86 -0
- package/gateway/ai/swarm_infra.py +656 -0
- package/gateway/ai/tweet_corpus_schema.sql +76 -0
- package/gateway/core/diff_engine_v2.py +6 -2
- package/gateway/core/generator_drift.py +242 -0
- package/gateway/core/json_schema_diff.py +375 -0
- package/gateway/core/openapi_version.py +124 -0
- package/gateway/core/spec_detector.py +47 -7
- package/gateway/core/spec_health.py +5 -2
- package/lib/cross-model-hooks.js +4 -12
- package/package.json +8 -1
- package/scripts/sync-gateway.sh +13 -1
package/bin/delimit-setup.js
CHANGED
|
@@ -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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
#
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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" ] &&
|
|
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
|
-
//
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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":
|
|
435
|
+
"skipped": skipped_total,
|
|
436
|
+
"skipped_premium": skipped_premium,
|
|
437
|
+
"skipped_other": skipped_other,
|
|
200
438
|
"checklist": checklist,
|
|
201
|
-
"tier":
|
|
439
|
+
"tier": tier,
|
|
202
440
|
"project": str(p),
|
|
203
441
|
}
|
|
204
|
-
if
|
|
205
|
-
|
|
206
|
-
|
|
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"] =
|
|
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
|