devvami 1.4.2 → 1.5.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 (96) hide show
  1. package/README.md +7 -0
  2. package/oclif.manifest.json +129 -89
  3. package/package.json +2 -1
  4. package/src/commands/auth/login.js +20 -16
  5. package/src/commands/changelog.js +12 -12
  6. package/src/commands/costs/get.js +14 -24
  7. package/src/commands/costs/trend.js +13 -24
  8. package/src/commands/create/repo.js +72 -54
  9. package/src/commands/docs/list.js +29 -25
  10. package/src/commands/docs/projects.js +58 -24
  11. package/src/commands/docs/read.js +56 -39
  12. package/src/commands/docs/search.js +37 -25
  13. package/src/commands/doctor.js +37 -35
  14. package/src/commands/dotfiles/add.js +51 -39
  15. package/src/commands/dotfiles/setup.js +62 -33
  16. package/src/commands/dotfiles/status.js +18 -18
  17. package/src/commands/dotfiles/sync.js +62 -46
  18. package/src/commands/init.js +143 -132
  19. package/src/commands/logs/index.js +10 -16
  20. package/src/commands/open.js +12 -12
  21. package/src/commands/pipeline/logs.js +8 -11
  22. package/src/commands/pipeline/rerun.js +21 -16
  23. package/src/commands/pipeline/status.js +28 -24
  24. package/src/commands/pr/create.js +40 -27
  25. package/src/commands/pr/detail.js +9 -7
  26. package/src/commands/pr/review.js +18 -19
  27. package/src/commands/pr/status.js +27 -21
  28. package/src/commands/prompts/browse.js +15 -15
  29. package/src/commands/prompts/download.js +15 -16
  30. package/src/commands/prompts/install-speckit.js +11 -12
  31. package/src/commands/prompts/list.js +12 -12
  32. package/src/commands/prompts/run.js +16 -19
  33. package/src/commands/repo/list.js +57 -41
  34. package/src/commands/search.js +20 -18
  35. package/src/commands/security/setup.js +38 -34
  36. package/src/commands/sync-config-ai/index.js +143 -0
  37. package/src/commands/tasks/assigned.js +43 -33
  38. package/src/commands/tasks/list.js +43 -33
  39. package/src/commands/tasks/today.js +32 -30
  40. package/src/commands/upgrade.js +18 -17
  41. package/src/commands/vuln/detail.js +8 -8
  42. package/src/commands/vuln/scan.js +39 -20
  43. package/src/commands/vuln/search.js +23 -18
  44. package/src/commands/welcome.js +2 -2
  45. package/src/commands/whoami.js +19 -23
  46. package/src/formatters/ai-config.js +127 -0
  47. package/src/formatters/charts.js +6 -23
  48. package/src/formatters/cost.js +1 -7
  49. package/src/formatters/dotfiles.js +48 -19
  50. package/src/formatters/markdown.js +11 -6
  51. package/src/formatters/openapi.js +7 -9
  52. package/src/formatters/prompts.js +69 -78
  53. package/src/formatters/security.js +2 -2
  54. package/src/formatters/status.js +1 -1
  55. package/src/formatters/table.js +1 -3
  56. package/src/formatters/vuln.js +33 -20
  57. package/src/help.js +162 -164
  58. package/src/hooks/init.js +1 -3
  59. package/src/hooks/postrun.js +5 -7
  60. package/src/index.js +1 -1
  61. package/src/services/ai-config-store.js +318 -0
  62. package/src/services/ai-env-deployer.js +444 -0
  63. package/src/services/ai-env-scanner.js +242 -0
  64. package/src/services/audit-detector.js +2 -2
  65. package/src/services/audit-runner.js +40 -31
  66. package/src/services/auth.js +9 -9
  67. package/src/services/awesome-copilot.js +7 -4
  68. package/src/services/aws-costs.js +22 -22
  69. package/src/services/clickup.js +26 -26
  70. package/src/services/cloudwatch-logs.js +5 -9
  71. package/src/services/config.js +13 -13
  72. package/src/services/docs.js +19 -20
  73. package/src/services/dotfiles.js +149 -51
  74. package/src/services/github.js +22 -24
  75. package/src/services/nvd.js +21 -31
  76. package/src/services/platform.js +2 -2
  77. package/src/services/prompts.js +23 -35
  78. package/src/services/security.js +135 -61
  79. package/src/services/shell.js +4 -4
  80. package/src/services/skills-sh.js +3 -9
  81. package/src/services/speckit.js +4 -7
  82. package/src/services/version-check.js +10 -10
  83. package/src/types.js +85 -0
  84. package/src/utils/aws-vault.js +18 -41
  85. package/src/utils/banner.js +5 -7
  86. package/src/utils/errors.js +42 -46
  87. package/src/utils/frontmatter.js +4 -4
  88. package/src/utils/gradient.js +18 -16
  89. package/src/utils/open-browser.js +3 -3
  90. package/src/utils/tui/form.js +1006 -0
  91. package/src/utils/tui/modal.js +15 -14
  92. package/src/utils/tui/navigable-table.js +16 -16
  93. package/src/utils/tui/tab-tui.js +800 -0
  94. package/src/utils/typewriter.js +3 -3
  95. package/src/utils/welcome.js +18 -21
  96. package/src/validators/repo-name.js +2 -2
