diff --git a/.gitignore b/.gitignore index 189276fb66..cf471aac86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .env node_modules/ browse/dist/ -bin/gstack-global-discover +bin/ .gstack/ .claude/skills/ .agents/ diff --git a/bin/dev-setup b/bin/dev-setup deleted file mode 100755 index a5bd482752..0000000000 --- a/bin/dev-setup +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Set up gstack for local development — test skills from within this repo. -# -# Creates .claude/skills/gstack → (symlink to repo root) so Claude Code -# discovers skills from your working tree. Changes take effect immediately. -# -# Also copies .env from the main worktree if this is a Conductor workspace -# or git worktree (so API keys carry over automatically). -# -# Usage: bin/dev-setup # set up -# bin/dev-teardown # clean up -set -e - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -# 1. Copy .env from main worktree (if we're a worktree and don't have one) -if [ ! -f "$REPO_ROOT/.env" ]; then - MAIN_WORKTREE="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | head -1 | sed 's/^worktree //')" - if [ -n "$MAIN_WORKTREE" ] && [ "$MAIN_WORKTREE" != "$REPO_ROOT" ] && [ -f "$MAIN_WORKTREE/.env" ]; then - cp "$MAIN_WORKTREE/.env" "$REPO_ROOT/.env" - echo "Copied .env from main worktree ($MAIN_WORKTREE)" - fi -fi - -# 2. Install dependencies -if [ ! -d "$REPO_ROOT/node_modules" ]; then - echo "Installing dependencies..." - (cd "$REPO_ROOT" && bun install) -fi - -# 3. Create .claude/skills/ inside the repo -mkdir -p "$REPO_ROOT/.claude/skills" - -# 4. Symlink .claude/skills/gstack → repo root -# This makes setup think it's inside a real .claude/skills/ directory -GSTACK_LINK="$REPO_ROOT/.claude/skills/gstack" -if [ -L "$GSTACK_LINK" ]; then - echo "Updating existing symlink..." - rm "$GSTACK_LINK" -elif [ -d "$GSTACK_LINK" ]; then - echo "Error: .claude/skills/gstack is a real directory, not a symlink." >&2 - echo "Remove it manually if you want to use dev mode." >&2 - exit 1 -fi -ln -s "$REPO_ROOT" "$GSTACK_LINK" - -# 5. Create .agents/skills/gstack → repo root (for Codex/Gemini/Cursor) -mkdir -p "$REPO_ROOT/.agents/skills" -AGENTS_LINK="$REPO_ROOT/.agents/skills/gstack" -if [ -L "$AGENTS_LINK" ]; then - rm "$AGENTS_LINK" -elif [ -d "$AGENTS_LINK" ]; then - echo "Warning: .agents/skills/gstack is a real directory, skipping." >&2 -fi -if [ ! -e "$AGENTS_LINK" ]; then - ln -s "$REPO_ROOT" "$AGENTS_LINK" -fi - -# 6. Run setup via the symlink so it detects .claude/skills/ as its parent -"$GSTACK_LINK/setup" - -echo "" -echo "Dev mode active. Skills resolve from this working tree." -echo " .claude/skills/gstack → $REPO_ROOT" -echo " .agents/skills/gstack → $REPO_ROOT" -echo "Edit any SKILL.md and test immediately — no copy/deploy needed." -echo "" -echo "To tear down: bin/dev-teardown" diff --git a/bin/dev-teardown b/bin/dev-teardown deleted file mode 100755 index dc8f742609..0000000000 --- a/bin/dev-teardown +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Remove local dev skill symlinks. Restores global gstack as the active install. -set -e - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" - -removed=() - -# ─── Clean up .claude/skills/ ───────────────────────────────── -CLAUDE_SKILLS="$REPO_ROOT/.claude/skills" -if [ -d "$CLAUDE_SKILLS" ]; then - for link in "$CLAUDE_SKILLS"/*/; do - name="$(basename "$link")" - [ "$name" = "gstack" ] && continue - if [ -L "${link%/}" ]; then - rm "${link%/}" - removed+=("claude/$name") - fi - done - - if [ -L "$CLAUDE_SKILLS/gstack" ]; then - rm "$CLAUDE_SKILLS/gstack" - removed+=("claude/gstack") - fi - - rmdir "$CLAUDE_SKILLS" 2>/dev/null || true - rmdir "$REPO_ROOT/.claude" 2>/dev/null || true -fi - -# ─── Clean up .agents/skills/ ──────────────────────────────── -AGENTS_SKILLS="$REPO_ROOT/.agents/skills" -if [ -d "$AGENTS_SKILLS" ]; then - for link in "$AGENTS_SKILLS"/*/; do - name="$(basename "$link")" - [ "$name" = "gstack" ] && continue - if [ -L "${link%/}" ]; then - rm "${link%/}" - removed+=("agents/$name") - fi - done - - if [ -L "$AGENTS_SKILLS/gstack" ]; then - rm "$AGENTS_SKILLS/gstack" - removed+=("agents/gstack") - fi - - rmdir "$AGENTS_SKILLS" 2>/dev/null || true - rmdir "$REPO_ROOT/.agents" 2>/dev/null || true -fi - -if [ ${#removed[@]} -gt 0 ]; then - echo "Removed: ${removed[*]}" -else - echo "No symlinks found." -fi -echo "Dev mode deactivated. Global gstack (~/.claude/skills/gstack) is now active." diff --git a/bin/gstack-analytics b/bin/gstack-analytics deleted file mode 100755 index ad06edd167..0000000000 --- a/bin/gstack-analytics +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -# gstack-analytics — personal usage dashboard from local JSONL -# -# Usage: -# gstack-analytics # default: last 7 days -# gstack-analytics 7d # last 7 days -# gstack-analytics 30d # last 30 days -# gstack-analytics all # all time -# -# Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory -set -uo pipefail - -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" -JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" - -# ─── Parse time window ─────────────────────────────────────── -WINDOW="${1:-7d}" -case "$WINDOW" in - 7d) DAYS=7; LABEL="last 7 days" ;; - 30d) DAYS=30; LABEL="last 30 days" ;; - all) DAYS=0; LABEL="all time" ;; - *) DAYS=7; LABEL="last 7 days" ;; -esac - -# ─── Check for data ────────────────────────────────────────── -if [ ! -f "$JSONL_FILE" ]; then - echo "gstack usage — no data yet" - echo "" - echo "Usage data will appear here after you use gstack skills" - echo "with telemetry enabled (gstack-config set telemetry anonymous)." - exit 0 -fi - -TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" -if [ "$TOTAL_LINES" = "0" ]; then - echo "gstack usage — no data yet" - exit 0 -fi - -# ─── Filter by time window ─────────────────────────────────── -if [ "$DAYS" -gt 0 ] 2>/dev/null; then - # Calculate cutoff date - if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then - # macOS date - CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" - else - # GNU date - CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" - fi - # Filter: skill_run events (new format) OR basic skill events (old format, no event_type) - # Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field) - # New format: {"event_type":"skill_run","skill":"X","ts":"Y",...} - FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' - /"ts":"/ { - # Skip hook_fire events - if (/"event":"hook_fire"/) next - # Skip non-skill_run new-format events - if (/"event_type":"/ && !/"event_type":"skill_run"/) next - for (i=1; i<=NF; i++) { - if ($i == "ts" && $(i+1) ~ /^:/) { - ts = $(i+2) - if (ts >= cutoff) { print; break } - } - } - } - ' "$JSONL_FILE")" -else - # All time: include skill_run events + old-format basic events, exclude hook_fire - FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 2>/dev/null || true)" -fi - -if [ -z "$FILTERED" ]; then - echo "gstack usage ($LABEL) — no skill runs found" - exit 0 -fi - -# ─── Aggregate by skill ────────────────────────────────────── -# Extract skill names and count -SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' - /"skill":"/ { - for (i=1; i<=NF; i++) { - if ($i == "skill" && $(i+1) ~ /^:/) { - skill = $(i+2) - counts[skill]++ - break - } - } - } - END { - for (s in counts) print counts[s], s - } -' | sort -rn)" - -# Count outcomes -TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" -SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' || true)" -SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')" -ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)" -ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')" -# Old format events have no outcome field — count them as successful -NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)" -NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')" -SUCCESS=$(( SUCCESS + NO_OUTCOME )) - -# Calculate success rate -if [ "$TOTAL" -gt 0 ] 2>/dev/null; then - SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) -else - SUCCESS_RATE=100 -fi - -# ─── Calculate total duration ──────────────────────────────── -TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' - /"duration_s"/ { - for (i=1; i<=NF; i++) { - if ($i ~ /"duration_s"/) { - val = $(i+1) - gsub(/[^0-9.]/, "", val) - if (val+0 > 0) total += val - } - } - } - END { printf "%.0f", total } -')" - -# Format duration -TOTAL_DURATION="${TOTAL_DURATION:-0}" -if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then - HOURS=$(( TOTAL_DURATION / 3600 )) - MINS=$(( (TOTAL_DURATION % 3600) / 60 )) - DUR_DISPLAY="${HOURS}h ${MINS}m" -elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then - MINS=$(( TOTAL_DURATION / 60 )) - DUR_DISPLAY="${MINS}m" -else - DUR_DISPLAY="${TOTAL_DURATION}s" -fi - -# ─── Render output ─────────────────────────────────────────── -echo "gstack usage ($LABEL)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -# Find max count for bar scaling -MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" -BAR_WIDTH=20 - -echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do - # Scale bar - if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then - BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) - else - BAR_LEN=1 - fi - [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 - - # Build bar - BAR="" - i=0 - while [ "$i" -lt "$BAR_LEN" ]; do - BAR="${BAR}█" - i=$(( i + 1 )) - done - - # Calculate avg duration for this skill - AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' - index($0, "\"skill\":\"" skill "\"") > 0 { - # Extract duration_s value using split on "duration_s": - n = split($0, parts, "\"duration_s\":") - if (n >= 2) { - # parts[2] starts with the value, e.g. "142," - gsub(/[^0-9.].*/, "", parts[2]) - if (parts[2]+0 > 0) { total += parts[2]; count++ } - } - } - END { if (count > 0) printf "%.0f", total/count; else print "0" } - ')" - - # Format avg duration - if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then - AVG_DISPLAY="$(( AVG_DUR / 60 ))m" - else - AVG_DISPLAY="${AVG_DUR}s" - fi - - printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" -done - -echo "" -echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" -echo "Events: ${TOTAL} skill runs" diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard deleted file mode 100755 index 5b7fc7ecf7..0000000000 --- a/bin/gstack-community-dashboard +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# gstack-community-dashboard — community usage stats from Supabase -# -# Queries the Supabase REST API to show community-wide gstack usage: -# skill popularity, crash clusters, version distribution, retention. -# -# Env overrides (for testing): -# GSTACK_DIR — override auto-detected gstack root -# GSTACK_SUPABASE_URL — override Supabase project URL -# GSTACK_SUPABASE_ANON_KEY — override Supabase anon key -set -uo pipefail - -GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" - -# Source Supabase config if not overridden by env -if [ -z "${GSTACK_SUPABASE_URL:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then - . "$GSTACK_DIR/supabase/config.sh" -fi -SUPABASE_URL="${GSTACK_SUPABASE_URL:-}" -ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" - -if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then - echo "gstack community dashboard" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Supabase not configured yet. The community dashboard will be" - echo "available once the gstack Supabase project is set up." - echo "" - echo "For local analytics, run: gstack-analytics" - exit 0 -fi - -# ─── Helper: query Supabase REST API ───────────────────────── -query() { - local table="$1" - local params="${2:-}" - curl -sf --max-time 10 \ - "${SUPABASE_URL}/rest/v1/${table}?${params}" \ - -H "apikey: ${ANON_KEY}" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - 2>/dev/null || echo "[]" -} - -echo "gstack community dashboard" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -# ─── Weekly active installs ────────────────────────────────── -WEEK_AGO="$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")" -if [ -n "$WEEK_AGO" ]; then - PULSE="$(curl -sf --max-time 10 \ - "${SUPABASE_URL}/functions/v1/community-pulse" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - 2>/dev/null || echo '{"weekly_active":0}')" - - WEEKLY="$(echo "$PULSE" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" - CHANGE="$(echo "$PULSE" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" - - echo "Weekly active installs: ${WEEKLY}" - if [ "$CHANGE" -gt 0 ] 2>/dev/null; then - echo " Change: +${CHANGE}%" - elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then - echo " Change: ${CHANGE}%" - fi - echo "" -fi - -# ─── Skill popularity (top 10) ─────────────────────────────── -echo "Top skills (last 7 days)" -echo "────────────────────────" - -# Query telemetry_events, group by skill -EVENTS="$(query "telemetry_events" "select=skill,gstack_version&event_type=eq.skill_run&event_timestamp=gte.${WEEK_AGO}&limit=1000" 2>/dev/null || echo "[]")" - -if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then - echo "$EVENTS" | grep -o '"skill":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -10 | while read -r COUNT SKILL; do - printf " /%-20s %d runs\n" "$SKILL" "$COUNT" - done -else - echo " No data yet" -fi -echo "" - -# ─── Crash clusters ────────────────────────────────────────── -echo "Top crash clusters" -echo "──────────────────" - -CRASHES="$(query "crash_clusters" "select=error_class,gstack_version,total_occurrences,identified_users&limit=5" 2>/dev/null || echo "[]")" - -if [ "$CRASHES" != "[]" ] && [ -n "$CRASHES" ]; then - echo "$CRASHES" | grep -o '"error_class":"[^"]*"' | awk -F'"' '{print $4}' | head -5 | while read -r ERR; do - C="$(echo "$CRASHES" | grep -o "\"error_class\":\"$ERR\"[^}]*\"total_occurrences\":[0-9]*" | grep -o '"total_occurrences":[0-9]*' | head -1 | grep -o '[0-9]*')" - printf " %-30s %s occurrences\n" "$ERR" "${C:-?}" - done -else - echo " No crashes reported" -fi -echo "" - -# ─── Version distribution ──────────────────────────────────── -echo "Version distribution (last 7 days)" -echo "───────────────────────────────────" - -if [ "$EVENTS" != "[]" ] && [ -n "$EVENTS" ]; then - echo "$EVENTS" | grep -o '"gstack_version":"[^"]*"' | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -5 | while read -r COUNT VER; do - printf " v%-15s %d events\n" "$VER" "$COUNT" - done -else - echo " No data yet" -fi - -echo "" -echo "For local analytics: gstack-analytics" diff --git a/bin/gstack-config b/bin/gstack-config deleted file mode 100755 index e99a940b1e..0000000000 --- a/bin/gstack-config +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# gstack-config — read/write ~/.gstack/config.yaml -# -# Usage: -# gstack-config get — read a config value -# gstack-config set — write a config value -# gstack-config list — show all config -# -# Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory -set -euo pipefail - -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" -CONFIG_FILE="$STATE_DIR/config.yaml" - -case "${1:-}" in - get) - KEY="${2:?Usage: gstack-config get }" - grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true - ;; - set) - KEY="${2:?Usage: gstack-config set }" - VALUE="${3:?Usage: gstack-config set }" - mkdir -p "$STATE_DIR" - if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then - sed -i '' "s/^${KEY}:.*/${KEY}: ${VALUE}/" "$CONFIG_FILE" - else - echo "${KEY}: ${VALUE}" >> "$CONFIG_FILE" - fi - ;; - list) - cat "$CONFIG_FILE" 2>/dev/null || true - ;; - *) - echo "Usage: gstack-config {get|set|list} [key] [value]" - exit 1 - ;; -esac diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope deleted file mode 100755 index f656732d20..0000000000 --- a/bin/gstack-diff-scope +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# gstack-diff-scope — categorize what changed in the diff against a base branch -# Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... -# Or: gstack-diff-scope main → prints SCOPE_*=... lines -set -euo pipefail - -BASE="${1:-main}" - -# Get changed file list -FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "") - -if [ -z "$FILES" ]; then - echo "SCOPE_FRONTEND=false" - echo "SCOPE_BACKEND=false" - echo "SCOPE_PROMPTS=false" - echo "SCOPE_TESTS=false" - echo "SCOPE_DOCS=false" - echo "SCOPE_CONFIG=false" - exit 0 -fi - -FRONTEND=false -BACKEND=false -PROMPTS=false -TESTS=false -DOCS=false -CONFIG=false - -while IFS= read -r f; do - case "$f" in - # Frontend: CSS, views, components, templates - *.css|*.scss|*.less|*.sass|*.pcss|*.module.css|*.module.scss) FRONTEND=true ;; - *.tsx|*.jsx|*.vue|*.svelte|*.astro) FRONTEND=true ;; - *.erb|*.haml|*.slim|*.hbs|*.ejs) FRONTEND=true ;; - *.html) FRONTEND=true ;; - tailwind.config.*|postcss.config.*) FRONTEND=true ;; - app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) FRONTEND=true ;; - - # Prompts: prompt builders, system prompts, generation services - *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) PROMPTS=true ;; - *evaluator*|*scorer*|*classifier_service*|*analyzer*) PROMPTS=true ;; - *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) PROMPTS=true ;; - app/services/chat_tools/*|app/services/x_thread_tools/*) PROMPTS=true ;; - config/system_prompts/*) PROMPTS=true ;; - - # Tests - *.test.*|*.spec.*|*_test.*|*_spec.*) TESTS=true ;; - test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) TESTS=true ;; - - # Docs - *.md) DOCS=true ;; - - # Config - package.json|package-lock.json|yarn.lock|bun.lockb) CONFIG=true ;; - Gemfile|Gemfile.lock) CONFIG=true ;; - *.yml|*.yaml) CONFIG=true ;; - .github/*) CONFIG=true ;; - requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;; - - # Backend: everything else that's code (excluding views/components already matched) - *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;; - *.ts|*.js) BACKEND=true ;; # Non-component TS/JS is backend - esac -done <<< "$FILES" - -echo "SCOPE_FRONTEND=$FRONTEND" -echo "SCOPE_BACKEND=$BACKEND" -echo "SCOPE_PROMPTS=$PROMPTS" -echo "SCOPE_TESTS=$TESTS" -echo "SCOPE_DOCS=$DOCS" -echo "SCOPE_CONFIG=$CONFIG" diff --git a/bin/gstack-global-discover b/bin/gstack-global-discover deleted file mode 100755 index ebffeeb9e5..0000000000 Binary files a/bin/gstack-global-discover and /dev/null differ diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts deleted file mode 100644 index e6c64f561d..0000000000 --- a/bin/gstack-global-discover.ts +++ /dev/null @@ -1,591 +0,0 @@ -#!/usr/bin/env bun -/** - * gstack-global-discover — Discover AI coding sessions across Claude Code, Codex CLI, and Gemini CLI. - * Resolves each session's working directory to a git repo, deduplicates by normalized remote URL, - * and outputs structured JSON to stdout. - * - * Usage: - * gstack-global-discover --since 7d [--format json|summary] - * gstack-global-discover --help - */ - -import { existsSync, readdirSync, statSync, readFileSync, openSync, readSync, closeSync } from "fs"; -import { join, basename } from "path"; -import { execSync } from "child_process"; -import { homedir } from "os"; - -// ── Types ────────────────────────────────────────────────────────────────── - -interface Session { - tool: "claude_code" | "codex" | "gemini"; - cwd: string; -} - -interface Repo { - name: string; - remote: string; - paths: string[]; - sessions: { claude_code: number; codex: number; gemini: number }; -} - -interface DiscoveryResult { - window: string; - start_date: string; - repos: Repo[]; - tools: { - claude_code: { total_sessions: number; repos: number }; - codex: { total_sessions: number; repos: number }; - gemini: { total_sessions: number; repos: number }; - }; - total_sessions: number; - total_repos: number; -} - -// ── CLI parsing ──────────────────────────────────────────────────────────── - -function printUsage(): void { - console.error(`Usage: gstack-global-discover --since [--format json|summary] - - --since Time window: e.g. 7d, 14d, 30d, 24h - --format Output format: json (default) or summary - --help Show this help - -Examples: - gstack-global-discover --since 7d - gstack-global-discover --since 14d --format summary`); -} - -function parseArgs(): { since: string; format: "json" | "summary" } { - const args = process.argv.slice(2); - let since = ""; - let format: "json" | "summary" = "json"; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--help" || args[i] === "-h") { - printUsage(); - process.exit(0); - } else if (args[i] === "--since" && args[i + 1]) { - since = args[++i]; - } else if (args[i] === "--format" && args[i + 1]) { - const f = args[++i]; - if (f !== "json" && f !== "summary") { - console.error(`Invalid format: ${f}. Use 'json' or 'summary'.`); - printUsage(); - process.exit(1); - } - format = f; - } else { - console.error(`Unknown argument: ${args[i]}`); - printUsage(); - process.exit(1); - } - } - - if (!since) { - console.error("Error: --since is required."); - printUsage(); - process.exit(1); - } - - if (!/^\d+(d|h|w)$/.test(since)) { - console.error(`Invalid window format: ${since}. Use e.g. 7d, 24h, 2w.`); - process.exit(1); - } - - return { since, format }; -} - -function windowToDate(window: string): Date { - const match = window.match(/^(\d+)(d|h|w)$/); - if (!match) throw new Error(`Invalid window: ${window}`); - const [, numStr, unit] = match; - const num = parseInt(numStr, 10); - const now = new Date(); - - if (unit === "h") { - return new Date(now.getTime() - num * 60 * 60 * 1000); - } else if (unit === "w") { - // weeks — midnight-aligned like days - const d = new Date(now); - d.setDate(d.getDate() - num * 7); - d.setHours(0, 0, 0, 0); - return d; - } else { - // days — midnight-aligned - const d = new Date(now); - d.setDate(d.getDate() - num); - d.setHours(0, 0, 0, 0); - return d; - } -} - -// ── URL normalization ────────────────────────────────────────────────────── - -export function normalizeRemoteUrl(url: string): string { - let normalized = url.trim(); - - // SSH → HTTPS: git@github.com:user/repo → https://github.com/user/repo - const sshMatch = normalized.match(/^(?:ssh:\/\/)?git@([^:]+):(.+)$/); - if (sshMatch) { - normalized = `https://${sshMatch[1]}/${sshMatch[2]}`; - } - - // Strip .git suffix - if (normalized.endsWith(".git")) { - normalized = normalized.slice(0, -4); - } - - // Lowercase the host portion - try { - const parsed = new URL(normalized); - parsed.hostname = parsed.hostname.toLowerCase(); - normalized = parsed.toString(); - // Remove trailing slash - if (normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - } catch { - // Not a valid URL (e.g., local:), return as-is - } - - return normalized; -} - -// ── Git helpers ──────────────────────────────────────────────────────────── - -function isGitRepo(dir: string): boolean { - return existsSync(join(dir, ".git")); -} - -function getGitRemote(cwd: string): string | null { - if (!existsSync(cwd) || !isGitRepo(cwd)) return null; - try { - const remote = execSync("git remote get-url origin", { - cwd, - encoding: "utf-8", - timeout: 5000, - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return remote || null; - } catch { - return null; - } -} - -// ── Scanners ─────────────────────────────────────────────────────────────── - -function scanClaudeCode(since: Date): Session[] { - const projectsDir = join(homedir(), ".claude", "projects"); - if (!existsSync(projectsDir)) return []; - - const sessions: Session[] = []; - - let dirs: string[]; - try { - dirs = readdirSync(projectsDir); - } catch { - return []; - } - - for (const dirName of dirs) { - const dirPath = join(projectsDir, dirName); - try { - const stat = statSync(dirPath); - if (!stat.isDirectory()) continue; - } catch { - continue; - } - - // Find JSONL files - let jsonlFiles: string[]; - try { - jsonlFiles = readdirSync(dirPath).filter((f) => f.endsWith(".jsonl")); - } catch { - continue; - } - if (jsonlFiles.length === 0) continue; - - // Coarse mtime pre-filter: check if any JSONL file is recent - const hasRecentFile = jsonlFiles.some((f) => { - try { - return statSync(join(dirPath, f)).mtime >= since; - } catch { - return false; - } - }); - if (!hasRecentFile) continue; - - // Resolve cwd - let cwd = resolveClaudeCodeCwd(dirPath, dirName, jsonlFiles); - if (!cwd) continue; - - // Count only JSONL files modified within the window as sessions - const recentFiles = jsonlFiles.filter((f) => { - try { - return statSync(join(dirPath, f)).mtime >= since; - } catch { - return false; - } - }); - for (let i = 0; i < recentFiles.length; i++) { - sessions.push({ tool: "claude_code", cwd }); - } - } - - return sessions; -} - -function resolveClaudeCodeCwd( - dirPath: string, - dirName: string, - jsonlFiles: string[] -): string | null { - // Fast-path: decode directory name - // e.g., -Users-garrytan-git-repo → /Users/garrytan/git/repo - const decoded = dirName.replace(/^-/, "/").replace(/-/g, "/"); - if (existsSync(decoded)) return decoded; - - // Fallback: read cwd from first JSONL file - // Sort by mtime descending, pick most recent - const sorted = jsonlFiles - .map((f) => { - try { - return { name: f, mtime: statSync(join(dirPath, f)).mtime.getTime() }; - } catch { - return null; - } - }) - .filter(Boolean) - .sort((a, b) => b!.mtime - a!.mtime) as { name: string; mtime: number }[]; - - for (const file of sorted.slice(0, 3)) { - const cwd = extractCwdFromJsonl(join(dirPath, file.name)); - if (cwd && existsSync(cwd)) return cwd; - } - - return null; -} - -function extractCwdFromJsonl(filePath: string): string | null { - try { - // Read only the first 8KB to avoid loading huge JSONL files into memory - const fd = openSync(filePath, "r"); - const buf = Buffer.alloc(8192); - const bytesRead = readSync(fd, buf, 0, 8192, 0); - closeSync(fd); - const text = buf.toString("utf-8", 0, bytesRead); - const lines = text.split("\n").slice(0, 15); - for (const line of lines) { - if (!line.trim()) continue; - try { - const obj = JSON.parse(line); - if (obj.cwd) return obj.cwd; - } catch { - continue; - } - } - } catch { - // File read error - } - return null; -} - -function scanCodex(since: Date): Session[] { - const sessionsDir = join(homedir(), ".codex", "sessions"); - if (!existsSync(sessionsDir)) return []; - - const sessions: Session[] = []; - - // Walk YYYY/MM/DD directory structure - try { - const years = readdirSync(sessionsDir); - for (const year of years) { - const yearPath = join(sessionsDir, year); - if (!statSync(yearPath).isDirectory()) continue; - - const months = readdirSync(yearPath); - for (const month of months) { - const monthPath = join(yearPath, month); - if (!statSync(monthPath).isDirectory()) continue; - - const days = readdirSync(monthPath); - for (const day of days) { - const dayPath = join(monthPath, day); - if (!statSync(dayPath).isDirectory()) continue; - - const files = readdirSync(dayPath).filter((f) => - f.startsWith("rollout-") && f.endsWith(".jsonl") - ); - - for (const file of files) { - const filePath = join(dayPath, file); - try { - const stat = statSync(filePath); - if (stat.mtime < since) continue; - } catch { - continue; - } - - // Read first line for session_meta (only first 4KB) - try { - const fd = openSync(filePath, "r"); - const buf = Buffer.alloc(4096); - const bytesRead = readSync(fd, buf, 0, 4096, 0); - closeSync(fd); - const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0]; - if (!firstLine) continue; - const meta = JSON.parse(firstLine); - if (meta.type === "session_meta" && meta.payload?.cwd) { - sessions.push({ tool: "codex", cwd: meta.payload.cwd }); - } - } catch { - console.error(`Warning: could not parse Codex session ${filePath}`); - } - } - } - } - } - } catch { - // Directory read error - } - - return sessions; -} - -function scanGemini(since: Date): Session[] { - const tmpDir = join(homedir(), ".gemini", "tmp"); - if (!existsSync(tmpDir)) return []; - - // Load projects.json for path mapping - const projectsPath = join(homedir(), ".gemini", "projects.json"); - let projectsMap: Record = {}; // name → path - if (existsSync(projectsPath)) { - try { - const data = JSON.parse(readFileSync(projectsPath, { encoding: "utf-8" })); - // Format: { projects: { "/path": "name" } } — we want name → path - const projects = data.projects || {}; - for (const [path, name] of Object.entries(projects)) { - projectsMap[name as string] = path; - } - } catch { - console.error("Warning: could not parse ~/.gemini/projects.json"); - } - } - - const sessions: Session[] = []; - const seenTimestamps = new Map>(); // projectName → Set - - let projectDirs: string[]; - try { - projectDirs = readdirSync(tmpDir); - } catch { - return []; - } - - for (const projectName of projectDirs) { - const chatsDir = join(tmpDir, projectName, "chats"); - if (!existsSync(chatsDir)) continue; - - // Resolve cwd from projects.json - let cwd = projectsMap[projectName] || null; - - // Fallback: check .project_root - if (!cwd) { - const projectRootFile = join(tmpDir, projectName, ".project_root"); - if (existsSync(projectRootFile)) { - try { - cwd = readFileSync(projectRootFile, { encoding: "utf-8" }).trim(); - } catch {} - } - } - - if (!cwd || !existsSync(cwd)) continue; - - const seen = seenTimestamps.get(projectName) || new Set(); - seenTimestamps.set(projectName, seen); - - let files: string[]; - try { - files = readdirSync(chatsDir).filter((f) => - f.startsWith("session-") && f.endsWith(".json") - ); - } catch { - continue; - } - - for (const file of files) { - const filePath = join(chatsDir, file); - try { - const stat = statSync(filePath); - if (stat.mtime < since) continue; - } catch { - continue; - } - - try { - const data = JSON.parse(readFileSync(filePath, { encoding: "utf-8" })); - const startTime = data.startTime || ""; - - // Deduplicate by startTime within project - if (startTime && seen.has(startTime)) continue; - if (startTime) seen.add(startTime); - - sessions.push({ tool: "gemini", cwd }); - } catch { - console.error(`Warning: could not parse Gemini session ${filePath}`); - } - } - } - - return sessions; -} - -// ── Deduplication ────────────────────────────────────────────────────────── - -async function resolveAndDeduplicate(sessions: Session[]): Promise { - // Group sessions by cwd - const byCwd = new Map(); - for (const s of sessions) { - const existing = byCwd.get(s.cwd) || []; - existing.push(s); - byCwd.set(s.cwd, existing); - } - - // Resolve git remotes for each cwd - const cwds = Array.from(byCwd.keys()); - const remoteMap = new Map(); // cwd → normalized remote - - for (const cwd of cwds) { - const raw = getGitRemote(cwd); - if (raw) { - remoteMap.set(cwd, normalizeRemoteUrl(raw)); - } else if (existsSync(cwd) && isGitRepo(cwd)) { - remoteMap.set(cwd, `local:${cwd}`); - } - } - - // Group by normalized remote - const byRemote = new Map(); - for (const [cwd, cwdSessions] of byCwd) { - const remote = remoteMap.get(cwd); - if (!remote) continue; - - const existing = byRemote.get(remote) || { paths: [], sessions: [] }; - if (!existing.paths.includes(cwd)) existing.paths.push(cwd); - existing.sessions.push(...cwdSessions); - byRemote.set(remote, existing); - } - - // Build Repo objects - const repos: Repo[] = []; - for (const [remote, data] of byRemote) { - // Find first valid path - const validPath = data.paths.find((p) => existsSync(p) && isGitRepo(p)); - if (!validPath) continue; - - // Derive name from remote URL - let name: string; - if (remote.startsWith("local:")) { - name = basename(remote.replace("local:", "")); - } else { - try { - const url = new URL(remote); - name = basename(url.pathname); - } catch { - name = basename(remote); - } - } - - const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 }; - for (const s of data.sessions) { - sessionCounts[s.tool]++; - } - - repos.push({ - name, - remote, - paths: data.paths, - sessions: sessionCounts, - }); - } - - // Sort by total sessions descending - repos.sort( - (a, b) => - b.sessions.claude_code + b.sessions.codex + b.sessions.gemini - - (a.sessions.claude_code + a.sessions.codex + a.sessions.gemini) - ); - - return repos; -} - -// ── Main ─────────────────────────────────────────────────────────────────── - -async function main() { - const { since, format } = parseArgs(); - const sinceDate = windowToDate(since); - const startDate = sinceDate.toISOString().split("T")[0]; - - // Run all scanners - const ccSessions = scanClaudeCode(sinceDate); - const codexSessions = scanCodex(sinceDate); - const geminiSessions = scanGemini(sinceDate); - - const allSessions = [...ccSessions, ...codexSessions, ...geminiSessions]; - - // Summary to stderr - console.error( - `Discovered: ${ccSessions.length} CC sessions, ${codexSessions.length} Codex sessions, ${geminiSessions.length} Gemini sessions` - ); - - // Deduplicate - const repos = await resolveAndDeduplicate(allSessions); - - console.error(`→ ${repos.length} unique repos`); - - // Count per-tool repo counts - const ccRepos = new Set(repos.filter((r) => r.sessions.claude_code > 0).map((r) => r.remote)).size; - const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size; - const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size; - - const result: DiscoveryResult = { - window: since, - start_date: startDate, - repos, - tools: { - claude_code: { total_sessions: ccSessions.length, repos: ccRepos }, - codex: { total_sessions: codexSessions.length, repos: codexRepos }, - gemini: { total_sessions: geminiSessions.length, repos: geminiRepos }, - }, - total_sessions: allSessions.length, - total_repos: repos.length, - }; - - if (format === "json") { - console.log(JSON.stringify(result, null, 2)); - } else { - // Summary format - console.log(`Window: ${since} (since ${startDate})`); - console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`); - console.log(`Repos: ${repos.length} unique`); - console.log(""); - for (const repo of repos) { - const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini; - const tools = []; - if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`); - if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`); - if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`); - console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`); - console.log(` Remote: ${repo.remote}`); - console.log(` Paths: ${repo.paths.join(", ")}`); - } - } -} - -// Only run main when executed directly (not when imported for testing) -if (import.meta.main) { - main().catch((err) => { - console.error(`Fatal error: ${err.message}`); - process.exit(1); - }); -} diff --git a/bin/gstack-repo-mode b/bin/gstack-repo-mode deleted file mode 100755 index 0b4d6da64f..0000000000 --- a/bin/gstack-repo-mode +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -# gstack-repo-mode — detect solo vs collaborative repo mode -# Usage: source <(gstack-repo-mode) → sets REPO_MODE variable -# Or: gstack-repo-mode → prints REPO_MODE=... line -# -# Detection heuristic (90-day window): -# Solo: top author >= 80% of commits -# Collaborative: top author < 80% -# -# Override: gstack-config set repo_mode solo|collaborative -# Cache: ~/.gstack/projects/$SLUG/repo-mode.json (7-day TTL) -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# Compute SLUG directly (avoid eval of gstack-slug — branch names can contain shell metacharacters) -REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) -if [ -z "$REMOTE_URL" ]; then - echo "REPO_MODE=unknown" - exit 0 -fi -SLUG=$(echo "$REMOTE_URL" | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') -[ -z "${SLUG:-}" ] && { echo "REPO_MODE=unknown"; exit 0; } - -# Validate: only allow known values (prevent shell injection via source <(...)) -validate_mode() { - case "$1" in solo|collaborative|unknown) echo "$1" ;; *) echo "unknown" ;; esac -} - -# Config override takes precedence -OVERRIDE=$("$SCRIPT_DIR/gstack-config" get repo_mode 2>/dev/null || true) -if [ -n "$OVERRIDE" ] && [ "$OVERRIDE" != "null" ]; then - echo "REPO_MODE=$(validate_mode "$OVERRIDE")" - exit 0 -fi - -# Check cache (7-day TTL) -CACHE_DIR="$HOME/.gstack/projects/$SLUG" -CACHE_FILE="$CACHE_DIR/repo-mode.json" -if [ -f "$CACHE_FILE" ]; then - CACHE_AGE=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE" 2>/dev/null || echo 0) )) - if [ "$CACHE_AGE" -lt 604800 ]; then # 7 days in seconds - MODE=$(grep -o '"mode":"[^"]*"' "$CACHE_FILE" | head -1 | cut -d'"' -f4) - [ -n "$MODE" ] && echo "REPO_MODE=$(validate_mode "$MODE")" && exit 0 - fi -fi - -# Compute from git history (90-day window) -# Use default branch (not HEAD) to avoid feature-branch sampling bias -DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||' || true) -# Fallback: try origin/main, then origin/master, then HEAD -if [ -z "$DEFAULT_BRANCH" ]; then - if git rev-parse --verify origin/main &>/dev/null; then - DEFAULT_BRANCH="origin/main" - elif git rev-parse --verify origin/master &>/dev/null; then - DEFAULT_BRANCH="origin/master" - else - DEFAULT_BRANCH="HEAD" - fi -fi -SHORTLOG=$(git shortlog -sn --since="90 days ago" --no-merges "$DEFAULT_BRANCH" 2>/dev/null) -if [ -z "$SHORTLOG" ]; then - echo "REPO_MODE=unknown" - exit 0 -fi - -# Compute TOTAL from ALL authors (not truncated) to avoid solo bias -TOTAL=$(echo "$SHORTLOG" | awk '{s+=$1} END {print s}') -TOP=$(echo "$SHORTLOG" | head -1 | awk '{print $1}') -AUTHORS=$(echo "$SHORTLOG" | wc -l | tr -d ' ') - -# Minimum sample: need at least 5 commits to classify -if [ "$TOTAL" -lt 5 ]; then - echo "REPO_MODE=unknown" - exit 0 -fi - -TOP_PCT=$(( TOP * 100 / TOTAL )) - -# Solo: top author >= 80% of commits (occasional outside PRs don't change mode) -if [ "$TOP_PCT" -ge 80 ]; then - MODE=solo -else - MODE=collaborative -fi - -# Cache result atomically (fail silently if ~/.gstack is unwritable) -mkdir -p "$CACHE_DIR" 2>/dev/null || true -CACHE_TMP=$(mktemp "$CACHE_DIR/.repo-mode-XXXXXX" 2>/dev/null || true) -if [ -n "$CACHE_TMP" ]; then - echo "{\"mode\":\"$MODE\",\"top_pct\":$TOP_PCT,\"authors\":$AUTHORS,\"total\":$TOTAL,\"computed\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$CACHE_TMP" 2>/dev/null && mv "$CACHE_TMP" "$CACHE_FILE" 2>/dev/null || rm -f "$CACHE_TMP" 2>/dev/null -fi - -echo "REPO_MODE=$MODE" diff --git a/bin/gstack-review-log b/bin/gstack-review-log deleted file mode 100755 index d7235bc3ac..0000000000 --- a/bin/gstack-review-log +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -# gstack-review-log — atomically log a review result -# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -mkdir -p "$GSTACK_HOME/projects/$SLUG" -echo "$1" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" diff --git a/bin/gstack-review-read b/bin/gstack-review-read deleted file mode 100755 index ccf1d70f64..0000000000 --- a/bin/gstack-review-read +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# gstack-review-read — read review log and config for dashboard -# Usage: gstack-review-read -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -cat "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" 2>/dev/null || echo "NO_REVIEWS" -echo "---CONFIG---" -"$SCRIPT_DIR/gstack-config" get skip_eng_review 2>/dev/null || echo "false" -echo "---HEAD---" -git rev-parse --short HEAD 2>/dev/null || echo "unknown" diff --git a/bin/gstack-slug b/bin/gstack-slug deleted file mode 100755 index a7ae788391..0000000000 --- a/bin/gstack-slug +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -# gstack-slug — output project slug and sanitized branch name -# Usage: eval "$(gstack-slug)" → sets SLUG and BRANCH variables -# Or: gstack-slug → prints SLUG=... and BRANCH=... lines -# -# Security: output is sanitized to [a-zA-Z0-9._-] only, preventing -# shell injection when consumed via source or eval. -set -euo pipefail -RAW_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-') -RAW_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') -# Strip any characters that aren't alphanumeric, dot, hyphen, or underscore -SLUG=$(printf '%s' "$RAW_SLUG" | tr -cd 'a-zA-Z0-9._-') -BRANCH=$(printf '%s' "$RAW_BRANCH" | tr -cd 'a-zA-Z0-9._-') -echo "SLUG=$SLUG" -echo "BRANCH=$BRANCH" diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log deleted file mode 100755 index edcbdbabfb..0000000000 --- a/bin/gstack-telemetry-log +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# gstack-telemetry-log — append a telemetry event to local JSONL -# -# Data flow: -# preamble (start) ──▶ .pending marker -# preamble (epilogue) ──▶ gstack-telemetry-log ──▶ skill-usage.jsonl -# └──▶ gstack-telemetry-sync (bg) -# -# Usage: -# gstack-telemetry-log --skill qa --duration 142 --outcome success \ -# --used-browse true --session-id "12345-1710756600" -# -# Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory -# GSTACK_DIR — override auto-detected gstack root -# -# NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero -set -uo pipefail - -GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" -ANALYTICS_DIR="$STATE_DIR/analytics" -JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" -PENDING_DIR="$ANALYTICS_DIR" # .pending-* files live here -CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" -VERSION_FILE="$GSTACK_DIR/VERSION" - -# ─── Parse flags ───────────────────────────────────────────── -SKILL="" -DURATION="" -OUTCOME="unknown" -USED_BROWSE="false" -SESSION_ID="" -ERROR_CLASS="" -EVENT_TYPE="skill_run" - -while [ $# -gt 0 ]; do - case "$1" in - --skill) SKILL="$2"; shift 2 ;; - --duration) DURATION="$2"; shift 2 ;; - --outcome) OUTCOME="$2"; shift 2 ;; - --used-browse) USED_BROWSE="$2"; shift 2 ;; - --session-id) SESSION_ID="$2"; shift 2 ;; - --error-class) ERROR_CLASS="$2"; shift 2 ;; - --event-type) EVENT_TYPE="$2"; shift 2 ;; - *) shift ;; - esac -done - -# ─── Read telemetry tier ───────────────────────────────────── -TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" -TIER="${TIER:-off}" - -# Validate tier -case "$TIER" in - off|anonymous|community) ;; - *) TIER="off" ;; # invalid value → default to off -esac - -if [ "$TIER" = "off" ]; then - # Still clear pending markers for this session even if telemetry is off - [ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true - exit 0 -fi - -# ─── Finalize stale .pending markers ──────────────────────── -# Each session gets its own .pending-$SESSION_ID file to avoid races -# between concurrent sessions. Finalize any that don't match our session. -for PFILE in "$PENDING_DIR"/.pending-*; do - [ -f "$PFILE" ] || continue - # Skip our own session's marker (it's still in-flight) - PFILE_BASE="$(basename "$PFILE")" - PFILE_SID="${PFILE_BASE#.pending-}" - [ "$PFILE_SID" = "$SESSION_ID" ] && continue - - PENDING_DATA="$(cat "$PFILE" 2>/dev/null || true)" - rm -f "$PFILE" 2>/dev/null || true - if [ -n "$PENDING_DATA" ]; then - # Extract fields from pending marker using grep -o + awk - P_SKILL="$(echo "$PENDING_DATA" | grep -o '"skill":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" - P_TS="$(echo "$PENDING_DATA" | grep -o '"ts":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" - P_SID="$(echo "$PENDING_DATA" | grep -o '"session_id":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" - P_VER="$(echo "$PENDING_DATA" | grep -o '"gstack_version":"[^"]*"' | head -1 | awk -F'"' '{print $4}')" - P_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" - P_ARCH="$(uname -m)" - - # Write the stale event as outcome: unknown - mkdir -p "$ANALYTICS_DIR" - printf '{"v":1,"ts":"%s","event_type":"skill_run","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":null,"outcome":"unknown","error_class":null,"used_browse":false,"sessions":1}\n' \ - "$P_TS" "$P_SKILL" "$P_SID" "$P_VER" "$P_OS" "$P_ARCH" >> "$JSONL_FILE" 2>/dev/null || true - fi -done - -# Clear our own session's pending marker (we're about to log the real event) -[ -n "$SESSION_ID" ] && rm -f "$PENDING_DIR/.pending-$SESSION_ID" 2>/dev/null || true - -# ─── Collect metadata ──────────────────────────────────────── -TS="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%S 2>/dev/null || echo "")" -GSTACK_VERSION="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo "unknown")" -OS="$(uname -s | tr '[:upper:]' '[:lower:]')" -ARCH="$(uname -m)" -SESSIONS="1" -if [ -d "$STATE_DIR/sessions" ]; then - _SC="$(find "$STATE_DIR/sessions" -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' \n\r\t')" - [ -n "$_SC" ] && [ "$_SC" -gt 0 ] 2>/dev/null && SESSIONS="$_SC" -fi - -# Generate installation_id for community tier -INSTALL_ID="" -if [ "$TIER" = "community" ]; then - HOST="$(hostname 2>/dev/null || echo "unknown")" - USER="$(whoami 2>/dev/null || echo "unknown")" - if command -v shasum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | shasum -a 256 | awk '{print $1}')" - elif command -v sha256sum >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | sha256sum | awk '{print $1}')" - elif command -v openssl >/dev/null 2>&1; then - INSTALL_ID="$(printf '%s-%s' "$HOST" "$USER" | openssl dgst -sha256 | awk '{print $NF}')" - fi - # If no SHA-256 command available, install_id stays empty -fi - -# Local-only fields (never sent remotely) -REPO_SLUG="" -BRANCH="" -if command -v git >/dev/null 2>&1; then - REPO_SLUG="$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' 2>/dev/null || true)" - BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" -fi - -# ─── Construct and append JSON ─────────────────────────────── -mkdir -p "$ANALYTICS_DIR" - -# Escape null fields -ERR_FIELD="null" -[ -n "$ERROR_CLASS" ] && ERR_FIELD="\"$ERROR_CLASS\"" - -DUR_FIELD="null" -[ -n "$DURATION" ] && DUR_FIELD="$DURATION" - -INSTALL_FIELD="null" -[ -n "$INSTALL_ID" ] && INSTALL_FIELD="\"$INSTALL_ID\"" - -BROWSE_BOOL="false" -[ "$USED_BROWSE" = "true" ] && BROWSE_BOOL="true" - -printf '{"v":1,"ts":"%s","event_type":"%s","skill":"%s","session_id":"%s","gstack_version":"%s","os":"%s","arch":"%s","duration_s":%s,"outcome":"%s","error_class":%s,"used_browse":%s,"sessions":%s,"installation_id":%s,"_repo_slug":"%s","_branch":"%s"}\n' \ - "$TS" "$EVENT_TYPE" "$SKILL" "$SESSION_ID" "$GSTACK_VERSION" "$OS" "$ARCH" \ - "$DUR_FIELD" "$OUTCOME" "$ERR_FIELD" "$BROWSE_BOOL" "${SESSIONS:-1}" \ - "$INSTALL_FIELD" "$REPO_SLUG" "$BRANCH" >> "$JSONL_FILE" 2>/dev/null || true - -# ─── Trigger sync if tier is not off ───────────────────────── -SYNC_CMD="$GSTACK_DIR/bin/gstack-telemetry-sync" -if [ -x "$SYNC_CMD" ]; then - "$SYNC_CMD" 2>/dev/null & -fi - -exit 0 diff --git a/bin/gstack-telemetry-sync b/bin/gstack-telemetry-sync deleted file mode 100755 index 90e372439c..0000000000 --- a/bin/gstack-telemetry-sync +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# gstack-telemetry-sync — sync local JSONL events to Supabase -# -# Fire-and-forget, backgrounded, rate-limited to once per 5 minutes. -# Strips local-only fields before sending. Respects privacy tiers. -# -# Env overrides (for testing): -# GSTACK_STATE_DIR — override ~/.gstack state directory -# GSTACK_DIR — override auto-detected gstack root -# GSTACK_TELEMETRY_ENDPOINT — override Supabase endpoint URL -set -uo pipefail - -GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" -ANALYTICS_DIR="$STATE_DIR/analytics" -JSONL_FILE="$ANALYTICS_DIR/skill-usage.jsonl" -CURSOR_FILE="$ANALYTICS_DIR/.last-sync-line" -RATE_FILE="$ANALYTICS_DIR/.last-sync-time" -CONFIG_CMD="$GSTACK_DIR/bin/gstack-config" - -# Source Supabase config if not overridden by env -if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then - . "$GSTACK_DIR/supabase/config.sh" -fi -ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" -ANON_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" - -# ─── Pre-checks ────────────────────────────────────────────── -# No endpoint configured yet → exit silently -[ -z "$ENDPOINT" ] && exit 0 - -# No JSONL file → nothing to sync -[ -f "$JSONL_FILE" ] || exit 0 - -# Rate limit: once per 5 minutes -if [ -f "$RATE_FILE" ]; then - STALE=$(find "$RATE_FILE" -mmin +5 2>/dev/null || true) - [ -z "$STALE" ] && exit 0 -fi - -# ─── Read tier ─────────────────────────────────────────────── -TIER="$("$CONFIG_CMD" get telemetry 2>/dev/null || true)" -TIER="${TIER:-off}" -[ "$TIER" = "off" ] && exit 0 - -# ─── Read cursor ───────────────────────────────────────────── -CURSOR=0 -if [ -f "$CURSOR_FILE" ]; then - CURSOR="$(cat "$CURSOR_FILE" 2>/dev/null | tr -d ' \n\r\t')" - # Validate: must be a non-negative integer - case "$CURSOR" in *[!0-9]*) CURSOR=0 ;; esac -fi - -# Safety: if cursor exceeds file length, reset -TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' \n\r\t')" -if [ "$CURSOR" -gt "$TOTAL_LINES" ] 2>/dev/null; then - CURSOR=0 -fi - -# Nothing new to sync -[ "$CURSOR" -ge "$TOTAL_LINES" ] 2>/dev/null && exit 0 - -# ─── Read unsent lines ─────────────────────────────────────── -SKIP=$(( CURSOR + 1 )) -UNSENT="$(tail -n "+$SKIP" "$JSONL_FILE" 2>/dev/null || true)" -[ -z "$UNSENT" ] && exit 0 - -# ─── Strip local-only fields and build batch ───────────────── -BATCH="[" -FIRST=true -COUNT=0 - -while IFS= read -r LINE; do - # Skip empty or malformed lines - [ -z "$LINE" ] && continue - echo "$LINE" | grep -q '^{' || continue - - # Strip local-only fields + map JSONL field names to Postgres column names - CLEAN="$(echo "$LINE" | sed \ - -e 's/,"_repo_slug":"[^"]*"//g' \ - -e 's/,"_branch":"[^"]*"//g' \ - -e 's/"v":/"schema_version":/g' \ - -e 's/"ts":/"event_timestamp":/g' \ - -e 's/"sessions":/"concurrent_sessions":/g' \ - -e 's/,"repo":"[^"]*"//g')" - - # If anonymous tier, strip installation_id - if [ "$TIER" = "anonymous" ]; then - CLEAN="$(echo "$CLEAN" | sed 's/,"installation_id":"[^"]*"//g; s/,"installation_id":null//g')" - fi - - if [ "$FIRST" = "true" ]; then - FIRST=false - else - BATCH="$BATCH," - fi - BATCH="$BATCH$CLEAN" - COUNT=$(( COUNT + 1 )) - - # Batch size limit - [ "$COUNT" -ge 100 ] && break -done <<< "$UNSENT" - -BATCH="$BATCH]" - -# Nothing to send after filtering -[ "$COUNT" -eq 0 ] && exit 0 - -# ─── POST to Supabase ──────────────────────────────────────── -HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ - -X POST "${ENDPOINT}/telemetry_events" \ - -H "Content-Type: application/json" \ - -H "apikey: ${ANON_KEY}" \ - -H "Authorization: Bearer ${ANON_KEY}" \ - -H "Prefer: return=minimal" \ - -d "$BATCH" 2>/dev/null || echo "000")" - -# ─── Update cursor on success (2xx) ───────────────────────── -case "$HTTP_CODE" in - 2*) NEW_CURSOR=$(( CURSOR + COUNT )) - echo "$NEW_CURSOR" > "$CURSOR_FILE" 2>/dev/null || true ;; -esac - -# Update rate limit marker -touch "$RATE_FILE" 2>/dev/null || true - -exit 0 diff --git a/bin/gstack-update-check b/bin/gstack-update-check deleted file mode 100755 index 823861d20e..0000000000 --- a/bin/gstack-update-check +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env bash -# gstack-update-check — periodic version check for all skills. -# -# Output (one line, or nothing): -# JUST_UPGRADED — marker found from recent upgrade -# UPGRADE_AVAILABLE — remote VERSION differs from local -# (nothing) — up to date, snoozed, disabled, or check skipped -# -# Env overrides (for testing): -# GSTACK_DIR — override auto-detected gstack root -# GSTACK_REMOTE_URL — override remote VERSION URL -# GSTACK_STATE_DIR — override ~/.gstack state directory -set -euo pipefail - -GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" -CACHE_FILE="$STATE_DIR/last-update-check" -MARKER_FILE="$STATE_DIR/just-upgraded-from" -SNOOZE_FILE="$STATE_DIR/update-snoozed" -VERSION_FILE="$GSTACK_DIR/VERSION" -REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}" - -# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ── -if [ "${1:-}" = "--force" ]; then - rm -f "$CACHE_FILE" - rm -f "$SNOOZE_FILE" -fi - -# ─── Step 0: Check if updates are disabled ──────────────────── -_UC=$("$GSTACK_DIR/bin/gstack-config" get update_check 2>/dev/null || true) -if [ "$_UC" = "false" ]; then - exit 0 -fi - -# ─── Migration: fix stale Codex descriptions (one-time) ─────── -# Existing installs may have .agents/skills/gstack/SKILL.md with oversized -# descriptions (>1024 chars) that Codex rejects. We can't regenerate from -# the runtime root (no bun/scripts), so delete oversized files — the next -# ./setup or /gstack-upgrade will regenerate them properly. -# Marker file ensures this runs at most once per install. -if [ ! -f "$STATE_DIR/.codex-desc-healed" ]; then - for _AGENTS_SKILL in "$GSTACK_DIR"/.agents/skills/*/SKILL.md; do - [ -f "$_AGENTS_SKILL" ] || continue - _DESC=$(awk '/^---$/{n++;next}n==1&&/^description:/{d=1;sub(/^description:\s*/,"");if(length>0)print;next}d&&/^ /{sub(/^ /,"");print;next}d{d=0}' "$_AGENTS_SKILL" | wc -c | tr -d ' ') - if [ "${_DESC:-0}" -gt 1024 ]; then - rm -f "$_AGENTS_SKILL" - fi - done - mkdir -p "$STATE_DIR" - touch "$STATE_DIR/.codex-desc-healed" -fi - -# ─── Snooze helper ────────────────────────────────────────── -# check_snooze -# Returns 0 if snoozed (should stay quiet), 1 if not snoozed (should output). -# -# Snooze file format: -# Level durations: 1=24h, 2=48h, 3+=7d -# New version (version mismatch) resets snooze. -check_snooze() { - local remote_ver="$1" - if [ ! -f "$SNOOZE_FILE" ]; then - return 1 # no snooze file → not snoozed - fi - local snoozed_ver snoozed_level snoozed_epoch - snoozed_ver="$(awk '{print $1}' "$SNOOZE_FILE" 2>/dev/null || true)" - snoozed_level="$(awk '{print $2}' "$SNOOZE_FILE" 2>/dev/null || true)" - snoozed_epoch="$(awk '{print $3}' "$SNOOZE_FILE" 2>/dev/null || true)" - - # Validate: all three fields must be non-empty - if [ -z "$snoozed_ver" ] || [ -z "$snoozed_level" ] || [ -z "$snoozed_epoch" ]; then - return 1 # corrupt file → not snoozed - fi - - # Validate: level and epoch must be integers - case "$snoozed_level" in *[!0-9]*) return 1 ;; esac - case "$snoozed_epoch" in *[!0-9]*) return 1 ;; esac - - # New version dropped? Ignore snooze. - if [ "$snoozed_ver" != "$remote_ver" ]; then - return 1 - fi - - # Compute snooze duration based on level - local duration - case "$snoozed_level" in - 1) duration=86400 ;; # 24 hours - 2) duration=172800 ;; # 48 hours - *) duration=604800 ;; # 7 days (level 3+) - esac - - local now - now="$(date +%s)" - local expires=$(( snoozed_epoch + duration )) - if [ "$now" -lt "$expires" ]; then - return 0 # still snoozed - fi - - return 1 # snooze expired -} - -# ─── Step 1: Read local version ────────────────────────────── -LOCAL="" -if [ -f "$VERSION_FILE" ]; then - LOCAL="$(cat "$VERSION_FILE" 2>/dev/null | tr -d '[:space:]')" -fi -if [ -z "$LOCAL" ]; then - exit 0 # No VERSION file → skip check -fi - -# ─── Step 2: Check "just upgraded" marker ───────────────────── -if [ -f "$MARKER_FILE" ]; then - OLD="$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]')" - rm -f "$MARKER_FILE" - rm -f "$SNOOZE_FILE" - mkdir -p "$STATE_DIR" - echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" - if [ -n "$OLD" ]; then - echo "JUST_UPGRADED $OLD $LOCAL" - fi - exit 0 -fi - -# ─── Step 3: Check cache freshness ────────────────────────── -# UP_TO_DATE: 60 min TTL (detect new releases quickly) -# UPGRADE_AVAILABLE: 720 min TTL (keep nagging) -if [ -f "$CACHE_FILE" ]; then - CACHED="$(cat "$CACHE_FILE" 2>/dev/null || true)" - case "$CACHED" in - UP_TO_DATE*) CACHE_TTL=60 ;; - UPGRADE_AVAILABLE*) CACHE_TTL=720 ;; - *) CACHE_TTL=0 ;; # corrupt → force re-fetch - esac - - STALE=$(find "$CACHE_FILE" -mmin +$CACHE_TTL 2>/dev/null || true) - if [ -z "$STALE" ] && [ "$CACHE_TTL" -gt 0 ]; then - case "$CACHED" in - UP_TO_DATE*) - CACHED_VER="$(echo "$CACHED" | awk '{print $2}')" - if [ "$CACHED_VER" = "$LOCAL" ]; then - exit 0 - fi - ;; - UPGRADE_AVAILABLE*) - CACHED_OLD="$(echo "$CACHED" | awk '{print $2}')" - if [ "$CACHED_OLD" = "$LOCAL" ]; then - CACHED_NEW="$(echo "$CACHED" | awk '{print $3}')" - if check_snooze "$CACHED_NEW"; then - exit 0 # snoozed — stay quiet - fi - echo "$CACHED" - exit 0 - fi - ;; - esac - fi -fi - -# ─── Step 4: Slow path — fetch remote version ──────────────── -mkdir -p "$STATE_DIR" - -# Fire Supabase install ping in background (parallel, non-blocking) -# This logs an update check event for community health metrics. -# If the endpoint isn't configured or Supabase is down, this is a no-op. -# Source Supabase config for install ping -if [ -z "${GSTACK_TELEMETRY_ENDPOINT:-}" ] && [ -f "$GSTACK_DIR/supabase/config.sh" ]; then - . "$GSTACK_DIR/supabase/config.sh" -fi -_SUPA_ENDPOINT="${GSTACK_TELEMETRY_ENDPOINT:-}" -_SUPA_KEY="${GSTACK_SUPABASE_ANON_KEY:-}" -# Respect telemetry opt-out — don't ping Supabase if user set telemetry: off -_TEL_TIER="$("$GSTACK_DIR/bin/gstack-config" get telemetry 2>/dev/null || true)" -if [ -n "$_SUPA_ENDPOINT" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off" ]; then - _OS="$(uname -s | tr '[:upper:]' '[:lower:]')" - curl -sf --max-time 5 \ - -X POST "${_SUPA_ENDPOINT}/update_checks" \ - -H "Content-Type: application/json" \ - -H "apikey: ${_SUPA_KEY}" \ - -H "Authorization: Bearer ${_SUPA_KEY}" \ - -H "Prefer: return=minimal" \ - -d "{\"gstack_version\":\"$LOCAL\",\"os\":\"$_OS\"}" \ - >/dev/null 2>&1 & -fi - -# GitHub raw fetch (primary, always reliable) -REMOTE="" -REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)" -REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')" - -# Validate: must look like a version number (reject HTML error pages) -if ! echo "$REMOTE" | grep -qE '^[0-9]+\.[0-9.]+$'; then - # Invalid or empty response — assume up to date - echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" - exit 0 -fi - -if [ "$LOCAL" = "$REMOTE" ]; then - echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE" - exit 0 -fi - -# Versions differ — upgrade available -echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE" -if check_snooze "$REMOTE"; then - exit 0 # snoozed — stay quiet -fi - -# Log upgrade_prompted event (only on slow-path fetch, not cached replays) -TEL_CMD="$GSTACK_DIR/bin/gstack-telemetry-log" -if [ -x "$TEL_CMD" ]; then - "$TEL_CMD" --event-type upgrade_prompted --skill "" --duration 0 \ - --outcome success --session-id "update-$$-$(date +%s)" 2>/dev/null & -fi - -echo "UPGRADE_AVAILABLE $LOCAL $REMOTE"