devvami 1.2.0 → 1.3.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.
@@ -0,0 +1,190 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import { search, input } from '@inquirer/prompts'
4
+ import { listLogGroups, filterLogEvents, sinceToEpochMs } from '../../services/cloudwatch-logs.js'
5
+ import { loadConfig } from '../../services/config.js'
6
+ import { DvmiError } from '../../utils/errors.js'
7
+
8
+ const SINCE_OPTIONS = ['1h', '24h', '7d']
9
+
10
+ export default class Logs extends Command {
11
+ static description = 'Browse and query CloudWatch log groups interactively'
12
+
13
+ static examples = [
14
+ '<%= config.bin %> logs',
15
+ '<%= config.bin %> logs --group /aws/lambda/my-fn',
16
+ '<%= config.bin %> logs --group /aws/lambda/my-fn --filter "ERROR" --since 24h',
17
+ '<%= config.bin %> logs --group /aws/lambda/my-fn --limit 50 --json',
18
+ ]
19
+
20
+ static enableJsonFlag = true
21
+
22
+ static flags = {
23
+ group: Flags.string({
24
+ description: 'Log group name — bypasses interactive picker',
25
+ char: 'g',
26
+ }),
27
+ filter: Flags.string({
28
+ description: 'CloudWatch filter pattern (empty = all events)',
29
+ char: 'f',
30
+ default: '',
31
+ }),
32
+ since: Flags.string({
33
+ description: 'Time window: 1h, 24h, 7d',
34
+ default: '1h',
35
+ }),
36
+ limit: Flags.integer({
37
+ description: 'Max log events to return (1–10000)',
38
+ default: 100,
39
+ }),
40
+ region: Flags.string({
41
+ description: 'AWS region (defaults to project config awsRegion)',
42
+ char: 'r',
43
+ }),
44
+ }
45
+
46
+ async run() {
47
+ const { flags } = await this.parse(Logs)
48
+ const isJson = flags.json
49
+
50
+ // Validate --limit
51
+ if (flags.limit < 1 || flags.limit > 10_000) {
52
+ throw new DvmiError('--limit must be between 1 and 10000.', '')
53
+ }
54
+
55
+ // Validate --since
56
+ if (!SINCE_OPTIONS.includes(flags.since)) {
57
+ throw new DvmiError('--since must be one of: 1h, 24h, 7d.', '')
58
+ }
59
+
60
+ const config = await loadConfig()
61
+ const region = flags.region ?? config.awsRegion ?? 'eu-west-1'
62
+
63
+ let logGroupName = flags.group
64
+ let filterPattern = flags.filter
65
+
66
+ // Interactive mode: pick log group + filter pattern
67
+ if (!logGroupName) {
68
+ const spinner = ora('Loading log groups...').start()
69
+ let groups
70
+ try {
71
+ groups = await listLogGroups(region)
72
+ } catch (err) {
73
+ spinner.stop()
74
+ this._handleAwsError(err, region)
75
+ throw err
76
+ }
77
+ spinner.stop()
78
+
79
+ if (groups.length === 0) {
80
+ this.log(`No log groups found in region ${region}. Check your AWS credentials and region.`)
81
+ return
82
+ }
83
+
84
+ try {
85
+ logGroupName = await search({
86
+ message: 'Select a log group',
87
+ source: async (input) => {
88
+ const term = (input ?? '').toLowerCase()
89
+ return groups
90
+ .filter((g) => g.name.toLowerCase().includes(term))
91
+ .map((g) => ({ name: g.name, value: g.name }))
92
+ },
93
+ })
94
+
95
+ filterPattern = await input({
96
+ message: 'Filter pattern (leave empty for all events)',
97
+ default: '',
98
+ })
99
+ } catch {
100
+ // Ctrl+C — clean exit with code 130
101
+ process.exit(130)
102
+ }
103
+ }
104
+
105
+ const { startTime, endTime } = sinceToEpochMs(/** @type {'1h'|'24h'|'7d'} */ (flags.since))
106
+
107
+ const fetchSpinner = isJson ? null : ora('Fetching log events...').start()
108
+
109
+ let result
110
+ try {
111
+ result = await filterLogEvents(logGroupName, filterPattern, startTime, endTime, flags.limit, region)
112
+ } catch (err) {
113
+ fetchSpinner?.stop()
114
+ this._handleAwsError(err, region, logGroupName)
115
+ throw err
116
+ }
117
+ fetchSpinner?.stop()
118
+
119
+ if (isJson) {
120
+ // NDJSON to stdout, summary to stderr
121
+ for (const event of result.events) {
122
+ this.log(
123
+ JSON.stringify({
124
+ eventId: event.eventId,
125
+ logStreamName: event.logStreamName,
126
+ timestamp: event.timestamp,
127
+ message: event.message,
128
+ }),
129
+ )
130
+ }
131
+ process.stderr.write(
132
+ JSON.stringify({
133
+ logGroupName: result.logGroupName,
134
+ filterPattern: result.filterPattern,
135
+ startTime: result.startTime,
136
+ endTime: result.endTime,
137
+ truncated: result.truncated,
138
+ count: result.events.length,
139
+ }) + '\n',
140
+ )
141
+ return
142
+ }
143
+
144
+ // Table output
145
+ const startIso = new Date(startTime).toISOString()
146
+ const endIso = new Date(endTime).toISOString()
147
+ const divider = '─'.repeat(74)
148
+
149
+ this.log(`Log Group: ${logGroupName}`)
150
+ this.log(`Period: last ${flags.since} (${startIso} → ${endIso})`)
151
+ this.log(`Filter: ${filterPattern ? `"${filterPattern}"` : '(none)'}`)
152
+ this.log(divider)
153
+
154
+ for (const event of result.events) {
155
+ const ts = new Date(event.timestamp).toISOString()
156
+ const msg = event.message.length > 200 ? event.message.slice(0, 200) + '…' : event.message
157
+ this.log(` ${ts} ${event.logStreamName.slice(-20).padEnd(20)} ${msg}`)
158
+ }
159
+
160
+ this.log(divider)
161
+ const truncationNotice = result.truncated ? ' [Truncated — use --limit or a narrower --since to see more]' : ''
162
+ this.log(` ${result.events.length} events shown${truncationNotice}`)
163
+ }
164
+
165
+ /**
166
+ * Handle common AWS errors and throw DvmiError with spec-defined messages.
167
+ * @param {unknown} err
168
+ * @param {string} _region
169
+ * @param {string} [_logGroupName]
170
+ */
171
+ _handleAwsError(err, _region, _logGroupName) {
172
+ const msg = String(err)
173
+ if (msg.includes('AccessDenied') || msg.includes('UnauthorizedAccess')) {
174
+ this.error(
175
+ 'Access denied. Ensure your role has logs:DescribeLogGroups and logs:FilterLogEvents permissions.',
176
+ )
177
+ }
178
+ if (msg.includes('ResourceNotFoundException')) {
179
+ this.error(
180
+ `Log group not found. Check the name and confirm you are using the correct region (--region).`,
181
+ )
182
+ }
183
+ if (msg.includes('InvalidParameterException')) {
184
+ this.error('Invalid filter pattern or parameter. Check the pattern syntax and time range.')
185
+ }
186
+ if (msg.includes('CredentialsProviderError') || msg.includes('No credentials')) {
187
+ this.error('No AWS credentials. Configure aws-vault and run `dvmi init` to set up your profile.')
188
+ }
189
+ }
190
+ }
@@ -0,0 +1,205 @@
1
+ import chalk from 'chalk'
2
+
3
+ /** @import { ChartSeries } from '../types.js' */
4
+
5
+ // Colour palette for multi-series charts (cycles if more than 8 series)
6
+ const PALETTE = [
7
+ chalk.cyan,
8
+ chalk.yellow,
9
+ chalk.green,
10
+ chalk.magenta,
11
+ chalk.blue,
12
+ chalk.red,
13
+ chalk.white,
14
+ chalk.gray,
15
+ ]
16
+
17
+ /**
18
+ * Get the terminal width, falling back to 80 columns.
19
+ * @returns {number}
20
+ */
21
+ function terminalWidth() {
22
+ return process.stdout.columns ?? 80
23
+ }
24
+
25
+ /**
26
+ * Scale a value to a bar width in characters.
27
+ * @param {number} value
28
+ * @param {number} max
29
+ * @param {number} maxWidth
30
+ * @returns {number}
31
+ */
32
+ function scaleBar(value, max, maxWidth) {
33
+ if (max === 0) return 0
34
+ return Math.round((value / max) * maxWidth)
35
+ }
36
+
37
+ /**
38
+ * Render a single-series or stacked ASCII bar chart.
39
+ *
40
+ * Each series contributes a coloured segment to each bar. With a single series
41
+ * the bars are monochrome cyan. Labels on the x-axis are sampled to avoid
42
+ * overlap at ~10-column intervals.
43
+ *
44
+ * @param {ChartSeries[]} series - One or more data series
45
+ * @param {{ title?: string, width?: number }} [options]
46
+ * @returns {string}
47
+ */
48
+ export function barChart(series, options = {}) {
49
+ if (!series.length) return '(no data)'
50
+
51
+ const width = options.width ?? Math.min(terminalWidth() - 2, 120)
52
+ const labelColWidth = 12 // space reserved for y-axis labels ($xx.xx)
53
+ const chartWidth = Math.max(width - labelColWidth - 2, 10)
54
+
55
+ // Combine all series into per-label totals for scaling
56
+ const allLabels = series[0]?.labels ?? []
57
+ const totals = allLabels.map((_, i) =>
58
+ series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0),
59
+ )
60
+ const maxTotal = Math.max(...totals, 0)
61
+
62
+ const lines = []
63
+
64
+ if (options.title) {
65
+ lines.push(chalk.bold(options.title))
66
+ lines.push('─'.repeat(width))
67
+ }
68
+
69
+ // Bar rows: one per time label
70
+ const BAR_HEIGHT = 8
71
+
72
+ // Build chart column by column (one char per day)
73
+ // We render it as a 2D grid: rows = height levels, cols = days
74
+ const grid = Array.from({ length: BAR_HEIGHT }, () => Array(allLabels.length).fill(' '))
75
+
76
+ for (let col = 0; col < allLabels.length; col++) {
77
+ const total = totals[col]
78
+ const barLen = scaleBar(total, maxTotal, BAR_HEIGHT)
79
+
80
+ // Determine colour per series for stacked effect (simplified: colour by dominant series)
81
+ let dominantIdx = 0
82
+ let dominantVal = 0
83
+ for (let si = 0; si < series.length; si++) {
84
+ if ((series[si].values[col] ?? 0) > dominantVal) {
85
+ dominantVal = series[si].values[col] ?? 0
86
+ dominantIdx = si
87
+ }
88
+ }
89
+ const colour = PALETTE[dominantIdx % PALETTE.length]
90
+
91
+ for (let row = 0; row < barLen; row++) {
92
+ grid[BAR_HEIGHT - 1 - row][col] = colour('█')
93
+ }
94
+ }
95
+
96
+ // Render grid rows with y-axis labels on the leftmost and rightmost rows
97
+ for (let row = 0; row < BAR_HEIGHT; row++) {
98
+ let yLabel = ' '
99
+ if (row === 0) {
100
+ yLabel = `$${maxTotal.toFixed(2)}`.padStart(labelColWidth)
101
+ } else if (row === BAR_HEIGHT - 1) {
102
+ yLabel = '$0.00'.padStart(labelColWidth)
103
+ }
104
+
105
+ // Sample columns to fit chartWidth (one char per label, spaced if needed)
106
+ const step = Math.max(1, Math.ceil(allLabels.length / chartWidth))
107
+ const rowStr = grid[row].filter((_, i) => i % step === 0).join('')
108
+ lines.push(`${yLabel} ${rowStr}`)
109
+ }
110
+
111
+ // X-axis date labels (sample every ~10 positions)
112
+ const step = Math.max(1, Math.ceil(allLabels.length / Math.floor(chartWidth / 10)))
113
+ const xLabels = allLabels
114
+ .filter((_, i) => i % step === 0)
115
+ .map((l) => l.slice(5)) // "MM-DD"
116
+ lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
117
+
118
+ // Legend for multi-series
119
+ if (series.length > 1) {
120
+ lines.push('')
121
+ lines.push(chalk.dim('Legend:'))
122
+ for (let i = 0; i < series.length; i++) {
123
+ const colour = PALETTE[i % PALETTE.length]
124
+ lines.push(` ${colour('█')} ${series[i].name}`)
125
+ }
126
+ }
127
+
128
+ return lines.join('\n')
129
+ }
130
+
131
+ /**
132
+ * Render an ASCII line chart. Each series is drawn as a distinct coloured line.
133
+ * Values are plotted on a fixed-height canvas using half-block characters.
134
+ *
135
+ * @param {ChartSeries[]} series - One or more data series
136
+ * @param {{ title?: string, width?: number }} [options]
137
+ * @returns {string}
138
+ */
139
+ export function lineChart(series, options = {}) {
140
+ if (!series.length) return '(no data)'
141
+
142
+ const width = options.width ?? Math.min(terminalWidth() - 2, 120)
143
+ const labelColWidth = 12
144
+ const chartWidth = Math.max(width - labelColWidth - 2, 10)
145
+ const chartHeight = 10
146
+
147
+ const allLabels = series[0]?.labels ?? []
148
+ const allValues = series.flatMap((s) => s.values)
149
+ const maxVal = Math.max(...allValues, 0)
150
+
151
+ const lines = []
152
+
153
+ if (options.title) {
154
+ lines.push(chalk.bold(options.title))
155
+ lines.push('─'.repeat(width))
156
+ }
157
+
158
+ // Build a 2D canvas: rows = chartHeight, cols = chartWidth
159
+ const canvas = Array.from({ length: chartHeight }, () =>
160
+ Array(chartWidth).fill(' '),
161
+ )
162
+
163
+ const step = Math.max(1, Math.ceil(allLabels.length / chartWidth))
164
+
165
+ for (let si = 0; si < series.length; si++) {
166
+ const colour = PALETTE[si % PALETTE.length]
167
+ const sampledValues = series[si].values.filter((_, i) => i % step === 0)
168
+
169
+ for (let col = 0; col < Math.min(sampledValues.length, chartWidth); col++) {
170
+ const val = sampledValues[col] ?? 0
171
+ const row = maxVal === 0 ? chartHeight - 1 : chartHeight - 1 - Math.round((val / maxVal) * (chartHeight - 1))
172
+ canvas[Math.max(0, Math.min(row, chartHeight - 1))][col] = colour('●')
173
+ }
174
+ }
175
+
176
+ // Render rows with y-axis labels
177
+ for (let row = 0; row < chartHeight; row++) {
178
+ let yLabel = ' '
179
+ if (row === 0) {
180
+ yLabel = `$${maxVal.toFixed(2)}`.padStart(labelColWidth)
181
+ } else if (row === chartHeight - 1) {
182
+ yLabel = '$0.00'.padStart(labelColWidth)
183
+ }
184
+ lines.push(`${yLabel} ${canvas[row].join('')}`)
185
+ }
186
+
187
+ // X-axis date labels
188
+ const xStep = Math.max(1, Math.ceil(allLabels.length / Math.floor(chartWidth / 10)))
189
+ const xLabels = allLabels
190
+ .filter((_, i) => i % xStep === 0)
191
+ .map((l) => l.slice(5))
192
+ lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
193
+
194
+ // Legend
195
+ if (series.length > 0) {
196
+ lines.push('')
197
+ lines.push(chalk.dim('Legend:'))
198
+ for (let i = 0; i < series.length; i++) {
199
+ const colour = PALETTE[i % PALETTE.length]
200
+ lines.push(` ${colour('●')} ${series[i].name}`)
201
+ }
202
+ }
203
+
204
+ return lines.join('\n')
205
+ }
@@ -1,4 +1,4 @@
1
- /** @import { AWSCostEntry } from '../types.js' */
1
+ /** @import { AWSCostEntry, CostGroupMode } from '../types.js' */
2
2
 
