@o/cli 0.16.18 → 0.16.19-canary.1790191946359

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,288 @@
1
+ /**
2
+ * skills command group - manage repository agent skills
3
+ */
4
+
5
+ import {
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readlinkSync,
12
+ rmSync,
13
+ rmdirSync,
14
+ symlinkSync,
15
+ unlinkSync,
16
+ } from 'node:fs'
17
+ import { join, relative, resolve } from 'node:path'
18
+
19
+ import { defineCommand } from 'citty'
20
+ import pc from 'picocolors'
21
+
22
+ // --- doc skills generation ---
23
+
24
+ const SKILL_PREFIX = 'takeout-'
25
+
26
+ function getSkillsDirs(cwd: string): string[] {
27
+ return [join(cwd, '.claude', 'skills'), join(cwd, '.agents', 'skills')]
28
+ }
29
+
30
+ function hasSkillFrontmatter(content: string): boolean {
31
+ if (!content.startsWith('---')) return false
32
+ const endIndex = content.indexOf('---', 3)
33
+ if (endIndex === -1) return false
34
+ const frontmatter = content.slice(3, endIndex)
35
+ return frontmatter.includes('name:') && frontmatter.includes('description:')
36
+ }
37
+
38
+ function isDevOnly(content: string): boolean {
39
+ if (!content.startsWith('---')) return false
40
+ const endIndex = content.indexOf('---', 3)
41
+ if (endIndex === -1) return false
42
+ const frontmatter = content.slice(3, endIndex)
43
+ return /\bdev:\s*true\b/.test(frontmatter)
44
+ }
45
+
46
+ // prefer a project's ./skills dir if it exists, else fall back to ./docs. lets
47
+ // consumers cleanly separate agent skills (frontmatter md) from plain docs.
48
+ function resolveLocalDocsDir(cwd: string): string {
49
+ const skillsDir = join(cwd, 'skills')
50
+ if (existsSync(skillsDir)) return skillsDir
51
+ return join(cwd, 'docs')
52
+ }
53
+
54
+ function collectAllDocs(cwd: string): Array<{ name: string; path: string }> {
55
+ const docs: Array<{ name: string; path: string }> = []
56
+
57
+ const localDocsDir = resolveLocalDocsDir(cwd)
58
+ if (existsSync(localDocsDir)) {
59
+ const files = readdirSync(localDocsDir).filter((f) => f.endsWith('.md'))
60
+ for (const file of files) {
61
+ const name = file.replace(/\.md$/, '')
62
+ docs.push({ name, path: join(localDocsDir, file) })
63
+ }
64
+ }
65
+
66
+ return docs
67
+ }
68
+
69
+ async function generateDocSkills(
70
+ cwd: string,
71
+ clean: boolean
72
+ ): Promise<{
73
+ symlinked: number
74
+ generated: number
75
+ unchanged: number
76
+ removed: number
77
+ skipped: number
78
+ }> {
79
+ const skillsDirs = getSkillsDirs(cwd)
80
+ const docs = collectAllDocs(cwd)
81
+ const localDocsDir = resolveLocalDocsDir(cwd)
82
+ const expectedSkillNames = new Set<string>()
83
+
84
+ if (docs.length === 0) {
85
+ console.info(pc.yellow('no documentation files found'))
86
+ } else {
87
+ console.info(pc.dim(`found ${docs.length} documentation files`))
88
+ }
89
+
90
+ for (const skillsDir of skillsDirs) {
91
+ if (clean && existsSync(skillsDir)) {
92
+ const existing = readdirSync(skillsDir)
93
+ for (const dir of existing) {
94
+ if (dir.startsWith(SKILL_PREFIX)) {
95
+ rmSync(join(skillsDir, dir), { recursive: true })
96
+ }
97
+ }
98
+ }
99
+
100
+ if (!existsSync(skillsDir)) {
101
+ mkdirSync(skillsDir, { recursive: true })
102
+ }
103
+ }
104
+
105
+ let symlinked = 0
106
+ let generated = 0
107
+ let unchanged = 0
108
+ let removed = 0
109
+ let skipped = 0
110
+ const isDev = !!process.env.IS_TAMAGUI_DEV
111
+
112
+ for (const doc of docs) {
113
+ const content = readFileSync(doc.path, 'utf-8')
114
+ if (isDevOnly(content) && !isDev) continue
115
+
116
+ const hasFrontmatter = hasSkillFrontmatter(content)
117
+
118
+ if (hasFrontmatter) {
119
+ const nameMatch = content.match(/^---\s*\nname:\s*([^\n]+)/m)
120
+ if (!nameMatch) continue
121
+
122
+ const skillName = nameMatch[1]!.trim()
123
+ expectedSkillNames.add(skillName)
124
+
125
+ for (const skillsDir of skillsDirs) {
126
+ const skillDir = join(skillsDir, skillName)
127
+ const skillFile = join(skillDir, 'SKILL.md')
128
+
129
+ if (!existsSync(skillDir)) {
130
+ mkdirSync(skillDir, { recursive: true })
131
+ }
132
+
133
+ const relativePath = relative(skillDir, doc.path)
134
+
135
+ let shouldCreate = true
136
+ try {
137
+ const stat = lstatSync(skillFile)
138
+ if (stat.isSymbolicLink() && existsSync(skillFile)) {
139
+ const existingContent = readFileSync(skillFile, 'utf-8')
140
+ if (existingContent === content) {
141
+ unchanged++
142
+ shouldCreate = false
143
+ }
144
+ }
145
+ if (shouldCreate) {
146
+ unlinkSync(skillFile)
147
+ }
148
+ } catch {
149
+ // nothing exists
150
+ }
151
+
152
+ if (!shouldCreate) continue
153
+
154
+ symlinkSync(relativePath, skillFile)
155
+ symlinked++
156
+
157
+ console.info(
158
+ ` ${pc.green('⟷')} ${skillName} ${pc.dim(`(${relative(cwd, skillsDir)})`)}`
159
+ )
160
+ }
161
+ } else {
162
+ if (!hasFrontmatter) {
163
+ skipped++
164
+ console.info(
165
+ ` ${pc.yellow('!')} skipped ${pc.dim(doc.name)} ${pc.dim('(missing skill frontmatter)')}`
166
+ )
167
+ continue
168
+ }
169
+ }
170
+ }
171
+
172
+ for (const skillsDir of skillsDirs) {
173
+ for (const dir of readdirSync(skillsDir)) {
174
+ if (expectedSkillNames.has(dir)) continue
175
+
176
+ const skillDir = join(skillsDir, dir)
177
+ const skillFile = join(skillDir, 'SKILL.md')
178
+
179
+ if (dir.startsWith(SKILL_PREFIX)) {
180
+ rmSync(skillDir, { recursive: true, force: true })
181
+ removed++
182
+ console.info(
183
+ ` ${pc.red('✕')} ${dir} ${pc.dim('(removed stale generated skill)')}`
184
+ )
185
+ continue
186
+ }
187
+
188
+ let shouldUnlink = false
189
+
190
+ try {
191
+ const stat = lstatSync(skillFile)
192
+ if (stat.isSymbolicLink()) {
193
+ const linkTarget = readlinkSync(skillFile)
194
+ const resolvedTarget = resolve(skillDir, linkTarget)
195
+ shouldUnlink = resolvedTarget.startsWith(`${localDocsDir}/`)
196
+ }
197
+ } catch {
198
+ // ignore unrelated skill directories
199
+ }
200
+
201
+ if (!shouldUnlink) continue
202
+
203
+ unlinkSync(skillFile)
204
+ if (readdirSync(skillDir).length === 0) {
205
+ rmdirSync(skillDir)
206
+ }
207
+ removed++
208
+ console.info(` ${pc.red('✕')} ${dir} ${pc.dim('(removed stale symlink)')}`)
209
+ }
210
+ }
211
+
212
+ return { symlinked, generated, unchanged, removed, skipped }
213
+ }
214
+
215
+ // --- commands ---
216
+
217
+ const generateCommand = defineCommand({
218
+ meta: {
219
+ name: 'generate',
220
+ description: 'Generate Claude Code and Codex skills from documentation',
221
+ },
222
+ args: {
223
+ clean: {
224
+ type: 'boolean',
225
+ description: 'Remove existing takeout-* skills before generating',
226
+ default: false,
227
+ },
228
+ 'skip-internal-docs': {
229
+ type: 'boolean',
230
+ description: 'Skip generating skills from internal documentation files',
231
+ default: false,
232
+ },
233
+ },
234
+ async run({ args }) {
235
+ const cwd = process.cwd()
236
+ const skillsDirs = getSkillsDirs(cwd)
237
+
238
+ console.info()
239
+ console.info(pc.bold(pc.cyan('Generate all skills')))
240
+ console.info()
241
+
242
+ let symlinked = 0
243
+ let generated = 0
244
+ let unchanged = 0
245
+ let removed = 0
246
+ let skipped = 0
247
+
248
+ // 1. doc skills (unless skipped)
249
+ if (!args['skip-internal-docs']) {
250
+ const docStats = await generateDocSkills(cwd, args.clean)
251
+ symlinked = docStats.symlinked
252
+ generated = docStats.generated
253
+ unchanged = docStats.unchanged
254
+ removed = docStats.removed
255
+ skipped = docStats.skipped
256
+ console.info()
257
+ }
258
+
259
+ // summary
260
+ console.info()
261
+ console.info(pc.bold('summary:'))
262
+ if (symlinked > 0) console.info(` ${pc.green(`${symlinked} symlinked`)}`)
263
+ if (generated > 0)
264
+ console.info(
265
+ ` ${pc.yellow(`${generated} generated`)} ${pc.dim('(add frontmatter to enable symlink)')}`
266
+ )
267
+ if (skipped > 0)
268
+ console.info(
269
+ ` ${pc.yellow(`${skipped} skipped`)} ${pc.dim('(missing skill frontmatter)')}`
270
+ )
271
+ if (unchanged > 0) console.info(` ${pc.dim(`${unchanged} unchanged`)}`)
272
+ if (removed > 0) console.info(` ${pc.red(`${removed} removed`)}`)
273
+ for (const skillsDir of skillsDirs) {
274
+ console.info(pc.dim(` skills in ${skillsDir}`))
275
+ }
276
+ console.info()
277
+ },
278
+ })
279
+
280
+ export const skillsCommand = defineCommand({
281
+ meta: {
282
+ name: 'skills',
283
+ description: 'Manage repository agent skills',
284
+ },
285
+ subCommands: {
286
+ generate: generateCommand,
287
+ },
288
+ })
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Sync command - sync fork with upstream Takeout repository
3
+ */
4
+
5
+ import { execSync, spawn, spawnSync } from 'node:child_process'
6
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ import { defineCommand } from 'citty'
11
+ import pc from 'picocolors'
12
+
13
+ import {
14
+ confirmContinue,
15
+ promptSelect,
16
+ showError,
17
+ showInfo,
18
+ showStep,
19
+ showSuccess,
20
+ } from '../utils/prompts'
21
+
22
+ const UPSTREAM_REPO = 'tamagui/takeout2'
23
+ const UPSTREAM_REMOTE = 'takeout-upstream'
24
+ const TAKEOUT_FILE = '.takeout'
25
+
26
+ function getSyncPrompt(): string {
27
+ const promptPath = join(process.cwd(), 'docs', 'upgrade.md')
28
+ if (!existsSync(promptPath)) {
29
+ throw new Error(
30
+ 'Missing docs/upgrade.md. Copy it from https://github.com/tamagui/takeout2/blob/main/docs/upgrade.md and rerun `bun tko sync`.'
31
+ )
32
+ }
33
+
34
+ return readFileSync(promptPath, 'utf-8')
35
+ }
36
+
37
+ function checkToolAvailable(command: string): boolean {
38
+ try {
39
+ // use 'where' on windows, 'which' on unix
40
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which'
41
+ const result = spawnSync(checkCmd, [command])
42
+ return result.status === 0
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ function ensureUpstreamRemote(): boolean {
49
+ try {
50
+ // check if remote exists
51
+ const remotes = execSync('git remote', { encoding: 'utf-8' })
52
+ if (!remotes.includes(UPSTREAM_REMOTE)) {
53
+ execSync(`git remote add ${UPSTREAM_REMOTE} git@github.com:${UPSTREAM_REPO}.git`, {
54
+ stdio: 'pipe',
55
+ })
56
+ }
57
+ execSync(`git fetch ${UPSTREAM_REMOTE} --quiet`, { stdio: 'pipe' })
58
+ return true
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ function getUpstreamHeadSha(): string | null {
65
+ try {
66
+ const sha = execSync(`git rev-parse ${UPSTREAM_REMOTE}/main`, { encoding: 'utf-8' })
67
+ return sha.trim()
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ function writeTakeoutConfig(sha: string): void {
74
+ const configPath = join(process.cwd(), TAKEOUT_FILE)
75
+ const date = new Date().toISOString().split('T')[0]
76
+ const content = `# takeout sync tracking file
77
+ # this file tracks the last synced commit from upstream takeout
78
+ sha=${sha}
79
+ date=${date}
80
+ `
81
+ writeFileSync(configPath, content)
82
+ }
83
+
84
+ export const syncCommand = defineCommand({
85
+ meta: {
86
+ name: 'sync',
87
+ description: 'Sync your fork with the latest Takeout repository',
88
+ },
89
+ args: {
90
+ auto: {
91
+ type: 'boolean',
92
+ description: 'Auto-run with claude-code without prompts (for non-TTY environments)',
93
+ default: false,
94
+ },
95
+ print: {
96
+ type: 'boolean',
97
+ description: 'Print the sync prompt and exit',
98
+ default: false,
99
+ },
100
+ },
101
+ async run({ args }) {
102
+ const isAuto = args.auto
103
+ const isPrint = args.print
104
+ showStep('Takeout Repository Sync')
105
+ console.info()
106
+
107
+ if (!isAuto && !isPrint) {
108
+ showInfo('Takeout sync uses AI to intelligently merge upstream changes')
109
+ console.info()
110
+ console.info(pc.gray('How it works:'))
111
+ console.info(pc.gray(' • Analyzes commits from upstream Takeout repository'))
112
+ console.info(pc.gray(' • Determines which changes are relevant to your fork'))
113
+ console.info(pc.gray(' • Applies changes while preserving your customizations'))
114
+ console.info(pc.gray(' • Handles package ejection automatically'))
115
+ console.info(pc.gray(' • Asks for your input when decisions are needed'))
116
+ console.info()
117
+ }
118
+
119
+ // check what tools are available
120
+ const hasClaudeCode = checkToolAvailable('claude')
121
+ const hasCursor = checkToolAvailable('cursor-agent')
122
+ const hasAider = checkToolAvailable('aider')
123
+
124
+ // handle --auto mode
125
+ let choice: string
126
+ if (isAuto) {
127
+ if (!hasClaudeCode) {
128
+ showError('--auto requires claude CLI to be installed')
129
+ process.exit(1)
130
+ }
131
+ choice = 'claude-code'
132
+ } else if (isPrint) {
133
+ choice = 'show-prompt'
134
+ } else {
135
+ const options: Array<{
136
+ value: string
137
+ label: string
138
+ hint: string
139
+ }> = []
140
+
141
+ if (hasClaudeCode) {
142
+ options.push({
143
+ value: 'claude-code',
144
+ label: 'Claude Code (recommended)',
145
+ hint: 'Run sync automatically with Claude Code CLI',
146
+ })
147
+ }
148
+
149
+ if (hasCursor) {
150
+ options.push({
151
+ value: 'cursor',
152
+ label: 'Cursor Agent',
153
+ hint: 'Run sync automatically with Cursor CLI',
154
+ })
155
+ }
156
+
157
+ if (hasAider) {
158
+ options.push({
159
+ value: 'aider',
160
+ label: 'Aider',
161
+ hint: 'Run sync automatically with Aider CLI',
162
+ })
163
+ }
164
+
165
+ options.push({
166
+ value: 'show-prompt',
167
+ label: 'Show prompt (copy & paste manually)',
168
+ hint: 'Display the full prompt to use with any LLM',
169
+ })
170
+
171
+ choice = await promptSelect<string>('How would you like to sync?', options)
172
+
173
+ if (choice === 'cancel') {
174
+ console.info()
175
+ showInfo('Sync cancelled')
176
+ return
177
+ }
178
+
179
+ console.info()
180
+ }
181
+
182
+ // fetch upstream to get the target SHA before syncing
183
+ console.info(pc.dim('Fetching upstream repository...'))
184
+ if (!ensureUpstreamRemote()) {
185
+ showError('Failed to fetch upstream repository')
186
+ return
187
+ }
188
+
189
+ const upstreamSha = getUpstreamHeadSha()
190
+ if (!upstreamSha) {
191
+ showError('Failed to get upstream HEAD SHA')
192
+ return
193
+ }
194
+ console.info(pc.dim(`Target SHA: ${upstreamSha.slice(0, 7)}`))
195
+ console.info()
196
+
197
+ try {
198
+ const prompt = getSyncPrompt()
199
+
200
+ if (choice === 'show-prompt') {
201
+ console.info(pc.dim('='.repeat(80)))
202
+ console.info(prompt)
203
+ console.info(pc.dim('='.repeat(80)))
204
+ console.info()
205
+ showInfo('Copy the prompt above and paste it into your preferred LLM')
206
+ console.info()
207
+ console.info(pc.gray('Recommended LLMs:'))
208
+ console.info(pc.gray(' • Claude Code (best for complex instructions)'))
209
+ console.info(pc.gray(' • ChatGPT'))
210
+ console.info(pc.gray(' • Cursor'))
211
+ console.info(pc.gray(' • Aider'))
212
+ console.info()
213
+ } else if (choice === 'claude-code') {
214
+ showInfo('Starting Claude Code with sync prompt...')
215
+ console.info()
216
+
217
+ if (!isAuto) {
218
+ console.info(
219
+ pc.dim(
220
+ 'Note: Claude Code will run in headless mode and make changes automatically.'
221
+ )
222
+ )
223
+ console.info(pc.dim('You will be asked to confirm important decisions.'))
224
+ console.info()
225
+
226
+ const shouldContinue = await confirmContinue('Continue with Claude Code?', true)
227
+ if (!shouldContinue) {
228
+ showInfo('Sync cancelled')
229
+ return
230
+ }
231
+ }
232
+
233
+ // write prompt to temp file to avoid shell escaping issues
234
+ const tempDir = mkdtempSync(join(tmpdir(), 'takeout-sync-'))
235
+ const promptFile = join(tempDir, 'prompt.md')
236
+ writeFileSync(promptFile, prompt)
237
+
238
+ // run claude with prompt from stdin
239
+ const claude = spawn('claude', ['-p', '-'], {
240
+ stdio: ['pipe', 'inherit', 'inherit'],
241
+ })
242
+
243
+ claude.stdin?.write(prompt)
244
+ claude.stdin?.end()
245
+
246
+ claude.on('close', (code) => {
247
+ console.info()
248
+ if (code === 0) {
249
+ writeTakeoutConfig(upstreamSha)
250
+ showSuccess('Sync completed successfully!')
251
+ console.info(pc.dim(`Updated .takeout to ${upstreamSha.slice(0, 7)}`))
252
+ } else {
253
+ showError(`Claude Code exited with code ${code}`)
254
+ }
255
+ })
256
+ } else if (choice === 'cursor') {
257
+ showInfo('Starting Cursor Agent with sync prompt...')
258
+ console.info()
259
+
260
+ const shouldContinue = await confirmContinue('Continue with Cursor?', true)
261
+ if (!shouldContinue) {
262
+ showInfo('Sync cancelled')
263
+ return
264
+ }
265
+
266
+ // run cursor agent with prompt from stdin
267
+ const cursor = spawn('cursor-agent', ['-p', '-'], {
268
+ stdio: ['pipe', 'inherit', 'inherit'],
269
+ })
270
+
271
+ cursor.stdin?.write(prompt)
272
+ cursor.stdin?.end()
273
+
274
+ cursor.on('close', (code) => {
275
+ console.info()
276
+ if (code === 0) {
277
+ writeTakeoutConfig(upstreamSha)
278
+ showSuccess('Sync completed successfully!')
279
+ console.info(pc.dim(`Updated .takeout to ${upstreamSha.slice(0, 7)}`))
280
+ } else {
281
+ showError(`Cursor Agent exited with code ${code}`)
282
+ }
283
+ })
284
+ } else if (choice === 'aider') {
285
+ showInfo('Starting Aider with sync prompt...')
286
+ console.info()
287
+
288
+ const shouldContinue = await confirmContinue('Continue with Aider?', true)
289
+ if (!shouldContinue) {
290
+ showInfo('Sync cancelled')
291
+ return
292
+ }
293
+
294
+ // write prompt to temp file for aider
295
+ const tempDir = mkdtempSync(join(tmpdir(), 'takeout-sync-'))
296
+ const promptFile = join(tempDir, 'prompt.md')
297
+ writeFileSync(promptFile, prompt)
298
+
299
+ // run aider with message from file
300
+ const aider = spawn('aider', ['--message-file', promptFile, '--no-stream'], {
301
+ stdio: 'inherit',
302
+ })
303
+
304
+ aider.on('close', (code) => {
305
+ console.info()
306
+ if (code === 0) {
307
+ writeTakeoutConfig(upstreamSha)
308
+ showSuccess('Sync completed successfully!')
309
+ console.info(pc.dim(`Updated .takeout to ${upstreamSha.slice(0, 7)}`))
310
+ } else {
311
+ showError(`Aider exited with code ${code}`)
312
+ }
313
+ })
314
+ }
315
+ } catch (error) {
316
+ showError(error instanceof Error ? error.message : 'Unknown error')
317
+ }
318
+ },
319
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ASCII art constants for Takeout branding
3
+ */
4
+
5
+ // TAKEOUT in block letters with chinese accent line
6
+ // chinese chars: 麵(noodles) 碼(code) 飯(rice)
7
+ export const TAKEOUT_ASCII = `
8
+ ████████╗ █████╗ ██╗ ██╗███████╗ ██████╗ ██╗ ██╗████████╗
9
+ ╚══██╔══╝██╔══██╗██║ ██╔╝██╔════╝██╔═══██╗██║ ██║╚══██╔══╝
10
+ ██║ ███████║█████╔╝ █████╗ ██║ ██║██║ ██║ ██║
11
+ ██║ ██╔══██║██╔═██╗ ██╔══╝ ██║ ██║██║ ██║ ██║
12
+ ██║ ██║ ██║██║ ██╗███████╗╚██████╔╝╚██████╔╝ ██║
13
+ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝
14
+ 麵 碼 飯
15
+ `.trim()
16
+
17
+ export const WELCOME_BANNER = TAKEOUT_ASCII
@@ -0,0 +1 @@
1
+ export { cmd } from '@o/scripts/cmd'
@@ -0,0 +1,59 @@
1
+ export interface PrerequisiteCheck {
2
+ name: string
3
+ required: boolean
4
+ installed: boolean
5
+ version?: string
6
+ requiredVersion?: string
7
+ message?: string
8
+ installUrl?: string
9
+ recommendation?: string
10
+ }
11
+
12
+ export interface PortCheck {
13
+ port: number
14
+ name: string
15
+ inUse: boolean
16
+ pid?: number
17
+ }
18
+
19
+ export interface ProjectIdentity {
20
+ name: string
21
+ bundleId: string
22
+ domain: string
23
+ }
24
+
25
+ export interface OnboardingConfig {
26
+ skipPrerequisites?: boolean
27
+ skipServices?: boolean
28
+ skipMigrations?: boolean
29
+ autoStart?: boolean
30
+ }
31
+
32
+ export interface EnvironmentSetup {
33
+ authSecret: string
34
+ githubClientId?: string
35
+ githubClientSecret?: string
36
+ domain: string
37
+ serverUrl: string
38
+ }
39
+
40
+ export interface EnvVariable {
41
+ key: string
42
+ label: string
43
+ description: string
44
+ instructions: string
45
+ required: boolean
46
+ type: 'text' | 'secret' | 'multiline'
47
+ default?: string
48
+ placeholder?: string
49
+ generator?: () => string
50
+ }
51
+
52
+ export interface EnvCategory {
53
+ id: string
54
+ name: string
55
+ description: string
56
+ required: boolean
57
+ setupTime?: string
58
+ variables: EnvVariable[]
59
+ }