softr-vibe-coding 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,9 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [1.10.0] - 2026-06-04
8
+ - Bundle get-airtable-base CLI script for full base metadata export; document in SKILL.md, airtable.md, fields.md
9
+
7
10
  ## [1.9.0] - 2026-06-04
8
11
  - Bundle get-softr-database CLI script for schema export; document in SKILL.md, softr-database.md, fields.md
9
12
  - Bump publish workflow to Node 24-based action majors — actions/checkout@v4 -> @v6, actions/setup-node@v4 -> @v6 (clears the Node 20 deprecation; GitHub forces Node 20 actions to Node 24 on 2026-06-16). No version bump, so this run skips publish.
package/SKILL.md CHANGED
@@ -87,7 +87,8 @@ When the user describes their block, figure out which of these areas apply and a
87
87
  - **Data source type**: Is it Airtable, Softr Database, REST API, or another source? This determines the data fetching approach. **Load the relevant data source guide** from the [datasources/](datasources/) directory before writing code.
88
88
  - **Data source fields**: For Airtable/Softr Database, you need actual field IDs. For REST APIs, you access the raw API response directly. If the user doesn't know field IDs:
89
89
  - For **Softr Database**, the cleanest path is the **Softr Database MCP server** — ask whether they have it installed (`claude mcp list` shows it as `softr` or similar). If yes, query schema directly with the MCP tools instead of asking for paste-ins. If no, the next-best option is the bundled **`get-softr-database` CLI script** — tell the user to run `python3 ~/.claude/skills/softr-vibe-coding/tools/get-softr-database.py <database_id>` (it prompts for their Softr API key and exports the full schema to `~/Desktop/softr-database-<id>-<timestamp>.json` — Python stdlib only, nothing to install) and paste the resulting JSON into chat. As a final fallback, ask them to paste the `tablespace-with-tables` network response (DevTools -> Network -> filter that string while on Studio's Data tab) — same JSON content, different acquisition path. Optionally tell them they can install the MCP once with `claude mcp add --transport http softr https://mcp.softr.io/mcp` for future sessions. Full MCP details in [references/softr-database-mcp.md](references/softr-database-mcp.md); CLI script details in [datasources/softr-database.md](datasources/softr-database.md#bundled-cli-script-get-softr-database); fallback paste-in workflows in [datasources/fields.md](datasources/fields.md#field-inspector-block).
90
- - For **Airtable** and other sources where empty `q.select({})` works, suggest the Field Inspector block.
90
+ - For **Airtable**, the most thorough path is the bundled **`get-airtable-base` shell script** — `bash ~/.claude/skills/softr-vibe-coding/tools/get-airtable-base` (requires `jq` — `brew install jq` on macOS). It prompts for Base ID + PAT, then exports the full schema (every table, every field with both `fld...` IDs and column names, relationships, webhooks, interfaces) to a timestamped Desktop folder. The user pastes `02-schema.json` or the combined `00-bundle.json` into chat. For lighter inspection (just a few fields, runtime-only), suggest the Field Inspector block — empty `q.select({})` works for Airtable. CLI script details in [datasources/airtable.md](datasources/airtable.md#bundled-cli-script-get-airtable-base).
91
+ - For other non-Softr-DB sources where empty `q.select({})` works, suggest the Field Inspector block.
91
92
  - **Brand colors**: Already resolved in Step 1 (Detect the brand source). Don't re-ask. The brand source is one of:
92
93
  - **Project's `./DESIGN.md`** (recommended for client work — produced by the `building-design-md` skill)
93
94
  - **User's quick override** (paste of primary + accent + font)
@@ -55,6 +55,62 @@ The asymmetry matters because reads silently degrade while writes silently disab
55
55
 
56
56
  3. **Avoid renaming columns mid-project** -- use Airtable's Description field for clarification instead.
57
57
 
58
+ ## Bundled CLI script: `get-airtable-base`
59
+
60
+ A Bash CLI bundled with this skill that exports an Airtable base's full metadata to a timestamped folder on your Desktop. Pulls schema, relationships, sync detection, webhooks, interfaces, and shares — everything the Airtable Web API exposes for a single base.
61
+
62
+ **Script location after `npx softr-vibe-coding@latest init`:**
63
+
64
+ ```
65
+ ~/.claude/skills/softr-vibe-coding/tools/get-airtable-base
66
+ ```
67
+
68
+ **Requirements:** `jq` (`brew install jq` on macOS), `curl` (preinstalled on macOS/Linux), Bash.
69
+
70
+ **Run it:**
71
+
72
+ ```bash
73
+ bash ~/.claude/skills/softr-vibe-coding/tools/get-airtable-base
74
+ ```
75
+
76
+ The script prompts interactively for:
77
+
78
+ - **Base ID** (e.g. `appXXXXXXXXXXXXXX` — find it in the Airtable URL or the API docs page for the base).
79
+ - **Personal Access Token** (input hidden — needs `schema.bases:read` scope minimum, plus `webhook:manage` for webhooks, and optionally `enterpriseAccount:read` for shares).
80
+
81
+ **Output folder:** `~/Desktop/airtable-base-<BASE_ID>-<UTC-timestamp>/` containing:
82
+
83
+ | File | Contents |
84
+ |---|---|
85
+ | `00-bundle.json` | Combined bundle of all the below for easy sharing in one paste |
86
+ | `01-base-info.json` | Collaborators, interfaces, invite links |
87
+ | `02-schema.json` | Full schema: every table, every field (with both `fld...` IDs and column names), every view, `visibleFieldIds` per view |
88
+ | `03-shares.json` | Enterprise-only shares — skipped on non-Enterprise plans |
89
+ | `04-webhooks.json` | Registered webhooks |
90
+ | `05-whoami.json` | Token identity and scopes — confirms which user/PAT was used and what it can access |
91
+ | `06-relationships.json` | Derived from `02-schema.json`: sync destinations, cross-table links, lookup/rollup/count/formula field maps |
92
+
93
+ After completion, the script opens the folder in Finder (macOS).
94
+
95
+ **Optional alias** for a shorter command. Add to your `~/.zshrc` or `~/.bashrc`:
96
+
97
+ ```bash
98
+ alias get-airtable-base='bash ~/.claude/skills/softr-vibe-coding/tools/get-airtable-base'
99
+ ```
100
+
101
+ After `source ~/.zshrc`, run `get-airtable-base` from anywhere.
102
+
103
+ **Getting a PAT:** https://airtable.com/create/tokens → Create token → grant `schema.bases:read` (add `data.records:read` only if you want to also read records via the Web API outside Softr). Scope to the specific base(s) the script should access.
104
+
105
+ **When to use this:**
106
+
107
+ - **Documenting field IDs alongside column names** in `q.select()` — the mitigation in [Maintainability gotcha](#maintainability-gotcha) above. Grep the `02-schema.json` for an `fld...` ID to find every block affected by a column rename.
108
+ - **Bisecting a broken Action** when a column rename has silently disabled it — the freshest `02-schema.json` is the source of truth to grep against.
109
+ - **Auditing relationships** (lookups, rollups, formulas, cross-table links, sync sources) — `06-relationships.json` summarizes everything the schema reveals about derived/referenced fields.
110
+ - **Sharing schema with an AI assistant** — paste `00-bundle.json` (or just `02-schema.json` if smaller) into chat to give the assistant accurate, current schema context.
111
+
112
+ This script reads only **metadata**, never records. To inspect record contents inside a Vibe Coding block, use the Field Inspector pattern in [fields.md](fields.md#field-inspector-block).
113
+
58
114
  ## Supported Fields
59
115
 
60
116
  | Field Type | Writable | Notes |
@@ -107,6 +107,8 @@ export default function Block() {
107
107
  }
108
108
  ```
109
109
 
110
+ **For Airtable specifically, the bundled `get-airtable-base` script is a more comprehensive alternative** — it exports the full base schema (every table, every field with `fld...` IDs and column names, relationships, webhooks, interfaces) to a Desktop folder via the Airtable Web API. Run with `bash ~/.claude/skills/softr-vibe-coding/tools/get-airtable-base` (requires `jq` — `brew install jq` on macOS). Best when you want a portable schema snapshot, are documenting field IDs alongside column names per the [airtable.md maintainability mitigation](airtable.md#maintainability-gotcha), or need to audit lookup/rollup/sync relationships. Full usage in [airtable.md](airtable.md#bundled-cli-script-get-airtable-base).
111
+
110
112
  **For Softr Database, find field IDs via:**
111
113
 
112
114
  1. **Softr Database MCP server (recommended for AI-assisted workflows)** -- if you're collaborating with an AI assistant (Claude Code, Claude Desktop, Cursor, ChatGPT, Mistral) to write Vibe Coding blocks, the official Softr MCP server is the cleanest path. The AI calls schema/list-fields tools directly against your workspace and reads back every field's `id`, `name`, `type`, and dropdown option UUIDs -- no copy-paste, no transcription errors. Full setup, scopes, and scope limitations (Softr DB only -- does NOT cover Airtable / external sources) in [../references/softr-database-mcp.md](../references/softr-database-mcp.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "./bin/cli.js"
@@ -0,0 +1,303 @@
1
+ #!/bin/bash
2
+ #
3
+ # get-airtable-base
4
+ # Pulls all available metadata for a single Airtable base via the Web API
5
+ # and saves it to a timestamped folder on the Desktop.
6
+ #
7
+
8
+ set -euo pipefail
9
+
10
+ # ---------- Colors ----------
11
+ RED='\033[0;31m'
12
+ GREEN='\033[0;32m'
13
+ YELLOW='\033[1;33m'
14
+ BLUE='\033[0;34m'
15
+ GRAY='\033[0;90m'
16
+ BOLD='\033[1m'
17
+ NC='\033[0m'
18
+
19
+ # ---------- Pre-flight ----------
20
+ if ! command -v jq &>/dev/null; then
21
+ echo -e "${RED}Error: jq is not installed.${NC}"
22
+ echo "Install it with: brew install jq"
23
+ exit 1
24
+ fi
25
+
26
+ if ! command -v curl &>/dev/null; then
27
+ echo -e "${RED}Error: curl is not installed.${NC}"
28
+ exit 1
29
+ fi
30
+
31
+ # ---------- Banner ----------
32
+ echo ""
33
+ echo -e "${BOLD}${BLUE}╔════════════════════════════════════════╗${NC}"
34
+ echo -e "${BOLD}${BLUE}║ Airtable Base Metadata Fetcher ║${NC}"
35
+ echo -e "${BOLD}${BLUE}╚════════════════════════════════════════╝${NC}"
36
+ echo ""
37
+
38
+ # ---------- Prompts ----------
39
+ read -r -p "$(echo -e "${BOLD}Base ID${NC} (e.g. appXXXXXXXXXXXXXX): ")" BASE_ID
40
+ if [[ -z "$BASE_ID" ]]; then
41
+ echo -e "${RED}Error: Base ID is required.${NC}"
42
+ exit 1
43
+ fi
44
+ if [[ ! "$BASE_ID" =~ ^app[a-zA-Z0-9]{14}$ ]]; then
45
+ echo -e "${YELLOW}Warning: '$BASE_ID' doesn't look like a standard Base ID (app + 14 chars). Continuing anyway...${NC}"
46
+ fi
47
+
48
+ read -r -s -p "$(echo -e "${BOLD}Personal Access Token${NC} (input hidden): ")" TOKEN
49
+ echo ""
50
+ if [[ -z "$TOKEN" ]]; then
51
+ echo -e "${RED}Error: PAT is required.${NC}"
52
+ exit 1
53
+ fi
54
+
55
+ # ---------- Output folder ----------
56
+ TIMESTAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)
57
+ OUTPUT_DIR="$HOME/Desktop/airtable-base-$BASE_ID-$TIMESTAMP"
58
+ mkdir -p "$OUTPUT_DIR"
59
+
60
+ echo ""
61
+ echo -e "${GRAY}Output: $OUTPUT_DIR${NC}"
62
+ echo ""
63
+
64
+ AUTH=(-H "Authorization: Bearer $TOKEN")
65
+ API="https://api.airtable.com/v0"
66
+
67
+ # ---------- Helper ----------
68
+ # fetch URL OUTFILE LABEL [optional]
69
+ # Writes response to OUTFILE (jq-pretty). Returns 0 on HTTP 2xx, 1 otherwise.
70
+ fetch() {
71
+ local url="$1"
72
+ local outfile="$2"
73
+ local label="$3"
74
+ local optional="${4:-false}"
75
+
76
+ local tmp http_code body
77
+ tmp=$(mktemp)
78
+ http_code=$(curl -s -o "$tmp" -w "%{http_code}" "${AUTH[@]}" "$url" || echo "000")
79
+
80
+ if [[ "$http_code" =~ ^2 ]]; then
81
+ if jq '.' "$tmp" >"$outfile" 2>/dev/null; then
82
+ local size
83
+ size=$(wc -c <"$outfile" | tr -d ' ')
84
+ echo -e " ${GREEN}✓${NC} $label ${GRAY}(${size} bytes)${NC}"
85
+ rm -f "$tmp"
86
+ return 0
87
+ else
88
+ echo -e " ${RED}✗${NC} $label ${GRAY}(invalid JSON response)${NC}"
89
+ mv "$tmp" "$outfile.raw"
90
+ return 1
91
+ fi
92
+ else
93
+ body=$(cat "$tmp")
94
+ rm -f "$tmp"
95
+ if [[ "$optional" == "true" ]]; then
96
+ echo -e " ${YELLOW}-${NC} $label ${GRAY}(HTTP $http_code — skipped)${NC}"
97
+ else
98
+ echo -e " ${RED}✗${NC} $label ${GRAY}(HTTP $http_code)${NC}"
99
+ echo -e "${GRAY} $body${NC}" | head -c 300
100
+ echo ""
101
+ fi
102
+ return 1
103
+ fi
104
+ }
105
+
106
+ # ---------- Phase 1: Identity & access ----------
107
+ echo -e "${BOLD}Phase 1: Validating token...${NC}"
108
+
109
+ fetch "$API/meta/whoami" \
110
+ "$OUTPUT_DIR/05-whoami.json" \
111
+ "Token identity (whoami)" || {
112
+ echo -e "${RED}Cannot continue — token is invalid or revoked.${NC}"
113
+ exit 1
114
+ }
115
+
116
+ if [[ -f "$OUTPUT_DIR/05-whoami.json" ]]; then
117
+ scopes=$(jq -r '.scopes // [] | join(", ")' "$OUTPUT_DIR/05-whoami.json" 2>/dev/null || echo "(unknown)")
118
+ user_id=$(jq -r '.id // "(unknown)"' "$OUTPUT_DIR/05-whoami.json" 2>/dev/null || echo "(unknown)")
119
+ echo -e "${GRAY} User: $user_id${NC}"
120
+ echo -e "${GRAY} Scopes: $scopes${NC}"
121
+ fi
122
+
123
+ # ---------- Phase 2: Base schema + metadata ----------
124
+ echo ""
125
+ echo -e "${BOLD}Phase 2: Fetching base metadata...${NC}"
126
+
127
+ fetch "$API/meta/bases/$BASE_ID?include=collaborators&include=interfaces&include=inviteLinks" \
128
+ "$OUTPUT_DIR/01-base-info.json" \
129
+ "Base info (collaborators, interfaces, invite links)" \
130
+ true || true
131
+
132
+ fetch "$API/meta/bases/$BASE_ID/tables?include[]=visibleFieldIds" \
133
+ "$OUTPUT_DIR/02-schema.json" \
134
+ "Schema (tables, fields, views with visibleFieldIds)" || {
135
+ echo -e "${RED}Cannot continue without schema. Exiting.${NC}"
136
+ exit 1
137
+ }
138
+
139
+ fetch "$API/meta/bases/$BASE_ID/shares" \
140
+ "$OUTPUT_DIR/03-shares.json" \
141
+ "Shares (Enterprise-only)" \
142
+ true || true
143
+
144
+ fetch "$API/bases/$BASE_ID/webhooks" \
145
+ "$OUTPUT_DIR/04-webhooks.json" \
146
+ "Webhooks" \
147
+ true || true
148
+
149
+ # ---------- Phase 3: Schema-derived relationships & sync detection ----------
150
+ echo ""
151
+ echo -e "${BOLD}Phase 3: Analyzing schema for relationships & sync...${NC}"
152
+
153
+ if [[ -f "$OUTPUT_DIR/02-schema.json" ]]; then
154
+ jq '
155
+ {
156
+ sync_destinations: [
157
+ .tables[] | select([.fields[].type] | any(. == "externalSyncSource")) | {
158
+ tableId: .id,
159
+ tableName: .name,
160
+ syncedFields: [.fields[] | select(.type == "externalSyncSource") | {id, name}]
161
+ }
162
+ ],
163
+ cross_table_links: [
164
+ .tables[] as $t |
165
+ $t.fields[] | select(.type == "multipleRecordLinks") | {
166
+ fromTableId: $t.id,
167
+ fromTableName: $t.name,
168
+ fromFieldId: .id,
169
+ fromFieldName: .name,
170
+ toTableId: (.options.linkedTableId // null),
171
+ inverseFieldId: (.options.inverseLinkFieldId // null),
172
+ isReversed: (.options.isReversed // null),
173
+ prefersSingleRecordLink: (.options.prefersSingleRecordLink // null)
174
+ }
175
+ ],
176
+ lookup_fields: [
177
+ .tables[] as $t |
178
+ $t.fields[] | select(.type == "multipleLookupValues") | {
179
+ tableId: $t.id,
180
+ tableName: $t.name,
181
+ fieldId: .id,
182
+ fieldName: .name,
183
+ viaLinkFieldId: (.options.recordLinkFieldId // null),
184
+ targetFieldId: (.options.fieldIdInLinkedTable // null)
185
+ }
186
+ ],
187
+ rollup_fields: [
188
+ .tables[] as $t |
189
+ $t.fields[] | select(.type == "rollup") | {
190
+ tableId: $t.id,
191
+ tableName: $t.name,
192
+ fieldId: .id,
193
+ fieldName: .name,
194
+ viaLinkFieldId: (.options.recordLinkFieldId // null),
195
+ sourceFieldId: (.options.fieldIdInLinkedTable // null),
196
+ formula: (.options.reductionConditional // null)
197
+ }
198
+ ],
199
+ count_fields: [
200
+ .tables[] as $t |
201
+ $t.fields[] | select(.type == "count") | {
202
+ tableId: $t.id,
203
+ tableName: $t.name,
204
+ fieldId: .id,
205
+ fieldName: .name,
206
+ viaLinkFieldId: (.options.recordLinkFieldId // null)
207
+ }
208
+ ],
209
+ formula_fields: [
210
+ .tables[] as $t |
211
+ $t.fields[] | select(.type == "formula") | {
212
+ tableId: $t.id,
213
+ tableName: $t.name,
214
+ fieldId: .id,
215
+ fieldName: .name,
216
+ formula: (.options.formula // null),
217
+ referencedFieldIds: (.options.referencedFieldIds // [])
218
+ }
219
+ ]
220
+ }
221
+ ' "$OUTPUT_DIR/02-schema.json" > "$OUTPUT_DIR/06-relationships.json" 2>/dev/null
222
+
223
+ if [[ -f "$OUTPUT_DIR/06-relationships.json" ]]; then
224
+ sync_count=$(jq '.sync_destinations | length' "$OUTPUT_DIR/06-relationships.json")
225
+ link_count=$(jq '.cross_table_links | length' "$OUTPUT_DIR/06-relationships.json")
226
+ lookup_count=$(jq '.lookup_fields | length' "$OUTPUT_DIR/06-relationships.json")
227
+ rollup_count=$(jq '.rollup_fields | length' "$OUTPUT_DIR/06-relationships.json")
228
+ count_count=$(jq '.count_fields | length' "$OUTPUT_DIR/06-relationships.json")
229
+ formula_count=$(jq '.formula_fields | length' "$OUTPUT_DIR/06-relationships.json")
230
+ echo -e " ${GREEN}✓${NC} Sync destinations: ${BOLD}$sync_count${NC}"
231
+ echo -e " ${GREEN}✓${NC} Cross-table links: ${BOLD}$link_count${NC}"
232
+ echo -e " ${GREEN}✓${NC} Lookup fields: ${BOLD}$lookup_count${NC}"
233
+ echo -e " ${GREEN}✓${NC} Rollup fields: ${BOLD}$rollup_count${NC}"
234
+ echo -e " ${GREEN}✓${NC} Count fields: ${BOLD}$count_count${NC}"
235
+ echo -e " ${GREEN}✓${NC} Formula fields: ${BOLD}$formula_count${NC}"
236
+ fi
237
+ fi
238
+
239
+ # ---------- Phase 4: Combined bundle ----------
240
+ echo ""
241
+ echo -e "${BOLD}Phase 4: Building combined bundle...${NC}"
242
+
243
+ # Ensure jq --slurpfile has something to read for endpoints that were skipped/failed.
244
+ for f in 01-base-info.json 02-schema.json 03-shares.json 04-webhooks.json 05-whoami.json 06-relationships.json; do
245
+ [[ -f "$OUTPUT_DIR/$f" ]] || echo 'null' > "$OUTPUT_DIR/$f"
246
+ done
247
+
248
+ jq -n \
249
+ --slurpfile whoami "$OUTPUT_DIR/05-whoami.json" \
250
+ --slurpfile base "$OUTPUT_DIR/01-base-info.json" \
251
+ --slurpfile schema "$OUTPUT_DIR/02-schema.json" \
252
+ --slurpfile shares "$OUTPUT_DIR/03-shares.json" \
253
+ --slurpfile webhooks "$OUTPUT_DIR/04-webhooks.json" \
254
+ --slurpfile rels "$OUTPUT_DIR/06-relationships.json" \
255
+ '{
256
+ whoami: ($whoami[0] // null),
257
+ base_info: ($base[0] // null),
258
+ schema: ($schema[0] // null),
259
+ shares: ($shares[0] // null),
260
+ webhooks: ($webhooks[0] // null),
261
+ relationships: ($rels[0] // null)
262
+ }' > "$OUTPUT_DIR/00-bundle.json" 2>/dev/null || {
263
+ echo -e " ${YELLOW}Could not build full bundle.${NC}"
264
+ }
265
+
266
+ if [[ -f "$OUTPUT_DIR/00-bundle.json" ]]; then
267
+ size=$(wc -c <"$OUTPUT_DIR/00-bundle.json" | tr -d ' ')
268
+ echo -e " ${GREEN}✓${NC} 00-bundle.json ${GRAY}(${size} bytes)${NC}"
269
+ fi
270
+
271
+ # ---------- Summary ----------
272
+ echo ""
273
+ echo -e "${BOLD}${GREEN}Done.${NC}"
274
+ echo ""
275
+ echo -e "${BOLD}Summary:${NC}"
276
+
277
+ if [[ -f "$OUTPUT_DIR/02-schema.json" ]]; then
278
+ TABLE_COUNT=$(jq '.tables | length' "$OUTPUT_DIR/02-schema.json")
279
+ FIELD_COUNT=$(jq '[.tables[].fields | length] | add' "$OUTPUT_DIR/02-schema.json")
280
+ VIEW_COUNT=$(jq '[.tables[].views | length] | add' "$OUTPUT_DIR/02-schema.json")
281
+ echo -e " Tables: ${BOLD}$TABLE_COUNT${NC}"
282
+ echo -e " Fields: ${BOLD}$FIELD_COUNT${NC}"
283
+ echo -e " Views: ${BOLD}$VIEW_COUNT${NC}"
284
+ fi
285
+
286
+ if [[ -f "$OUTPUT_DIR/01-base-info.json" ]]; then
287
+ INT_COUNT=$(jq '.interfaces // {} | keys | length' "$OUTPUT_DIR/01-base-info.json" 2>/dev/null || echo 0)
288
+ [[ "$INT_COUNT" -gt 0 ]] && echo -e " Interfaces: ${BOLD}$INT_COUNT${NC}"
289
+ fi
290
+
291
+ if [[ -f "$OUTPUT_DIR/04-webhooks.json" ]]; then
292
+ WH_COUNT=$(jq '.webhooks // [] | length' "$OUTPUT_DIR/04-webhooks.json" 2>/dev/null || echo 0)
293
+ [[ "$WH_COUNT" -gt 0 ]] && echo -e " Webhooks: ${BOLD}$WH_COUNT${NC}"
294
+ fi
295
+
296
+ echo ""
297
+ echo -e "${GRAY}Folder: $OUTPUT_DIR${NC}"
298
+ echo ""
299
+
300
+ # Open in Finder
301
+ if command -v open &>/dev/null; then
302
+ open "$OUTPUT_DIR"
303
+ fi