shipwright-cli 1.7.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +926 -0
  3. package/claude-code/CLAUDE.md.shipwright +125 -0
  4. package/claude-code/hooks/notify-idle.sh +35 -0
  5. package/claude-code/hooks/pre-compact-save.sh +57 -0
  6. package/claude-code/hooks/task-completed.sh +170 -0
  7. package/claude-code/hooks/teammate-idle.sh +68 -0
  8. package/claude-code/settings.json.template +184 -0
  9. package/completions/_shipwright +140 -0
  10. package/completions/shipwright.bash +89 -0
  11. package/completions/shipwright.fish +107 -0
  12. package/docs/KNOWN-ISSUES.md +199 -0
  13. package/docs/TIPS.md +331 -0
  14. package/docs/definition-of-done.example.md +16 -0
  15. package/docs/patterns/README.md +139 -0
  16. package/docs/patterns/audit-loop.md +149 -0
  17. package/docs/patterns/bug-hunt.md +183 -0
  18. package/docs/patterns/feature-implementation.md +159 -0
  19. package/docs/patterns/refactoring.md +183 -0
  20. package/docs/patterns/research-exploration.md +144 -0
  21. package/docs/patterns/test-generation.md +173 -0
  22. package/package.json +49 -0
  23. package/scripts/adapters/docker-deploy.sh +50 -0
  24. package/scripts/adapters/fly-deploy.sh +41 -0
  25. package/scripts/adapters/iterm2-adapter.sh +122 -0
  26. package/scripts/adapters/railway-deploy.sh +34 -0
  27. package/scripts/adapters/tmux-adapter.sh +87 -0
  28. package/scripts/adapters/vercel-deploy.sh +35 -0
  29. package/scripts/adapters/wezterm-adapter.sh +103 -0
  30. package/scripts/cct +242 -0
  31. package/scripts/cct-cleanup.sh +172 -0
  32. package/scripts/cct-cost.sh +590 -0
  33. package/scripts/cct-daemon.sh +3189 -0
  34. package/scripts/cct-doctor.sh +328 -0
  35. package/scripts/cct-fix.sh +478 -0
  36. package/scripts/cct-fleet.sh +904 -0
  37. package/scripts/cct-init.sh +282 -0
  38. package/scripts/cct-logs.sh +273 -0
  39. package/scripts/cct-loop.sh +1332 -0
  40. package/scripts/cct-memory.sh +1148 -0
  41. package/scripts/cct-pipeline.sh +3844 -0
  42. package/scripts/cct-prep.sh +1352 -0
  43. package/scripts/cct-ps.sh +168 -0
  44. package/scripts/cct-reaper.sh +390 -0
  45. package/scripts/cct-session.sh +284 -0
  46. package/scripts/cct-status.sh +169 -0
  47. package/scripts/cct-templates.sh +242 -0
  48. package/scripts/cct-upgrade.sh +422 -0
  49. package/scripts/cct-worktree.sh +405 -0
  50. package/scripts/postinstall.mjs +96 -0
  51. package/templates/pipelines/autonomous.json +71 -0
  52. package/templates/pipelines/cost-aware.json +95 -0
  53. package/templates/pipelines/deployed.json +79 -0
  54. package/templates/pipelines/enterprise.json +114 -0
  55. package/templates/pipelines/fast.json +63 -0
  56. package/templates/pipelines/full.json +104 -0
  57. package/templates/pipelines/hotfix.json +63 -0
  58. package/templates/pipelines/standard.json +91 -0
  59. package/tmux/claude-teams-overlay.conf +109 -0
  60. package/tmux/templates/architecture.json +19 -0
  61. package/tmux/templates/bug-fix.json +24 -0
  62. package/tmux/templates/code-review.json +24 -0
  63. package/tmux/templates/devops.json +19 -0
  64. package/tmux/templates/documentation.json +19 -0
  65. package/tmux/templates/exploration.json +19 -0
  66. package/tmux/templates/feature-dev.json +24 -0
  67. package/tmux/templates/full-stack.json +24 -0
  68. package/tmux/templates/migration.json +24 -0
  69. package/tmux/templates/refactor.json +19 -0
  70. package/tmux/templates/security-audit.json +24 -0
  71. package/tmux/templates/testing.json +24 -0
  72. package/tmux/tmux.conf +167 -0