@@ -1,12 +1,18 @@
1
- import { Command, Flags } from '@oclif/core'
2
- import { writeFile } from 'node:fs/promises'
1
+ import {Command, Flags} from '@oclif/core'
2
+ import {writeFile} from 'node:fs/promises'
3
3
  import ora from 'ora'
4
4
  import chalk from 'chalk'
5
- import { detectEcosystems, supportedEcosystemsMessage } from '../../services/audit-detector.js'
6
- import { runAudit, summarizeFindings, filterBySeverity } from '../../services/audit-runner.js'
7
- import { formatFindingsTable, formatScanSummary, formatMarkdownReport, truncate, colorSeverity } from '../../formatters/vuln.js'
8
- import { getCveDetail } from '../../services/nvd.js'
9
- import { startInteractiveTable } from '../../utils/tui/navigable-table.js'
5
+ import {detectEcosystems, supportedEcosystemsMessage} from '../../services/audit-detector.js'
6
+ import {runAudit, summarizeFindings, filterBySeverity} from '../../services/audit-runner.js'
7
+ import {
8
+ formatFindingsTable,
9
+ formatScanSummary,
10
+ formatMarkdownReport,
11
+ truncate,
12
+ colorSeverity,
13
+ } from '../../formatters/vuln.js'
14
+ import {getCveDetail} from '../../services/nvd.js'
15
+ import {startInteractiveTable} from '../../utils/tui/navigable-table.js'
10
16
 
11
17
  // Minimum terminal rows required to show the interactive TUI (same threshold as vuln search)
12
18
  const MIN_TTY_ROWS = 6
@@ -49,9 +55,9 @@ export default class VulnScan extends Command {
49
55
  }
50
56
 
51
57
  async run() {
52
- const { flags } = await this.parse(VulnScan)
58
+ const {flags} = await this.parse(VulnScan)
53
59
  const isJson = flags.json
54
- const { severity, 'no-fail': noFail, report } = flags
60
+ const {severity, 'no-fail': noFail, report} = flags
55
61
 
56
62
  const projectPath = process.env.DVMI_SCAN_DIR ?? process.cwd()
57
63
  const scanDate = new Date().toISOString()
@@ -66,8 +72,8 @@ export default class VulnScan extends Command {
66
72
  scanDate,
67
73
  ecosystems: [],
68
74
  findings: [],
69
- summary: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0, total: 0 },
70
- errors: [{ ecosystem: 'none', message: 'No supported package manager detected.' }],
75
+ summary: {critical: 0, high: 0, medium: 0, low: 0, unknown: 0, total: 0},
76
+ errors: [{ecosystem: 'none', message: 'No supported package manager detected.'}],
71
77
  }
72
78
  }
73
79
 