3
3
  /**
4
4
  * Format a USD amount as currency string.
@@ -31,21 +31,34 @@ export function formatTrend(current, previous) {
31
31
  return `${sign}${pct.toFixed(1)}%`
32
32
  }
33
33
 
34
+ /**
35
+ * Derive the display row label for a cost entry based on the grouping mode.
36
+ * @param {AWSCostEntry} entry
37
+ * @param {CostGroupMode} groupBy
38
+ * @returns {string}
39
+ */
40
+ export function rowLabel(entry, groupBy) {
41
+ if (groupBy === 'tag') return entry.tagValue ?? entry.serviceName
42
+ if (groupBy === 'both') return `${entry.serviceName} / ${entry.tagValue ?? '(untagged)'}`
43
+ return entry.serviceName
44
+ }
45
+
34
46
  /**
35
47
  * Format cost entries as a printable table string.
36
48
  * @param {AWSCostEntry[]} entries
37
- * @param {string} serviceName
49
+ * @param {string} label - Display label for the header
50
+ * @param {CostGroupMode} [groupBy] - Grouping mode (default: 'service')
38
51
  * @returns {string}
39
52
  */
40
- export function formatCostTable(entries, serviceName) {
53
+ export function formatCostTable(entries, label, groupBy = 'service') {
41
54
  const total = calculateTotal(entries)
42
55
  const rows = entries
43
56
  .sort((a, b) => b.amount - a.amount)
44
- .map((e) => ` ${e.serviceName.padEnd(40)} ${formatCurrency(e.amount)}`)
57
+ .map((e) => ` ${rowLabel(e, groupBy).padEnd(40)} ${formatCurrency(e.amount)}`)
45
58
  .join('\n')
46
59
  const divider = '─'.repeat(50)
47
60
  return [
48
- `Costs for: ${serviceName}`,
61
+ `Costs for: ${label}`,
49
62
  divider,
50
63
  rows,
51
64
  divider,
package/src/help.js CHANGED
@@ -58,14 +58,17 @@ const CATEGORIES = [
58
58
  {
59
59
  title: 'Tasks (ClickUp)',
60
60
  cmds: [
61
- { id: 'tasks:list', hint: '[--status] [--search]' },
62
- { id: 'tasks:today', hint: '' },
61
+ { id: 'tasks:list', hint: '[--status] [--search]' },
62
+ { id: 'tasks:today', hint: '' },
63
+ { id: 'tasks:assigned', hint: '[--status] [--search]' },
63
64
  ],
64
65
  },
65
66
  {
66
67
  title: 'Cloud & Costi',
67
68
  cmds: [
68
- { id: 'costs:get', hint: '[--period] [--profile]' },
69
+ { id: 'costs:get', hint: '[SERVICE] [--period] [--group-by] [--tag-key]' },
70
+ { id: 'costs:trend', hint: '[--group-by] [--tag-key] [--line]' },
71
+ { id: 'logs', hint: '[--group] [--filter] [--since] [--limit] [--region]' },
69
72
  ],
70
73
  },
71
74
  {
@@ -91,30 +94,12 @@ const CATEGORIES = [
91
94
  { id: 'doctor', hint: '' },
92
95
  { id: 'auth:login', hint: '' },
93
96
  { id: 'whoami', hint: '' },
97
+ { id: 'welcome', hint: '' },
94
98
  { id: 'upgrade', hint: '' },
95
99
  ],
96
100
  },
97
101
  ]
98
102
 
99
- // ─── Example commands shown at bottom of root help ──────────────────────────
100
- const EXAMPLES = [
101
- { cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
102
- { cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
103
- { cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
104
- { cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
105
- { cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
106
- { cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
107
- { cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
108
- { cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
109
- { cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
110
- { cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
111
- { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
112
- { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
113
- { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
114
- { cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
115
- { cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
116
- ]
117
-
118
103
  // ─── Help class ─────────────────────────────────────────────────────────────
119
104
 
120
105
  /**
@@ -164,14 +149,41 @@ export default class CustomHelp extends Help {
164
149
 
165
150
  // ─── Private helpers ──────────────────────────────────────────────────────
166
151
 
167
- /**
168
- * Build the full categorized root help layout.
169
- * @returns {string}
170
- */
152
+ /**
153
+ * Build the full categorized root help layout.
154
+ * @returns {string}
155
+ */
171
156
  #buildRootLayout() {
172
157
  /** @type {Map<string, import('@oclif/core').Command.Cached>} */
173
158
  const cmdMap = new Map(this.config.commands.map((c) => [c.id, c]))
174
159
 
160
+ /** @type {Array<{cmd: string, note: string}>} */
161
+ const EXAMPLES = [
162
+ { cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
163
+ { cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
164
+ { cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
165
+ { cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
166
+ { cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
167
+ { cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
168
+ { cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
169
+ { cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
170
+ { cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
171
+ { cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
172
+ { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
173
+ { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
174
+ { cmd: 'dvmi tasks today', note: 'Task in lavorazione oggi' },
175
+ { cmd: 'dvmi costs get --period mtd', note: 'Costi AWS mese corrente per servizio' },
176
+ { cmd: 'dvmi costs get --group-by tag --tag-key env', note: 'Costi raggruppati per tag env' },
177
+ { cmd: 'dvmi costs trend --line', note: 'Trend costi 2 mesi (grafico lineare)' },
178
+ { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
179
+ { cmd: 'dvmi logs', note: 'Sfoglia log CloudWatch in modo interattivo' },
180
+ { cmd: 'dvmi logs --group /aws/lambda/my-fn --since 24h', note: 'Log Lambda ultimi 24h' },
181
+ { cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group' },
182
+ { cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
183
+ { cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
184
+ { cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata' },
185
+ ]
186
+
175
187
  const lines = []
176
188
 
177
189
  // ── Usage ──────────────────────────────────────────────────────────────