@@ -0,0 +1,590 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright cost — Token Usage & Cost Intelligence ║
4
+ # ║ Tracks spending · Enforces budgets · Stage breakdowns · Trend analysis ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ set -euo pipefail
7
+
8
+ VERSION="1.7.0"
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+
12
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
13
+ CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
14
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
15
+ BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
16
+ GREEN='\033[38;2;74;222;128m' # success
17
+ YELLOW='\033[38;2;250;204;21m' # warning
18
+ RED='\033[38;2;248;113;113m' # error
19
+ DIM='\033[2m'
20
+ BOLD='\033[1m'
21
+ RESET='\033[0m'
22
+
23
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
24
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
25
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
26
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
27
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
28
+
29
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
30
+ now_epoch() { date +%s; }
31
+
32
+ format_duration() {
33
+ local secs="$1"
34
+ if [[ "$secs" -ge 3600 ]]; then
35
+ printf "%dh %dm %ds" $((secs/3600)) $((secs%3600/60)) $((secs%60))
36
+ elif [[ "$secs" -ge 60 ]]; then
37
+ printf "%dm %ds" $((secs/60)) $((secs%60))
38
+ else
39
+ printf "%ds" "$secs"
40
+ fi
41
+ }
42
+
43
+ # ─── Structured Event Log ──────────────────────────────────────────────────
44
+ EVENTS_FILE="${HOME}/.claude-teams/events.jsonl"
45
+
46
+ emit_event() {
47
+ local event_type="$1"
48
+ shift
49
+ local json_fields=""
50
+ for kv in "$@"; do
51
+ local key="${kv%%=*}"
52
+ local val="${kv#*=}"
53
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
54
+ json_fields="${json_fields},\"${key}\":${val}"
55
+ else
56
+ val="${val//\"/\\\"}"
57
+ json_fields="${json_fields},\"${key}\":\"${val}\""
58
+ fi
59
+ done
60
+ mkdir -p "${HOME}/.claude-teams"
61
+ echo "{\"ts\":\"$(now_iso)\",\"ts_epoch\":$(now_epoch),\"type\":\"${event_type}\"${json_fields}}" >> "$EVENTS_FILE"
62
+ }
63
+
64
+ # ─── Cost Storage ──────────────────────────────────────────────────────────
65
+ COST_DIR="${HOME}/.shipwright"
66
+ COST_FILE="${COST_DIR}/costs.json"
67
+ BUDGET_FILE="${COST_DIR}/budget.json"
68
+
69
+ ensure_cost_dir() {
70
+ mkdir -p "$COST_DIR"
71
+ [[ -f "$COST_FILE" ]] || echo '{"entries":[],"summary":{}}' > "$COST_FILE"
72
+ [[ -f "$BUDGET_FILE" ]] || echo '{"daily_budget_usd":0,"enabled":false}' > "$BUDGET_FILE"
73
+ }
74
+
75
+ # ─── Model Pricing (USD per million tokens) ────────────────────────────────
76
+ # Pricing as of 2025
77
+ OPUS_INPUT_PER_M=15.00
78
+ OPUS_OUTPUT_PER_M=75.00
79
+ SONNET_INPUT_PER_M=3.00
80
+ SONNET_OUTPUT_PER_M=15.00
81
+ HAIKU_INPUT_PER_M=0.25
82
+ HAIKU_OUTPUT_PER_M=1.25
83
+
84
+ # cost_calculate <input_tokens> <output_tokens> <model>
85
+ # Returns the cost in USD (floating point)
86
+ cost_calculate() {
87
+ local input_tokens="${1:-0}"
88
+ local output_tokens="${2:-0}"
89
+ local model="${3:-sonnet}"
90
+
91
+ local input_rate output_rate
92
+ case "$model" in
93
+ opus|claude-opus-4*)
94
+ input_rate="$OPUS_INPUT_PER_M"
95
+ output_rate="$OPUS_OUTPUT_PER_M"
96
+ ;;
97
+ sonnet|claude-sonnet-4*)
98
+ input_rate="$SONNET_INPUT_PER_M"
99
+ output_rate="$SONNET_OUTPUT_PER_M"
100
+ ;;
101
+ haiku|claude-haiku-4*)
102
+ input_rate="$HAIKU_INPUT_PER_M"
103
+ output_rate="$HAIKU_OUTPUT_PER_M"
104
+ ;;
105
+ *)
106
+ # Default to sonnet pricing for unknown models
107
+ input_rate="$SONNET_INPUT_PER_M"
108
+ output_rate="$SONNET_OUTPUT_PER_M"
109
+ ;;
110
+ esac
111
+
112
+ awk -v it="$input_tokens" -v ot="$output_tokens" \
113
+ -v ir="$input_rate" -v or_="$output_rate" \
114
+ 'BEGIN { printf "%.4f", (it / 1000000.0 * ir) + (ot / 1000000.0 * or_) }'
115
+ }
116
+
117
+ # cost_record <input_tokens> <output_tokens> <model> <stage> [issue]
118
+ # Records a cost entry to the cost file and events log.
119
+ cost_record() {
120
+ local input_tokens="${1:-0}"
121
+ local output_tokens="${2:-0}"
122
+ local model="${3:-sonnet}"
123
+ local stage="${4:-unknown}"
124
+ local issue="${5:-}"
125
+
126
+ ensure_cost_dir
127
+
128
+ local cost_usd
129
+ cost_usd=$(cost_calculate "$input_tokens" "$output_tokens" "$model")
130
+
131
+ local tmp_file
132
+ tmp_file=$(mktemp)
133
+ jq --argjson input "$input_tokens" \
134
+ --argjson output "$output_tokens" \
135
+ --arg model "$model" \
136
+ --arg stage "$stage" \
137
+ --arg issue "$issue" \
138
+ --arg cost "$cost_usd" \
139
+ --arg ts "$(now_iso)" \
140
+ --argjson epoch "$(now_epoch)" \
141
+ '.entries += [{
142
+ input_tokens: $input,
143
+ output_tokens: $output,
144
+ model: $model,
145
+ stage: $stage,
146
+ issue: $issue,
147
+ cost_usd: ($cost | tonumber),
148
+ ts: $ts,
149
+ ts_epoch: $epoch
150
+ }] | .entries = (.entries | .[-1000:])' \
151
+ "$COST_FILE" > "$tmp_file" && mv "$tmp_file" "$COST_FILE"
152
+
153
+ emit_event "cost.record" \
154
+ "input_tokens=${input_tokens}" \
155
+ "output_tokens=${output_tokens}" \
156
+ "model=${model}" \
157
+ "stage=${stage}" \
158
+ "cost_usd=${cost_usd}"
159
+ }
160
+
161
+ # cost_check_budget [estimated_cost_usd]
162
+ # Checks if daily budget would be exceeded. Returns 0=ok, 1=warning, 2=blocked.
163
+ cost_check_budget() {
164
+ local estimated="${1:-0}"
165
+
166
+ ensure_cost_dir
167
+
168
+ local budget_enabled budget_usd
169
+ budget_enabled=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
170
+ budget_usd=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
171
+
172
+ if [[ "$budget_enabled" != "true" || "$budget_usd" == "0" ]]; then
173
+ return 0
174
+ fi
175
+
176
+ # Calculate today's spending
177
+ local today_start
178
+ today_start=$(date -u +"%Y-%m-%dT00:00:00Z")
179
+ local today_epoch
180
+ today_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$today_start" +%s 2>/dev/null || date -u -d "$today_start" +%s 2>/dev/null || echo "0")
181
+
182
+ local today_spent
183
+ today_spent=$(jq --argjson cutoff "$today_epoch" \
184
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0' \
185
+ "$COST_FILE" 2>/dev/null || echo "0")
186
+
187
+ local projected
188
+ projected=$(awk -v spent="$today_spent" -v est="$estimated" 'BEGIN { printf "%.4f", spent + est }')
189
+
190
+ local pct_used
191
+ pct_used=$(awk -v spent="$today_spent" -v budget="$budget_usd" 'BEGIN { printf "%.0f", (spent / budget) * 100 }')
192
+
193
+ if awk -v proj="$projected" -v budget="$budget_usd" 'BEGIN { exit !(proj > budget) }'; then
194
+ error "Budget exceeded! Today: \$${today_spent} + estimated \$${estimated} > \$${budget_usd} daily limit"
195
+ emit_event "cost.budget_exceeded" "today_spent=${today_spent}" "estimated=${estimated}" "budget=${budget_usd}"
196
+ return 2
197
+ fi
198
+
199
+ if [[ "${pct_used}" -ge 80 ]]; then
200
+ warn "Budget warning: ${pct_used}% used (\$${today_spent} / \$${budget_usd})"
201
+ emit_event "cost.budget_warning" "pct_used=${pct_used}" "today_spent=${today_spent}" "budget=${budget_usd}"
202
+ return 1
203
+ fi
204
+
205
+ return 0
206
+ }
207
+
208
+ # cost_remaining_budget
209
+ # Returns remaining daily budget as a plain number (for daemon auto-scale consumption)
210
+ # Outputs "unlimited" if budget is not enabled
211
+
212
+ cost_remaining_budget() {
213
+ ensure_cost_dir
214
+
215
+ local budget_enabled budget_usd
216
+ budget_enabled=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
217
+ budget_usd=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
218
+
219
+ if [[ "$budget_enabled" != "true" || "$budget_usd" == "0" ]]; then
220
+ echo "unlimited"
221
+ return 0
222
+ fi
223
+
224
+ # Calculate today's spending (same pattern as cost_check_budget)
225
+ local today_start
226
+ today_start=$(date -u +"%Y-%m-%dT00:00:00Z")
227
+ local today_epoch
228
+ today_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$today_start" +%s 2>/dev/null || date -u -d "$today_start" +%s 2>/dev/null || echo "0")
229
+
230
+ local today_spent
231
+ today_spent=$(jq --argjson cutoff "$today_epoch" \
232
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0' \
233
+ "$COST_FILE" 2>/dev/null || echo "0")
234
+
235
+ # Validate numeric values
236
+ if [[ ! "$today_spent" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
237
+ today_spent="0"
238
+ fi
239
+ if [[ ! "$budget_usd" =~ ^[0-9]+\.?[0-9]*$ ]]; then
240
+ echo "unlimited"
241
+ return 0
242
+ fi
243
+
244
+ # Calculate remaining
245
+ local remaining
246
+ remaining=$(awk -v budget="$budget_usd" -v spent="$today_spent" 'BEGIN { printf "%.2f", budget - spent }')
247
+
248
+ echo "$remaining"
249
+ }
250
+
251
+ # ─── Dashboard ─────────────────────────────────────────────────────────────
252
+
253
+ cost_dashboard() {
254
+ local period_days=7
255
+ local json_output=false
256
+ local by_stage=false
257
+ local by_issue=false
258
+
259
+ while [[ $# -gt 0 ]]; do
260
+ case "$1" in
261
+ --period) period_days="${2:-7}"; shift 2 ;;
262
+ --period=*) period_days="${1#--period=}"; shift ;;
263
+ --json) json_output=true; shift ;;
264
+ --by-stage) by_stage=true; shift ;;
265
+ --by-issue) by_issue=true; shift ;;
266
+ *) shift ;;
267
+ esac
268
+ done
269
+
270
+ ensure_cost_dir
271
+
272
+ if [[ ! -f "$COST_FILE" ]]; then
273
+ warn "No cost data found."
274
+ return 0
275
+ fi
276
+
277
+ local cutoff_epoch
278
+ cutoff_epoch=$(( $(now_epoch) - (period_days * 86400) ))
279
+
280
+ # Filter entries within period
281
+ local period_entries
282
+ period_entries=$(jq --argjson cutoff "$cutoff_epoch" \
283
+ '[.entries[] | select(.ts_epoch >= $cutoff)]' \
284
+ "$COST_FILE" 2>/dev/null || echo "[]")
285
+
286
+ local entry_count
287
+ entry_count=$(echo "$period_entries" | jq 'length')
288
+
289
+ if [[ "$entry_count" -eq 0 ]]; then
290
+ warn "No cost entries in the last ${period_days} day(s)."
291
+ return 0
292
+ fi
293
+
294
+ # Aggregate stats
295
+ local total_cost avg_cost max_cost total_input total_output
296
+ total_cost=$(echo "$period_entries" | jq '[.[].cost_usd] | add // 0 | . * 100 | round / 100')
297
+ avg_cost=$(echo "$period_entries" | jq '[.[].cost_usd] | if length > 0 then add / length else 0 end | . * 100 | round / 100')
298
+ max_cost=$(echo "$period_entries" | jq '[.[].cost_usd] | max // 0 | . * 100 | round / 100')
299
+ total_input=$(echo "$period_entries" | jq '[.[].input_tokens] | add // 0')
300
+ total_output=$(echo "$period_entries" | jq '[.[].output_tokens] | add // 0')
301
+
302
+ # Stage breakdown
303
+ local stage_breakdown
304
+ stage_breakdown=$(echo "$period_entries" | jq '
305
+ group_by(.stage) | map({
306
+ stage: .[0].stage,
307
+ cost: ([.[].cost_usd] | add // 0 | . * 100 | round / 100),
308
+ count: length
309
+ }) | sort_by(-.cost)')
310
+
311
+ # Issue breakdown
312
+ local issue_breakdown
313
+ issue_breakdown=$(echo "$period_entries" | jq '
314
+ [.[] | select(.issue != "")] | group_by(.issue) | map({
315
+ issue: .[0].issue,
316
+ cost: ([.[].cost_usd] | add // 0 | . * 100 | round / 100),
317
+ count: length
318
+ }) | sort_by(-.cost) | .[:10]')
319
+
320
+ # Cost trend (compare first half vs second half of period)
321
+ local half_epoch
322
+ half_epoch=$(( cutoff_epoch + (period_days * 86400 / 2) ))
323
+ local first_half_cost second_half_cost trend
324
+ first_half_cost=$(echo "$period_entries" | jq --argjson mid "$half_epoch" \
325
+ '[.[] | select(.ts_epoch < $mid) | .cost_usd] | add // 0')
326
+ second_half_cost=$(echo "$period_entries" | jq --argjson mid "$half_epoch" \
327
+ '[.[] | select(.ts_epoch >= $mid) | .cost_usd] | add // 0')
328
+
329
+ if awk -v f="$first_half_cost" -v s="$second_half_cost" 'BEGIN { exit !(f > 0) }' 2>/dev/null; then
330
+ local change_pct
331
+ change_pct=$(awk -v f="$first_half_cost" -v s="$second_half_cost" \
332
+ 'BEGIN { printf "%.0f", ((s - f) / f) * 100 }')
333
+ if [[ "$change_pct" -gt 10 ]]; then
334
+ trend="↑ ${change_pct}% (increasing)"
335
+ elif [[ "$change_pct" -lt -10 ]]; then
336
+ trend="↓ ${change_pct#-}% (decreasing)"
337
+ else
338
+ trend="→ stable"
339
+ fi
340
+ else
341
+ trend="→ insufficient data"
342
+ fi
343
+
344
+ # Budget info
345
+ local budget_enabled budget_usd today_spent
346
+ budget_enabled=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
347
+ budget_usd=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
348
+
349
+ local today_start_epoch
350
+ today_start_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$(date -u +%Y-%m-%dT00:00:00Z)" +%s 2>/dev/null || date -u -d "$(date -u +%Y-%m-%dT00:00:00Z)" +%s 2>/dev/null || echo "0")
351
+ today_spent=$(jq --argjson cutoff "$today_start_epoch" \
352
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0 | . * 100 | round / 100' \
353
+ "$COST_FILE" 2>/dev/null || echo "0")
354
+
355
+ # ── JSON Output ──
356
+ if [[ "$json_output" == "true" ]]; then
357
+ jq -n \
358
+ --arg period "${period_days}d" \
359
+ --argjson total_cost "$total_cost" \
360
+ --argjson avg_cost "$avg_cost" \
361
+ --argjson max_cost "$max_cost" \
362
+ --argjson total_input "$total_input" \
363
+ --argjson total_output "$total_output" \
364
+ --argjson entry_count "$entry_count" \
365
+ --argjson stage_breakdown "$stage_breakdown" \
366
+ --argjson issue_breakdown "$issue_breakdown" \
367
+ --arg trend "$trend" \
368
+ --arg budget_enabled "$budget_enabled" \
369
+ --argjson budget_usd "${budget_usd:-0}" \
370
+ --argjson today_spent "$today_spent" \
371
+ '{
372
+ period: $period,
373
+ total_cost_usd: $total_cost,
374
+ avg_cost_usd: $avg_cost,
375
+ max_cost_usd: $max_cost,
376
+ total_input_tokens: $total_input,
377
+ total_output_tokens: $total_output,
378
+ entries: $entry_count,
379
+ by_stage: $stage_breakdown,
380
+ by_issue: $issue_breakdown,
381
+ trend: $trend,
382
+ budget: {
383
+ enabled: ($budget_enabled == "true"),
384
+ daily_usd: $budget_usd,
385
+ today_spent_usd: $today_spent
386
+ }
387
+ }'
388
+ return 0
389
+ fi
390
+
391
+ # ── Dashboard Output ──
392
+ echo ""
393
+ echo -e "${PURPLE}${BOLD}━━━ Cost Intelligence ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
394
+ echo -e " Period: last ${period_days} day(s) ${DIM}$(now_iso)${RESET}"
395
+ echo ""
396
+
397
+ echo -e "${BOLD} SPENDING SUMMARY${RESET}"
398
+ echo -e " Total cost ${CYAN}\$${total_cost}${RESET}"
399
+ echo -e " Avg per pipeline \$${avg_cost}"
400
+ echo -e " Max single pipeline \$${max_cost}"
401
+ echo -e " Entries ${entry_count}"
402
+ echo ""
403
+
404
+ echo -e "${BOLD} TOKENS${RESET}"
405
+ echo -e " Input tokens $(printf "%'d" "$total_input")"
406
+ echo -e " Output tokens $(printf "%'d" "$total_output")"
407
+ echo ""
408
+
409
+ echo -e "${BOLD} TREND${RESET}"
410
+ echo -e " ${trend}"
411
+ echo ""
412
+
413
+ # Stage breakdown
414
+ if [[ "$by_stage" == "true" ]]; then
415
+ echo -e "${BOLD} BY STAGE${RESET}"
416
+ echo "$stage_breakdown" | jq -r '.[] | " \(.stage)\t$\(.cost)\t(\(.count) entries)"' 2>/dev/null | \
417
+ while IFS=$'\t' read -r stage cost count; do
418
+ printf " %-20s %-12s %s\n" "$stage" "$cost" "$count"
419
+ done
420
+ echo ""
421
+ fi
422
+
423
+ # Issue breakdown
424
+ if [[ "$by_issue" == "true" ]]; then
425
+ echo -e "${BOLD} BY ISSUE${RESET}"
426
+ echo "$issue_breakdown" | jq -r '.[] | " #\(.issue)\t$\(.cost)\t(\(.count) entries)"' 2>/dev/null | \
427
+ while IFS=$'\t' read -r issue cost count; do
428
+ printf " %-20s %-12s %s\n" "$issue" "$cost" "$count"
429
+ done
430
+ echo ""
431
+ fi
432
+
433
+ # Budget
434
+ if [[ "$budget_enabled" == "true" ]]; then
435
+ local pct_used
436
+ pct_used=$(awk -v spent="$today_spent" -v budget="$budget_usd" \
437
+ 'BEGIN { if (budget > 0) printf "%.0f", (spent / budget) * 100; else print "0" }')
438
+ local bar=""
439
+ local filled=$(( pct_used / 5 ))
440
+ [[ "$filled" -gt 20 ]] && filled=20
441
+ local empty=$(( 20 - filled ))
442
+ bar=$(printf '%0.s█' $(seq 1 "$filled") 2>/dev/null || true)
443
+ bar+=$(printf '%0.s░' $(seq 1 "$empty") 2>/dev/null || true)
444
+
445
+ local color="$GREEN"
446
+ [[ "$pct_used" -ge 80 ]] && color="$YELLOW"
447
+ [[ "$pct_used" -ge 100 ]] && color="$RED"
448
+
449
+ echo -e "${BOLD} DAILY BUDGET${RESET}"
450
+ echo -e " ${color}${bar}${RESET} ${pct_used}%"
451
+ echo -e " \$${today_spent} / \$${budget_usd}"
452
+ echo ""
453
+ fi
454
+
455
+ echo -e "${PURPLE}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
456
+ echo ""
457
+ }
458
+
459
+ # ─── Budget Management ─────────────────────────────────────────────────────
460
+
461
+ budget_set() {
462
+ local amount="${1:-}"
463
+
464
+ if [[ -z "$amount" ]]; then
465
+ error "Usage: shipwright cost budget set <amount_usd>"
466
+ return 1
467
+ fi
468
+
469
+ # Validate it's a number
470
+ if ! echo "$amount" | grep -qE '^[0-9]+\.?[0-9]*$'; then
471
+ error "Invalid amount: ${amount} (must be a positive number)"
472
+ return 1
473
+ fi
474
+
475
+ ensure_cost_dir
476
+
477
+ local tmp_file
478
+ tmp_file=$(mktemp)
479
+ jq --arg amt "$amount" \
480
+ '{daily_budget_usd: ($amt | tonumber), enabled: true}' \
481
+ "$BUDGET_FILE" > "$tmp_file" && mv "$tmp_file" "$BUDGET_FILE"
482
+
483
+ success "Daily budget set to \$${amount}"
484
+ emit_event "cost.budget_set" "daily_budget_usd=${amount}"
485
+ }
486
+
487
+ budget_show() {
488
+ ensure_cost_dir
489
+
490
+ local budget_enabled budget_usd
491
+ budget_enabled=$(jq -r '.enabled' "$BUDGET_FILE" 2>/dev/null || echo "false")
492
+ budget_usd=$(jq -r '.daily_budget_usd' "$BUDGET_FILE" 2>/dev/null || echo "0")
493
+
494
+ echo ""
495
+ echo -e "${BOLD} Daily Budget${RESET}"
496
+ if [[ "$budget_enabled" == "true" ]]; then
497
+ echo -e " Limit: ${CYAN}\$${budget_usd}${RESET} per day"
498
+ echo -e " Status: ${GREEN}enabled${RESET}"
499
+
500
+ # Show today's usage
501
+ local today_start_epoch
502
+ today_start_epoch=$(date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$(date -u +%Y-%m-%dT00:00:00Z)" +%s 2>/dev/null || date -u -d "$(date -u +%Y-%m-%dT00:00:00Z)" +%s 2>/dev/null || echo "0")
503
+ local today_spent
504
+ today_spent=$(jq --argjson cutoff "$today_start_epoch" \
505
+ '[.entries[] | select(.ts_epoch >= $cutoff) | .cost_usd] | add // 0 | . * 100 | round / 100' \
506
+ "$COST_FILE" 2>/dev/null || echo "0")
507
+ echo -e " Today: \$${today_spent} / \$${budget_usd}"
508
+ else
509
+ echo -e " Status: ${DIM}not configured${RESET}"
510
+ echo -e " ${DIM}Set with: shipwright cost budget set <amount>${RESET}"
511
+ fi
512
+ echo ""
513
+ }
514
+
515
+ # ─── Help ──────────────────────────────────────────────────────────────────
516
+
517
+ show_help() {
518
+ echo -e "${CYAN}${BOLD}shipwright cost${RESET} ${DIM}v${VERSION}${RESET} — Token Usage & Cost Intelligence"
519
+ echo ""
520
+ echo -e "${BOLD}USAGE${RESET}"
521
+ echo -e " ${CYAN}shipwright cost${RESET} <command> [options]"
522
+ echo ""
523
+ echo -e "${BOLD}COMMANDS${RESET}"
524
+ echo -e " ${CYAN}show${RESET} Show cost summary for current period"
525
+ echo -e " ${CYAN}show${RESET} --period 30 Last 30 days"
526
+ echo -e " ${CYAN}show${RESET} --json JSON output"
527
+ echo -e " ${CYAN}show${RESET} --by-stage Breakdown by pipeline stage"
528
+ echo -e " ${CYAN}show${RESET} --by-issue Breakdown by issue"
529
+ echo -e " ${CYAN}budget set${RESET} <amount> Set daily budget (USD)"
530
+ echo -e " ${CYAN}budget show${RESET} Show current budget/usage"
531
+ echo ""
532
+ echo -e "${BOLD}PIPELINE INTEGRATION${RESET}"
533
+ echo -e " ${CYAN}record${RESET} <in> <out> <model> <stage> [issue] Record token usage"
534
+ echo -e " ${CYAN}calculate${RESET} <in> <out> <model> Calculate cost (no record)"
535
+ echo -e " ${CYAN}check-budget${RESET} [estimated_usd] Check budget before starting"
536
+ echo ""
537
+ echo -e "${BOLD}MODEL PRICING${RESET}"
538
+ echo -e " opus \$15.00 / \$75.00 per 1M tokens (in/out)"
539
+ echo -e " sonnet \$3.00 / \$15.00 per 1M tokens (in/out)"
540
+ echo -e " haiku \$0.25 / \$1.25 per 1M tokens (in/out)"
541
+ echo ""
542
+ echo -e "${BOLD}EXAMPLES${RESET}"
543
+ echo -e " ${DIM}shipwright cost show${RESET} # 7-day cost summary"
544
+ echo -e " ${DIM}shipwright cost show --period 30 --by-stage${RESET} # 30-day breakdown by stage"
545
+ echo -e " ${DIM}shipwright cost budget set 50.00${RESET} # Set \$50/day limit"
546
+ echo -e " ${DIM}shipwright cost budget show${RESET} # Check current budget"
547
+ echo -e " ${DIM}shipwright cost calculate 50000 10000 opus${RESET} # Estimate cost"
548
+ }
549
+
550
+ # ─── Command Router ─────────────────────────────────────────────────────────
551
+
552
+ SUBCOMMAND="${1:-help}"
553
+ shift 2>/dev/null || true
554
+
555
+ case "$SUBCOMMAND" in
556
+ show)
557
+ cost_dashboard "$@"
558
+ ;;
559
+ budget)
560
+ BUDGET_CMD="${1:-show}"
561
+ shift 2>/dev/null || true
562
+ case "$BUDGET_CMD" in
563
+ set) budget_set "$@" ;;
564
+ show) budget_show ;;
565
+ *) error "Unknown budget command: ${BUDGET_CMD}"; show_help; exit 1 ;;
566
+ esac
567
+ ;;
568
+ record)
569
+ cost_record "$@"
570
+ ;;
571
+ calculate)
572
+ cost_calculate "$@"
573
+ echo ""
574
+ ;;
575
+ remaining-budget)
576
+ cost_remaining_budget
577
+ ;;
578
+ check-budget)
579
+ cost_check_budget "$@"
580
+ ;;
581
+ help|--help|-h)
582
+ show_help
583
+ ;;
584
+ *)
585
+ error "Unknown command: ${SUBCOMMAND}"
586
+ echo ""
587
+ show_help
588
+ exit 1
589
+ ;;
590
+ esac