@@ -99,11 +105,11 @@ export default class VulnScan extends Command {
99
105
  for (const eco of ecosystems) {
100
106
  const spinner = isJson ? null : ora(` Scanning ${eco.name} dependencies...`).start()
101
107
 
102
- const { findings, error } = await runAudit(eco)
108
+ const {findings, error} = await runAudit(eco)
103
109
 
104
110
  if (error) {
105
111
  spinner?.fail(` Scanning ${eco.name} dependencies... failed`)
106
- errors.push({ ecosystem: eco.name, message: error })
112
+ errors.push({ecosystem: eco.name, message: error})
107
113
  } else {
108
114
  spinner?.succeed(` Scanning ${eco.name} dependencies... done`)
109
115
  allFindings.push(...findings)
@@ -170,25 +176,38 @@ export default class VulnScan extends Command {
170
176
 
171
177
  /** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */
172
178
  const columns = [
173
- { header: 'Package', key: 'pkg', width: COL_WIDTHS.pkg },
174
- { header: 'Version', key: 'version', width: COL_WIDTHS.version },
175
- { header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) },
176
- { header: 'CVE', key: 'cve', width: COL_WIDTHS.cve, colorize: (v) => (v !== '—' ? chalk.cyan(v) : chalk.gray(v)) },
177
- { header: 'Title', key: 'title', width: titleWidth },
179
+ {header: 'Package', key: 'pkg', width: COL_WIDTHS.pkg},
180
+ {header: 'Version', key: 'version', width: COL_WIDTHS.version},
181
+ {header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v)},
182
+ {
183
+ header: 'CVE',
184
+ key: 'cve',
185
+ width: COL_WIDTHS.cve,
186
+ colorize: (v) => (v !== '—' ? chalk.cyan(v) : chalk.gray(v)),
187
+ },
188
+ {header: 'Title', key: 'title', width: titleWidth},
178
189
  ]
179
190
 
180
191
  await startInteractiveTable(rows, columns, heading, filteredFindings.length, getCveDetail)
181
192
  } else {
182
193
  // Non-TTY fallback: static table + summary (unchanged from pre-TUI behaviour)
183
194
  if (filteredFindings.length > 0) {
184
- this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`))
195
+ this.log(
196
+ chalk.bold(
197
+ ` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`,
198
+ ),
199
+ )
185
200
  this.log('')
186
201
  this.log(formatFindingsTable(filteredFindings))
187
202
  this.log('')
188
203
  this.log(chalk.bold(' Summary'))
189
204
  this.log(formatScanSummary(summary))
190
205
  this.log('')
191
- this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`))
206
+ this.log(
207
+ chalk.yellow(
208
+ ` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`,
209
+ ),
210
+ )
192
211
  }
193
212
  }
194
213
 
@@ -1,10 +1,10 @@
1
- import { Command, Args, Flags } from '@oclif/core'
1
+ import {Command, Args, Flags} from '@oclif/core'
2
2
  import ora from 'ora'
3
3
  import chalk from 'chalk'
4
- import { searchCves, getCveDetail } from '../../services/nvd.js'
5
- import { formatCveSearchTable, colorSeverity, formatScore, formatDate, truncate } from '../../formatters/vuln.js'
6
- import { startInteractiveTable } from '../../utils/tui/navigable-table.js'
7
- import { ValidationError } from '../../utils/errors.js'
4
+ import {searchCves, getCveDetail} from '../../services/nvd.js'
5
+ import {formatCveSearchTable, colorSeverity, formatScore, formatDate, truncate} from '../../formatters/vuln.js'
6
+ import {startInteractiveTable} from '../../utils/tui/navigable-table.js'
7
+ import {ValidationError} from '../../utils/errors.js'
8
8
 
9
9
  // Minimum terminal rows required to show the interactive TUI
10
10
  const MIN_TTY_ROWS = 6
