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.
- package/LICENSE +21 -0
- package/README.md +926 -0
- package/claude-code/CLAUDE.md.shipwright +125 -0
- package/claude-code/hooks/notify-idle.sh +35 -0
- package/claude-code/hooks/pre-compact-save.sh +57 -0
- package/claude-code/hooks/task-completed.sh +170 -0
- package/claude-code/hooks/teammate-idle.sh +68 -0
- package/claude-code/settings.json.template +184 -0
- package/completions/_shipwright +140 -0
- package/completions/shipwright.bash +89 -0
- package/completions/shipwright.fish +107 -0
- package/docs/KNOWN-ISSUES.md +199 -0
- package/docs/TIPS.md +331 -0
- package/docs/definition-of-done.example.md +16 -0
- package/docs/patterns/README.md +139 -0
- package/docs/patterns/audit-loop.md +149 -0
- package/docs/patterns/bug-hunt.md +183 -0
- package/docs/patterns/feature-implementation.md +159 -0
- package/docs/patterns/refactoring.md +183 -0
- package/docs/patterns/research-exploration.md +144 -0
- package/docs/patterns/test-generation.md +173 -0
- package/package.json +49 -0
- package/scripts/adapters/docker-deploy.sh +50 -0
- package/scripts/adapters/fly-deploy.sh +41 -0
- package/scripts/adapters/iterm2-adapter.sh +122 -0
- package/scripts/adapters/railway-deploy.sh +34 -0
- package/scripts/adapters/tmux-adapter.sh +87 -0
- package/scripts/adapters/vercel-deploy.sh +35 -0
- package/scripts/adapters/wezterm-adapter.sh +103 -0
- package/scripts/cct +242 -0
- package/scripts/cct-cleanup.sh +172 -0
- package/scripts/cct-cost.sh +590 -0
- package/scripts/cct-daemon.sh +3189 -0
- package/scripts/cct-doctor.sh +328 -0
- package/scripts/cct-fix.sh +478 -0
- package/scripts/cct-fleet.sh +904 -0
- package/scripts/cct-init.sh +282 -0
- package/scripts/cct-logs.sh +273 -0
- package/scripts/cct-loop.sh +1332 -0
- package/scripts/cct-memory.sh +1148 -0
- package/scripts/cct-pipeline.sh +3844 -0
- package/scripts/cct-prep.sh +1352 -0
- package/scripts/cct-ps.sh +168 -0
- package/scripts/cct-reaper.sh +390 -0
- package/scripts/cct-session.sh +284 -0
- package/scripts/cct-status.sh +169 -0
- package/scripts/cct-templates.sh +242 -0
- package/scripts/cct-upgrade.sh +422 -0
- package/scripts/cct-worktree.sh +405 -0
- package/scripts/postinstall.mjs +96 -0
- package/templates/pipelines/autonomous.json +71 -0
- package/templates/pipelines/cost-aware.json +95 -0
- package/templates/pipelines/deployed.json +79 -0
- package/templates/pipelines/enterprise.json +114 -0
- package/templates/pipelines/fast.json +63 -0
- package/templates/pipelines/full.json +104 -0
- package/templates/pipelines/hotfix.json +63 -0
- package/templates/pipelines/standard.json +91 -0
- package/tmux/claude-teams-overlay.conf +109 -0
- package/tmux/templates/architecture.json +19 -0
- package/tmux/templates/bug-fix.json +24 -0
- package/tmux/templates/code-review.json +24 -0
- package/tmux/templates/devops.json +19 -0
- package/tmux/templates/documentation.json +19 -0
- package/tmux/templates/exploration.json +19 -0
- package/tmux/templates/feature-dev.json +24 -0
- package/tmux/templates/full-stack.json +24 -0
- package/tmux/templates/migration.json +24 -0
- package/tmux/templates/refactor.json +19 -0
- package/tmux/templates/security-audit.json +24 -0
- package/tmux/templates/testing.json +24 -0
- 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
|