@@ -33,7 +33,10 @@ export default class VulnSearch extends Command {
33
33
  static enableJsonFlag = true
34
34
 
35
35
  static args = {
36
- keyword: Args.string({ description: 'Product, library, or keyword to search for (optional — omit to see all recent CVEs)', required: false }),
36
+ keyword: Args.string({
37
+ description: 'Product, library, or keyword to search for (optional — omit to see all recent CVEs)',
38
+ required: false,
39
+ }),
37
40
  }
38
41
 
39
42
  static flags = {
@@ -55,11 +58,11 @@ export default class VulnSearch extends Command {
55
58
  }
56
59
 
57
60
  async run() {
58
- const { args, flags } = await this.parse(VulnSearch)
61
+ const {args, flags} = await this.parse(VulnSearch)
59
62
  const isJson = flags.json
60
63
 
61
- const { keyword } = args
62
- const { days, severity, limit } = flags
64
+ const {keyword} = args
65
+ const {days, severity, limit} = flags
63
66
 
64
67
  if (days < 1 || days > 120) {
65
68
  throw new ValidationError(
@@ -75,13 +78,15 @@ export default class VulnSearch extends Command {
75
78
  )
76
79
  }
77
80
 
78
- const spinner = isJson ? null : ora(keyword ? `Searching NVD for "${keyword}"...` : `Fetching recent CVEs (last ${days} days)...`).start()
81
+ const spinner = isJson
82
+ ? null
83
+ : ora(keyword ? `Searching NVD for "${keyword}"...` : `Fetching recent CVEs (last ${days} days)...`).start()
79
84
 
80
85
  try {
81
- const { results, totalResults } = await searchCves({ keyword, days, severity, limit })
86
+ const {results, totalResults} = await searchCves({keyword, days, severity, limit})
82
87
  spinner?.stop()
83
88
 
84
- const result = { keyword: keyword ?? null, days, severity: severity ?? null, totalResults, results }
89
+ const result = {keyword: keyword ?? null, days, severity: severity ?? null, totalResults, results}
85
90
 
86
91
  if (isJson) return result
87
92
 
@@ -108,12 +113,12 @@ export default class VulnSearch extends Command {
108
113
 
109
114
  /** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */
110
115
  const columns = [
111
- { header: 'CVE ID', key: 'id', width: COL_WIDTHS.id, colorize: (v) => chalk.cyan(v) },
112
- { header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) },
113
- { header: 'Score', key: 'score', width: COL_WIDTHS.score },
114
- { header: 'Published', key: 'published', width: COL_WIDTHS.published },
115
- { header: 'Description', key: 'description', width: descWidth },
116
- { header: 'Reference', key: 'reference', width: COL_WIDTHS.reference },
116
+ {header: 'CVE ID', key: 'id', width: COL_WIDTHS.id, colorize: (v) => chalk.cyan(v)},
117
+ {header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v)},
118
+ {header: 'Score', key: 'score', width: COL_WIDTHS.score},
119
+ {header: 'Published', key: 'published', width: COL_WIDTHS.published},
120
+ {header: 'Description', key: 'description', width: descWidth},
121
+ {header: 'Reference', key: 'reference', width: COL_WIDTHS.reference},
117
122
  ]
118
123
 
119
124
  await startInteractiveTable(rows, columns, heading, totalResults, getCveDetail)
@@ -1,5 +1,5 @@
1
- import { Command } from '@oclif/core'
2
- import { printWelcomeScreen } from '../utils/welcome.js'
1
+ import {Command} from '@oclif/core'
2
+ import {printWelcomeScreen} from '../utils/welcome.js'
3
3
 
4
4
  /**
5
5
  * Display the dvmi cyberpunk mission dashboard.
@@ -1,66 +1,62 @@
1
- import { Command } from '@oclif/core'
1
+ import {Command} from '@oclif/core'
2
2
  import chalk from 'chalk'
3
3
  import ora from 'ora'
4
- import { createOctokit } from '../services/github.js'
5
- import { checkAWSAuth } from '../services/auth.js'
6
- import { getCurrentVersion } from '../services/version-check.js'
7
- import { CONFIG_PATH, loadConfig } from '../services/config.js'
8
- import { getUser, isAuthenticated } from '../services/clickup.js'
4
+ import {createOctokit} from '../services/github.js'
5
+ import {checkAWSAuth} from '../services/auth.js'
6
+ import {getCurrentVersion} from '../services/version-check.js'
7
+ import {CONFIG_PATH, loadConfig} from '../services/config.js'
8
+ import {getUser, isAuthenticated} from '../services/clickup.js'
9
9
 
10
10
  export default class Whoami extends Command {
11
11
  static description = 'Mostra la tua identita su GitHub, AWS e ClickUp'
12
12
 
13
- static examples = [
14
- '<%= config.bin %> whoami',
15
- '<%= config.bin %> whoami --json',
16
- ]
13
+ static examples = ['<%= config.bin %> whoami', '<%= config.bin %> whoami --json']
17
14
 
18
15
  static enableJsonFlag = true
19
16
 
20
17
  async run() {
21
- const { flags } = await this.parse(Whoami)
18
+ const {flags} = await this.parse(Whoami)
22
19
  const isJson = flags.json
23
20
 
24
- const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Fetching identity...') }).start()
21
+ const spinner = isJson
22
+ ? null
23
+ : ora({spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Fetching identity...')}).start()
25
24
 
26
25
  const [ghResult, awsResult, version, cuResult] = await Promise.allSettled([
27
26
  (async () => {
28
27
  const octokit = await createOctokit()
29
- const { data: user } = await octokit.rest.users.getAuthenticated()
30
- return { username: user.login, name: user.name ?? '', org: '', teams: [] }
28
+ const {data: user} = await octokit.rest.users.getAuthenticated()
29
+ return {username: user.login, name: user.name ?? '', org: '', teams: []}
31
30
  })(),
32
31
  checkAWSAuth(),
33
32
  getCurrentVersion(),
34
33
  (async () => {
35
34
  if (!(await isAuthenticated())) return null
36
35
  const [user, config] = await Promise.all([getUser(), loadConfig()])
37
- return { username: user.username, teamName: config.clickup?.teamName ?? null }
36
+ return {username: user.username, teamName: config.clickup?.teamName ?? null}
38
37
  })(),
39
38
  ])
40
39
 
41
40
  spinner?.stop()
42
41
 
43
- const github =
44
- ghResult.status === 'fulfilled'
45
- ? ghResult.value
46
- : { username: null, error: '[NOT AUTHENTICATED]' }
42
+ const github = ghResult.status === 'fulfilled' ? ghResult.value : {username: null, error: '[NOT AUTHENTICATED]'}
47
43
 
48
44
  const aws =
49
45
  awsResult.status === 'fulfilled' && awsResult.value.authenticated
50
- ? { accountId: awsResult.value.account, role: awsResult.value.role }
51
- : { accountId: null, error: '[NOT AUTHENTICATED]' }
46
+ ? {accountId: awsResult.value.account, role: awsResult.value.role}
47
+ : {accountId: null, error: '[NOT AUTHENTICATED]'}
52
48
 
53
49
  const clickup =
54
50
  cuResult.status === 'fulfilled' && cuResult.value
55
51
  ? cuResult.value
56
- : { username: null, teamName: null, error: '[NOT AUTHENTICATED]' }
52
+ : {username: null, teamName: null, error: '[NOT AUTHENTICATED]'}
57
53
 
58
54
  const cli = {
59
55
  version: version.status === 'fulfilled' ? version.value : '?',
60
56
  configPath: CONFIG_PATH,
61
57
  }
62
58
 
63
- const result = { github, aws, clickup, cli }
59
+ const result = {github, aws, clickup, cli}
64
60
 
65
61
  if (isJson) return result
66
62
 
@@ -0,0 +1,127 @@
1
+ import chalk from 'chalk'
2
+
3
+ /** @import { DetectedEnvironment, CategoryEntry } from '../types.js' */
4
+
5
+ // ──────────────────────────────────────────────────────────────────────────────
6
+ // Internal helpers
7
+ // ──────────────────────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Pad a string to a fixed width, truncating with '…' if needed.
11
+ * @param {string} str
12
+ * @param {number} width
13
+ * @returns {string}
14
+ */
15
+ function padCell(str, width) {
16
+ if (!str) str = ''
17
+ if (str.length > width) return str.slice(0, width - 1) + '…'
18
+ return str.padEnd(width)
19
+ }
20
+
21
+ // ──────────────────────────────────────────────────────────────────────────────
22
+ // Environments table formatter
23
+ // ──────────────────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Format a list of detected environments as a table string for display in the TUI.
27
+ * Columns: Environment (name), Status, Scope, MCPs, Commands, Skills, Agents
28
+ * @param {DetectedEnvironment[]} detectedEnvs
29
+ * @param {number} [termCols]
30
+ * @returns {string[]} Array of formatted lines (no ANSI clear/home)
31
+ */
32
+ export function formatEnvironmentsTable(detectedEnvs, termCols = 120) {
33
+ const COL_ENV = 22
34
+ const COL_STATUS = 24
35
+ const COL_SCOPE = 8
36
+ const COL_COUNT = 9
37
+
38
+ const headerParts = [
39
+ chalk.bold.white(padCell('Environment', COL_ENV)),
40
+ chalk.bold.white(padCell('Status', COL_STATUS)),
41
+ chalk.bold.white(padCell('Scope', COL_SCOPE)),
42
+ chalk.bold.white(padCell('MCPs', COL_COUNT)),
43
+ chalk.bold.white(padCell('Commands', COL_COUNT)),
44
+ chalk.bold.white(padCell('Skills', COL_COUNT)),
45
+ chalk.bold.white(padCell('Agents', COL_COUNT)),
46
+ ]
47
+
48
+ const dividerWidth = COL_ENV + COL_STATUS + COL_SCOPE + COL_COUNT * 4 + 6 * 2
49
+ const lines = []
50
+ lines.push(headerParts.join(' '))
51
+ lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth))))
52
+
53
+ for (const env of detectedEnvs) {
54
+ const hasUnreadable = env.unreadable.length > 0
55
+ const statusText = hasUnreadable ? 'Detected (unreadable)' : 'Detected'
56
+ const statusStr = hasUnreadable
57
+ ? chalk.yellow(padCell(statusText, COL_STATUS))
58
+ : chalk.green(padCell(statusText, COL_STATUS))
59
+ const scopeStr = padCell(env.scope ?? 'project', COL_SCOPE)
60
+
61
+ const mcpStr = padCell(String(env.counts.mcp), COL_COUNT)
62
+ const cmdStr = padCell(String(env.counts.command), COL_COUNT)
63
+ const skillStr = env.supportedCategories.includes('skill')
64
+ ? padCell(String(env.counts.skill), COL_COUNT)
65
+ : padCell('—', COL_COUNT)
66
+ const agentStr = env.supportedCategories.includes('agent')
67
+ ? padCell(String(env.counts.agent), COL_COUNT)
68
+ : padCell('—', COL_COUNT)
69
+
70
+ lines.push([padCell(env.name, COL_ENV), statusStr, scopeStr, mcpStr, cmdStr, skillStr, agentStr].join(' '))
71
+ }
72
+
73
+ return lines
74
+ }
75
+
76
+ // ──────────────────────────────────────────────────────────────────────────────
77
+ // Categories table formatter
78
+ // ──────────────────────────────────────────────────────────────────────────────
79
+
80
+ /** @type {Record<string, string>} */
81
+ const ENV_SHORT_NAMES = {
82
+ 'vscode-copilot': 'VSCode',
83
+ 'claude-code': 'Claude',
84
+ opencode: 'OpenCode',
85
+ 'gemini-cli': 'Gemini',
86
+ 'copilot-cli': 'Copilot',
87
+ }
88
+
89
+ /**
90
+ * Format a list of category entries as a table string for display in the TUI.
91
+ * Columns: Name, Type, Status, Environments
92
+ * @param {CategoryEntry[]} entries
93
+ * @param {number} [termCols]
94
+ * @returns {string[]} Array of formatted lines (no ANSI clear/home)
95
+ */
96
+ export function formatCategoriesTable(entries, termCols = 120) {
97
+ const COL_NAME = 24
98
+ const COL_TYPE = 9
99
+ const COL_STATUS = 10
100
+ const COL_ENVS = 36
101
+
102
+ const headerParts = [
103
+ chalk.bold.white(padCell('Name', COL_NAME)),
104
+ chalk.bold.white(padCell('Type', COL_TYPE)),
105
+ chalk.bold.white(padCell('Status', COL_STATUS)),
106
+ chalk.bold.white(padCell('Environments', COL_ENVS)),
107
+ ]
108
+
109
+ const dividerWidth = COL_NAME + COL_TYPE + COL_STATUS + COL_ENVS + 3 * 2
110
+ const lines = []
111
+ lines.push(headerParts.join(' '))
112
+ lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth))))
113
+
114
+ for (const entry of entries) {
115
+ const statusStr = entry.active
116
+ ? chalk.green(padCell('Active', COL_STATUS))
117
+ : chalk.dim(padCell('Inactive', COL_STATUS))
118
+
119
+ const envNames = entry.environments.map((id) => ENV_SHORT_NAMES[id] ?? id).join(', ')
120
+
121
+ lines.push(
122
+ [padCell(entry.name, COL_NAME), padCell(entry.type, COL_TYPE), statusStr, padCell(envNames, COL_ENVS)].join(' '),
123
+ )
124
+ }
125
+
126
+ return lines
127
+ }
@@ -3,16 +3,7 @@ import chalk from 'chalk'
3
3
  /** @import { ChartSeries } from '../types.js' */
4
4
 
5
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
- ]
6
+ const PALETTE = [chalk.cyan, chalk.yellow, chalk.green, chalk.magenta, chalk.blue, chalk.red, chalk.white, chalk.gray]
16
7
 
17
8
  /**
18
9
  * Get the terminal width, falling back to 80 columns.
@@ -54,9 +45,7 @@ export function barChart(series, options = {}) {
54
45
 
55
46
  // Combine all series into per-label totals for scaling
56
47
  const allLabels = series[0]?.labels ?? []
57
- const totals = allLabels.map((_, i) =>
58
- series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0),
59
- )
48
+ const totals = allLabels.map((_, i) => series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0))
60
49
  const maxTotal = Math.max(...totals, 0)
61
50
 
62
51
  const lines = []
@@ -71,7 +60,7 @@ export function barChart(series, options = {}) {
71
60
 
72
61
  // Build chart column by column (one char per day)
73
62
  // We render it as a 2D grid: rows = height levels, cols = days
74
- const grid = Array.from({ length: BAR_HEIGHT }, () => Array(allLabels.length).fill(' '))
63
+ const grid = Array.from({length: BAR_HEIGHT}, () => Array(allLabels.length).fill(' '))
75
64
 
76
65
  for (let col = 0; col < allLabels.length; col++) {
77
66
  const total = totals[col]
@@ -110,9 +99,7 @@ export function barChart(series, options = {}) {
110
99
 
111
100
  // X-axis date labels (sample every ~10 positions)
112
101
  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"
102
+ const xLabels = allLabels.filter((_, i) => i % step === 0).map((l) => l.slice(5)) // "MM-DD"
116
103
  lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
117
104
 
118
105
  // Legend for multi-series
@@ -156,9 +143,7 @@ export function lineChart(series, options = {}) {
156
143
  }
157
144
 
158
145
  // Build a 2D canvas: rows = chartHeight, cols = chartWidth
159
- const canvas = Array.from({ length: chartHeight }, () =>
160
- Array(chartWidth).fill(' '),
161
- )
146
+ const canvas = Array.from({length: chartHeight}, () => Array(chartWidth).fill(' '))
162
147
 
163
148
  const step = Math.max(1, Math.ceil(allLabels.length / chartWidth))
164
149
 
@@ -186,9 +171,7 @@ export function lineChart(series, options = {}) {
186
171
 
187
172
  // X-axis date labels
188
173
  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))
174
+ const xLabels = allLabels.filter((_, i) => i % xStep === 0).map((l) => l.slice(5))
192
175
  lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
193
176
 
194
177
  // Legend
@@ -57,11 +57,5 @@ export function formatCostTable(entries, label, groupBy = 'service') {
57
57
  .map((e) => ` ${rowLabel(e, groupBy).padEnd(40)} ${formatCurrency(e.amount)}`)
58
58
  .join('\n')
59
59
  const divider = '─'.repeat(50)
60
- return [
61
- `Costs for: ${label}`,
62
- divider,
63
- rows,
64
- divider,
65
- ` ${'Total'.padEnd(40)} ${formatCurrency(total)}`,
66
- ].join('\n')
60
+ return [`Costs for: ${label}`, divider, rows, divider, ` ${'Total'.padEnd(40)} ${formatCurrency(total)}`].join('\n')
67
61
  }
@@ -21,7 +21,9 @@ export function formatDotfilesSetup(result) {
21
21
  BORDER,
22
22
  '',
23
23
  chalk.bold(` Platform: ${chalk.cyan(result.platform)}`),
24
- chalk.bold(` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`),
24
+ chalk.bold(
25
+ ` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`,
26
+ ),
25
27
  ]
26
28
 
27
29
  if (result.sourceDir) {
@@ -79,11 +81,41 @@ export function formatDotfilesSummary(summary) {
79
81
  */
80
82
  function inferCategory(filePath) {
81
83
  const lower = filePath.toLowerCase()
82
- if (lower.includes('.ssh') || lower.includes('.gnupg') || lower.includes('gpg') || lower.includes('secret') || lower.includes('credential') || lower.includes('token') || lower.includes('password')) return 'Security'
84
+ if (
85
+ lower.includes('.ssh') ||
86
+ lower.includes('.gnupg') ||
87
+ lower.includes('gpg') ||
88
+ lower.includes('secret') ||
89
+ lower.includes('credential') ||
90
+ lower.includes('token') ||
91
+ lower.includes('password')
92
+ )
93
+ return 'Security'
83
94
  if (lower.includes('.gitconfig') || lower.includes('.gitignore') || lower.includes('.git')) return 'Git'
84
- if (lower.includes('zshrc') || lower.includes('bashrc') || lower.includes('bash_profile') || lower.includes('zprofile') || lower.includes('fish')) return 'Shell'
85
- if (lower.includes('vim') || lower.includes('nvim') || lower.includes('emacs') || lower.includes('vscode') || lower.includes('cursor')) return 'Editor'
86
- if (lower.includes('brew') || lower.includes('npm') || lower.includes('yarn') || lower.includes('pip') || lower.includes('gem')) return 'Package'
95
+ if (
96
+ lower.includes('zshrc') ||
97
+ lower.includes('bashrc') ||
98
+ lower.includes('bash_profile') ||
99
+ lower.includes('zprofile') ||
100
+ lower.includes('fish')
101
+ )
102
+ return 'Shell'
103
+ if (
104
+ lower.includes('vim') ||
105
+ lower.includes('nvim') ||
106
+ lower.includes('emacs') ||
107
+ lower.includes('vscode') ||
108
+ lower.includes('cursor')
109
+ )
110
+ return 'Editor'
111
+ if (
112
+ lower.includes('brew') ||
113
+ lower.includes('npm') ||
114
+ lower.includes('yarn') ||
115
+ lower.includes('pip') ||
116
+ lower.includes('gem')
117
+ )
118
+ return 'Package'
87
119
  return 'Other'
88
120
  }
89
121
 
@@ -167,13 +199,7 @@ export function formatDotfilesStatus(result) {
167
199
  * @returns {string}
168
200
  */
169
201
  export function formatDotfilesAdd(result) {
170
- const lines = [
171
- '',
172
- BORDER,
173
- chalk.bold(' Dotfiles Add — Summary'),
174
- BORDER,
175
- '',
176
- ]
202
+ const lines = ['', BORDER, chalk.bold(' Dotfiles Add — Summary'), BORDER, '']
177
203
 
178
204
  if (result.added.length > 0) {
179
205
  lines.push(chalk.bold(` Added (${result.added.length}):`))
@@ -219,12 +245,13 @@ export function formatDotfilesAdd(result) {
219
245
  * @returns {string}
220
246
  */
221
247
  export function formatDotfilesSync(result) {
222
- const actionLabel = {
223
- push: 'Push',
224
- pull: 'Pull',
225
- 'init-remote': 'Remote Setup',
226
- skipped: 'Skipped',
227
- }[result.action] ?? result.action
248
+ const actionLabel =
249
+ {
250
+ push: 'Push',
251
+ pull: 'Pull',
252
+ 'init-remote': 'Remote Setup',
253
+ skipped: 'Skipped',
254
+ }[result.action] ?? result.action
228
255
 
229
256
  const lines = [
230
257
  '',
@@ -233,7 +260,9 @@ export function formatDotfilesSync(result) {
233
260
  BORDER,
234
261
  '',
235
262
  chalk.white(` Action: ${chalk.cyan(actionLabel)}`),
236
- chalk.white(` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`),
263
+ chalk.white(
264
+ ` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`,
265
+ ),
237
266
  ]
238
267
 
239
268
  if (result.repo) {
@@ -1,6 +1,6 @@
1
- import { marked } from 'marked'
1
+ import {marked} from 'marked'
2
2
  import chalk from 'chalk'
3
- import { deflate } from 'pako'
3
+ import {deflate} from 'pako'
4
4
 
5
5
  // Custom terminal renderer — outputs ANSI-formatted text using chalk.
6
6
  // marked-terminal@7 is incompatible with all currently released versions of marked
@@ -30,7 +30,12 @@ const terminalRenderer = {
30
30
  return '\n' + lines.join('\n') + '\n\n'
31
31
  },
32
32
  blockquote(quote) {
33
- return quote.split('\n').map((l) => chalk.dim('│ ') + chalk.italic(l)).join('\n') + '\n'
33
+ return (
34
+ quote
35
+ .split('\n')
36
+ .map((l) => chalk.dim('│ ') + chalk.italic(l))
37
+ .join('\n') + '\n'
38
+ )
34
39
  },
35
40
  link(href, _title, text) {
36
41
  return `${text} ${chalk.dim(`(${href})`)}`
@@ -61,7 +66,7 @@ const terminalRenderer = {
61
66
  },
62
67
  }
63
68
 
64
- marked.use({ renderer: terminalRenderer })
69
+ marked.use({renderer: terminalRenderer})
65
70
 
66
71
  /**
67
72
  * Render a markdown string as ANSI-formatted terminal output.
@@ -95,14 +100,14 @@ export function extractMermaidBlocks(content) {
95
100
  export function toMermaidLiveUrl(diagramCode) {
96
101
  const state = JSON.stringify({
97
102
  code: diagramCode,
98
- mermaid: JSON.stringify({ theme: 'default' }),
103
+ mermaid: JSON.stringify({theme: 'default'}),
99
104
  updateDiagram: true,
100
105
  grid: true,
101
106
  panZoom: true,
102
107
  rough: false,
103
108
  })
104
109
  const data = new TextEncoder().encode(state)
105
- const compressed = deflate(data, { level: 9 })
110
+ const compressed = deflate(data, {level: 9})
106
111
  const encoded = Buffer.from(compressed).toString('base64url')
107
112
  return `https://mermaid.live/view#pako:${encoded}`
108
113
  }