@orderful/droid 0.2.0 → 0.4.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 (50) hide show
  1. package/.claude/CLAUDE.md +41 -0
  2. package/.github/workflows/changeset-check.yml +43 -0
  3. package/.github/workflows/release.yml +6 -3
  4. package/CHANGELOG.md +43 -0
  5. package/bun.lock +357 -14
  6. package/dist/agents/README.md +137 -0
  7. package/dist/bin/droid.js +12 -1
  8. package/dist/bin/droid.js.map +1 -1
  9. package/dist/commands/setup.d.ts +8 -0
  10. package/dist/commands/setup.d.ts.map +1 -1
  11. package/dist/commands/setup.js +67 -0
  12. package/dist/commands/setup.js.map +1 -1
  13. package/dist/commands/tui.d.ts +2 -0
  14. package/dist/commands/tui.d.ts.map +1 -0
  15. package/dist/commands/tui.js +737 -0
  16. package/dist/commands/tui.js.map +1 -0
  17. package/dist/lib/agents.d.ts +53 -0
  18. package/dist/lib/agents.d.ts.map +1 -0
  19. package/dist/lib/agents.js +149 -0
  20. package/dist/lib/agents.js.map +1 -0
  21. package/dist/lib/skills.d.ts +20 -0
  22. package/dist/lib/skills.d.ts.map +1 -1
  23. package/dist/lib/skills.js +102 -0
  24. package/dist/lib/skills.js.map +1 -1
  25. package/dist/lib/types.d.ts +5 -0
  26. package/dist/lib/types.d.ts.map +1 -1
  27. package/dist/lib/version.d.ts +5 -0
  28. package/dist/lib/version.d.ts.map +1 -1
  29. package/dist/lib/version.js +19 -1
  30. package/dist/lib/version.js.map +1 -1
  31. package/dist/skills/README.md +85 -0
  32. package/dist/skills/comments/SKILL.md +8 -0
  33. package/dist/skills/comments/SKILL.yaml +32 -0
  34. package/dist/skills/comments/commands/README.md +58 -0
  35. package/package.json +15 -2
  36. package/src/agents/README.md +137 -0
  37. package/src/bin/droid.ts +12 -1
  38. package/src/commands/setup.ts +77 -0
  39. package/src/commands/tui.tsx +1535 -0
  40. package/src/lib/agents.ts +186 -0
  41. package/src/lib/skills.test.ts +75 -1
  42. package/src/lib/skills.ts +125 -0
  43. package/src/lib/types.ts +7 -0
  44. package/src/lib/version.test.ts +20 -1
  45. package/src/lib/version.ts +19 -1
  46. package/src/skills/README.md +85 -0
  47. package/src/skills/comments/SKILL.md +8 -0
  48. package/src/skills/comments/SKILL.yaml +32 -0
  49. package/src/skills/comments/commands/README.md +58 -0
  50. package/tsconfig.json +5 -3
@@ -0,0 +1,1535 @@
1
+ import { render, Box, Text, useInput, useApp } from 'ink';
2
+ import TextInput from 'ink-text-input';
3
+ import { useState, useMemo } from 'react';
4
+ import { execSync } from 'child_process';
5
+ import { existsSync, readdirSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import {
8
+ getBundledSkills,
9
+ getBundledSkillsDir,
10
+ isSkillInstalled,
11
+ getInstalledSkill,
12
+ installSkill,
13
+ uninstallSkill,
14
+ isCommandInstalled,
15
+ installCommand,
16
+ uninstallCommand,
17
+ } from '../lib/skills.js';
18
+ import { getBundledAgents, getBundledAgentsDir, isAgentInstalled, installAgent, uninstallAgent, type AgentManifest } from '../lib/agents.js';
19
+ import { configExists, loadConfig, saveConfig, loadSkillOverrides, saveSkillOverrides } from '../lib/config.js';
20
+ import { configureAIToolPermissions } from './setup.js';
21
+ import { AITool, BuiltInOutput, ConfigOptionType, type DroidConfig, type OutputPreference, type SkillManifest, type ConfigOption, type SkillOverrides } from '../lib/types.js';
22
+ import { getVersion } from '../lib/version.js';
23
+
24
+ type Tab = 'skills' | 'commands' | 'agents' | 'settings';
25
+ type View = 'welcome' | 'setup' | 'menu' | 'detail' | 'configure' | 'readme';
26
+ type SetupStep = 'ai_tool' | 'user_mention' | 'output_preference' | 'confirm';
27
+
28
+ const colors = {
29
+ primary: '#6366f1',
30
+ bgSelected: '#2d2d2d',
31
+ border: '#3a3a3a',
32
+ text: '#e8e8e8',
33
+ textMuted: '#999999',
34
+ textDim: '#6a6a6a',
35
+ success: '#4ade80',
36
+ error: '#f87171',
37
+ };
38
+
39
+ interface Command {
40
+ name: string;
41
+ description: string;
42
+ skillName: string;
43
+ usage: string[];
44
+ }
45
+
46
+ function getCommandsFromSkills(): Command[] {
47
+ const skillsDir = getBundledSkillsDir();
48
+ const commands: Command[] = [];
49
+
50
+ if (!existsSync(skillsDir)) return commands;
51
+
52
+ const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
53
+ .filter((d) => d.isDirectory())
54
+ .map((d) => d.name);
55
+
56
+ for (const skillName of skillDirs) {
57
+ const commandsDir = join(skillsDir, skillName, 'commands');
58
+ if (!existsSync(commandsDir)) continue;
59
+
60
+ const cmdFiles = readdirSync(commandsDir).filter((f) => f.endsWith('.md'));
61
+ for (const file of cmdFiles) {
62
+ const content = readFileSync(join(commandsDir, file), 'utf-8');
63
+ const lines = content.split('\n');
64
+
65
+ const headerMatch = lines[0]?.match(/^#\s+\/(\S+)\s*-?\s*(.*)/);
66
+ if (!headerMatch) continue;
67
+
68
+ const usage: string[] = [];
69
+ let inUsage = false;
70
+ for (const line of lines) {
71
+ if (line.startsWith('## Usage')) inUsage = true;
72
+ else if (line.startsWith('## ') && inUsage) break;
73
+ else if (inUsage && line.startsWith('/')) usage.push(line.trim());
74
+ }
75
+
76
+ commands.push({
77
+ name: headerMatch[1],
78
+ description: headerMatch[2] || '',
79
+ skillName,
80
+ usage,
81
+ });
82
+ }
83
+ }
84
+
85
+ return commands;
86
+ }
87
+
88
+ function detectAITool(): AITool | null {
89
+ try {
90
+ execSync('claude --version', { stdio: 'ignore' });
91
+ return AITool.ClaudeCode;
92
+ } catch {
93
+ // Claude Code not found
94
+ }
95
+ try {
96
+ execSync('opencode --version', { stdio: 'ignore' });
97
+ return AITool.OpenCode;
98
+ } catch {
99
+ // OpenCode not found
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function getOutputOptions(): Array<{ label: string; value: OutputPreference }> {
105
+ const options: Array<{ label: string; value: OutputPreference }> = [
106
+ { label: 'Terminal (display in CLI)', value: BuiltInOutput.Terminal },
107
+ { label: 'Editor ($EDITOR)', value: BuiltInOutput.Editor },
108
+ ];
109
+ const skills = getBundledSkills();
110
+ for (const skill of skills) {
111
+ if (skill.provides_output) {
112
+ options.push({ label: `${skill.name} (${skill.description})`, value: skill.name });
113
+ }
114
+ }
115
+ return options;
116
+ }
117
+
118
+ function WelcomeScreen({ onContinue }: { onContinue: () => void }) {
119
+ useInput((input, key) => {
120
+ if (key.return || input === 'q') {
121
+ onContinue();
122
+ }
123
+ });
124
+
125
+ return (
126
+ <Box flexDirection="column" alignItems="center" justifyContent="center" height={16}>
127
+ <Box
128
+ flexDirection="column"
129
+ alignItems="center"
130
+ borderStyle="single"
131
+ borderColor={colors.border}
132
+ paddingX={4}
133
+ paddingY={1}
134
+ >
135
+ <Box flexDirection="column">
136
+ <Text>
137
+ <Text color={colors.textDim}>╔═════╗ </Text>
138
+ <Text color={colors.text}>droid</Text>
139
+ <Text color={colors.textDim}> v{getVersion()}</Text>
140
+ </Text>
141
+ <Text>
142
+ <Text color={colors.textDim}>║ </Text>
143
+ <Text color={colors.primary}>●</Text>
144
+ <Text color={colors.textDim}> </Text>
145
+ <Text color={colors.primary}>●</Text>
146
+ <Text color={colors.textDim}> ║ </Text>
147
+ <Text color={colors.textMuted}>Teaching your droid new tricks</Text>
148
+ </Text>
149
+ <Text>
150
+ <Text color={colors.textDim}>╚═╦═╦═╝ </Text>
151
+ <Text color={colors.textDim}>github.com/Orderful/droid</Text>
152
+ </Text>
153
+ </Box>
154
+
155
+ <Box marginTop={2} marginBottom={1}>
156
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
157
+ {' '}I'm probably the droid you are looking for.{' '}
158
+ </Text>
159
+ </Box>
160
+
161
+ <Text color={colors.textDim}>press enter</Text>
162
+ </Box>
163
+ </Box>
164
+ );
165
+ }
166
+
167
+ interface SetupScreenProps {
168
+ onComplete: () => void;
169
+ onSkip: () => void;
170
+ initialConfig?: DroidConfig;
171
+ }
172
+
173
+ function SetupScreen({ onComplete, onSkip, initialConfig }: SetupScreenProps) {
174
+ const [step, setStep] = useState<SetupStep>('ai_tool');
175
+ const [aiTool, setAITool] = useState<AITool>(
176
+ initialConfig?.ai_tool || detectAITool() || AITool.ClaudeCode
177
+ );
178
+ const [userMention, setUserMention] = useState(initialConfig?.user_mention || '@user');
179
+ const [outputPreference, setOutputPreference] = useState<OutputPreference>(
180
+ initialConfig?.output_preference || BuiltInOutput.Terminal
181
+ );
182
+ const [selectedIndex, setSelectedIndex] = useState(0);
183
+ const [error, setError] = useState<string | null>(null);
184
+
185
+ const aiToolOptions = useMemo(() => [
186
+ { label: 'Claude Code', value: AITool.ClaudeCode },
187
+ { label: 'OpenCode', value: AITool.OpenCode },
188
+ ], []);
189
+ const outputOptions = useMemo(() => getOutputOptions(), []);
190
+
191
+ const steps: SetupStep[] = ['ai_tool', 'user_mention', 'output_preference', 'confirm'];
192
+ const stepIndex = steps.indexOf(step);
193
+ const totalSteps = steps.length - 1; // Don't count confirm as a step
194
+
195
+ const handleUserMentionSubmit = () => {
196
+ if (!userMention.startsWith('@')) {
197
+ setError('Mention should start with @');
198
+ return;
199
+ }
200
+ setError(null);
201
+ setStep('output_preference');
202
+ setSelectedIndex(0);
203
+ };
204
+
205
+ // Handle escape during text input (only intercept escape, nothing else)
206
+ useInput((input, key) => {
207
+ if (key.escape) {
208
+ setStep('ai_tool');
209
+ setSelectedIndex(0);
210
+ }
211
+ }, { isActive: step === 'user_mention' });
212
+
213
+ // Handle all input for non-text-input steps
214
+ useInput((input, key) => {
215
+ if (key.escape) {
216
+ if (step === 'ai_tool') {
217
+ onSkip();
218
+ } else if (step === 'output_preference') {
219
+ setStep('user_mention');
220
+ } else if (step === 'confirm') {
221
+ setStep('output_preference');
222
+ setSelectedIndex(0);
223
+ }
224
+ return;
225
+ }
226
+
227
+ if (step === 'ai_tool') {
228
+ if (key.upArrow) setSelectedIndex((prev) => Math.max(0, prev - 1));
229
+ if (key.downArrow) setSelectedIndex((prev) => Math.min(aiToolOptions.length - 1, prev + 1));
230
+ if (key.return) {
231
+ setAITool(aiToolOptions[selectedIndex].value);
232
+ setStep('user_mention');
233
+ }
234
+ } else if (step === 'output_preference') {
235
+ if (key.upArrow) setSelectedIndex((prev) => Math.max(0, prev - 1));
236
+ if (key.downArrow) setSelectedIndex((prev) => Math.min(outputOptions.length - 1, prev + 1));
237
+ if (key.return) {
238
+ setOutputPreference(outputOptions[selectedIndex].value);
239
+ setStep('confirm');
240
+ }
241
+ } else if (step === 'confirm') {
242
+ if (key.return) {
243
+ const config: DroidConfig = {
244
+ ...loadConfig(),
245
+ ai_tool: aiTool,
246
+ user_mention: userMention,
247
+ output_preference: outputPreference,
248
+ };
249
+ saveConfig(config);
250
+ configureAIToolPermissions(aiTool);
251
+ onComplete();
252
+ }
253
+ }
254
+ }, { isActive: step !== 'user_mention' });
255
+
256
+ const renderHeader = () => (
257
+ <Box flexDirection="column" marginBottom={1}>
258
+ <Text>
259
+ <Text color={colors.textDim}>[</Text>
260
+ <Text color={colors.primary}>●</Text>
261
+ <Text color={colors.textDim}> </Text>
262
+ <Text color={colors.primary}>●</Text>
263
+ <Text color={colors.textDim}>] </Text>
264
+ <Text color={colors.text} bold>droid setup</Text>
265
+ <Text color={colors.textDim}> · Step {Math.min(stepIndex + 1, totalSteps)} of {totalSteps}</Text>
266
+ </Text>
267
+ </Box>
268
+ );
269
+
270
+ if (step === 'ai_tool') {
271
+ return (
272
+ <Box flexDirection="column" padding={1}>
273
+ {renderHeader()}
274
+ <Text color={colors.text}>Which AI tool are you using?</Text>
275
+ <Box flexDirection="column" marginTop={1}>
276
+ {aiToolOptions.map((option, index) => (
277
+ <Text key={option.value}>
278
+ <Text color={colors.textDim}>{index === selectedIndex ? '>' : ' '} </Text>
279
+ <Text color={index === selectedIndex ? colors.text : colors.textMuted}>{option.label}</Text>
280
+ {option.value === detectAITool() && <Text color={colors.success}> (detected)</Text>}
281
+ </Text>
282
+ ))}
283
+ </Box>
284
+ <Box marginTop={1}>
285
+ <Text color={colors.textDim}>↑↓ select · enter next · esc skip</Text>
286
+ </Box>
287
+ </Box>
288
+ );
289
+ }
290
+
291
+ if (step === 'user_mention') {
292
+ return (
293
+ <Box flexDirection="column" padding={1}>
294
+ {renderHeader()}
295
+ <Text color={colors.text}>What @mention should be used for you?</Text>
296
+ <Box marginTop={1}>
297
+ <Text color={colors.textDim}>{'> '}</Text>
298
+ <TextInput
299
+ value={userMention}
300
+ onChange={setUserMention}
301
+ onSubmit={handleUserMentionSubmit}
302
+ placeholder="@user"
303
+ />
304
+ </Box>
305
+ {error && (
306
+ <Box marginTop={1}>
307
+ <Text color={colors.error}>{error}</Text>
308
+ </Box>
309
+ )}
310
+ <Box marginTop={1}>
311
+ <Text color={colors.textDim}>enter next · esc back</Text>
312
+ </Box>
313
+ </Box>
314
+ );
315
+ }
316
+
317
+ if (step === 'output_preference') {
318
+ return (
319
+ <Box flexDirection="column" padding={1}>
320
+ {renderHeader()}
321
+ <Text color={colors.text}>Default output preference for skill results?</Text>
322
+ <Box flexDirection="column" marginTop={1}>
323
+ {outputOptions.map((option, index) => (
324
+ <Text key={option.value}>
325
+ <Text color={colors.textDim}>{index === selectedIndex ? '>' : ' '} </Text>
326
+ <Text color={index === selectedIndex ? colors.text : colors.textMuted}>{option.label}</Text>
327
+ </Text>
328
+ ))}
329
+ </Box>
330
+ <Box marginTop={1}>
331
+ <Text color={colors.textDim}>↑↓ select · enter next · esc back</Text>
332
+ </Box>
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ // Confirm step
338
+ return (
339
+ <Box flexDirection="column" padding={1}>
340
+ {renderHeader()}
341
+ <Text color={colors.text} bold>Review your settings</Text>
342
+ <Box flexDirection="column" marginTop={1}>
343
+ <Text>
344
+ <Text color={colors.textDim}>AI Tool: </Text>
345
+ <Text color={colors.text}>{aiTool === AITool.ClaudeCode ? 'Claude Code' : 'OpenCode'}</Text>
346
+ </Text>
347
+ <Text>
348
+ <Text color={colors.textDim}>Your @mention: </Text>
349
+ <Text color={colors.text}>{userMention}</Text>
350
+ </Text>
351
+ <Text>
352
+ <Text color={colors.textDim}>Output: </Text>
353
+ <Text color={colors.text}>{outputOptions.find((o) => o.value === outputPreference)?.label || outputPreference}</Text>
354
+ </Text>
355
+ </Box>
356
+ <Box marginTop={2}>
357
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
358
+ {' '}Save{' '}
359
+ </Text>
360
+ </Box>
361
+ <Box marginTop={1}>
362
+ <Text color={colors.textDim}>enter save · esc back</Text>
363
+ </Box>
364
+ </Box>
365
+ );
366
+ }
367
+
368
+ function TabBar({ tabs, activeTab }: { tabs: { id: Tab; label: string }[]; activeTab: Tab }) {
369
+ return (
370
+ <Box flexDirection="row">
371
+ {tabs.map((tab) => (
372
+ <Text
373
+ key={tab.id}
374
+ backgroundColor={tab.id === activeTab ? colors.primary : undefined}
375
+ color={tab.id === activeTab ? '#ffffff' : colors.textMuted}
376
+ bold={tab.id === activeTab}
377
+ >
378
+ {' '}{tab.label}{' '}
379
+ </Text>
380
+ ))}
381
+ </Box>
382
+ );
383
+ }
384
+
385
+ function SkillItem({
386
+ skill,
387
+ isSelected,
388
+ isActive,
389
+ }: {
390
+ skill: SkillManifest;
391
+ isSelected: boolean;
392
+ isActive: boolean;
393
+ }) {
394
+ const installed = isSkillInstalled(skill.name);
395
+ const installedInfo = getInstalledSkill(skill.name);
396
+
397
+ return (
398
+ <Box paddingX={1} backgroundColor={isActive ? colors.bgSelected : undefined}>
399
+ <Text>
400
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
401
+ <Text color={isSelected || isActive ? colors.text : colors.textMuted}>{skill.name}</Text>
402
+ {installed && installedInfo && <Text color={colors.textDim}> v{installedInfo.version}</Text>}
403
+ {installed && <Text color={colors.success}> *</Text>}
404
+ </Text>
405
+ </Box>
406
+ );
407
+ }
408
+
409
+ function CommandItem({
410
+ command,
411
+ isSelected,
412
+ }: {
413
+ command: Command;
414
+ isSelected: boolean;
415
+ }) {
416
+ const installed = isSkillInstalled(command.skillName);
417
+
418
+ return (
419
+ <Box paddingX={1}>
420
+ <Text>
421
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
422
+ <Text color={isSelected ? colors.text : colors.textMuted}>/{command.name}</Text>
423
+ {installed && <Text color={colors.success}> *</Text>}
424
+ </Text>
425
+ </Box>
426
+ );
427
+ }
428
+
429
+ function SkillDetails({
430
+ skill,
431
+ isFocused,
432
+ selectedAction,
433
+ }: {
434
+ skill: SkillManifest | null;
435
+ isFocused: boolean;
436
+ selectedAction: number;
437
+ }) {
438
+ if (!skill) {
439
+ return (
440
+ <Box paddingLeft={2} flexGrow={1}>
441
+ <Text color={colors.textDim}>Select a skill</Text>
442
+ </Box>
443
+ );
444
+ }
445
+
446
+ const installed = isSkillInstalled(skill.name);
447
+ const skillCommands = getCommandsFromSkills().filter((c) => c.skillName === skill.name);
448
+
449
+ const actions = installed
450
+ ? [
451
+ { id: 'view', label: 'View', variant: 'default' },
452
+ { id: 'configure', label: 'Configure', variant: 'primary' },
453
+ { id: 'uninstall', label: 'Uninstall', variant: 'danger' },
454
+ ]
455
+ : [
456
+ { id: 'view', label: 'View', variant: 'default' },
457
+ { id: 'install', label: 'Install', variant: 'primary' },
458
+ ];
459
+
460
+ return (
461
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
462
+ <Text color={colors.text} bold>{skill.name}</Text>
463
+
464
+ <Box marginTop={1}>
465
+ <Text color={colors.textDim}>
466
+ {skill.version}
467
+ {skill.status && ` · ${skill.status}`}
468
+ {installed && <Text color={colors.success}> · installed</Text>}
469
+ </Text>
470
+ </Box>
471
+
472
+ <Box marginTop={1}>
473
+ <Text color={colors.textMuted}>{skill.description}</Text>
474
+ </Box>
475
+
476
+ {skillCommands.length > 0 && (
477
+ <Box marginTop={1}>
478
+ <Text color={colors.textDim}>
479
+ Commands: {skillCommands.map((c) => `/${c.name}`).join(', ')}
480
+ </Text>
481
+ </Box>
482
+ )}
483
+
484
+ {skill.examples && skill.examples.length > 0 && (
485
+ <Box flexDirection="column" marginTop={1}>
486
+ <Text color={colors.textDim}>
487
+ Examples{skill.examples.length > 2 ? ` (showing 2 of ${skill.examples.length})` : ''}:
488
+ </Text>
489
+ {skill.examples.slice(0, 2).map((example, i) => (
490
+ <Box key={i} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
491
+ <Text color={colors.textMuted}> {example.title}</Text>
492
+ {example.code
493
+ .trim()
494
+ .split('\n')
495
+ .slice(0, 3)
496
+ .map((line, j) => (
497
+ <Text key={j} color={colors.textDim}>
498
+ {' '}{line}
499
+ </Text>
500
+ ))}
501
+ </Box>
502
+ ))}
503
+ </Box>
504
+ )}
505
+
506
+ {isFocused && (
507
+ <Box flexDirection="row" marginTop={1}>
508
+ {actions.map((action, index) => (
509
+ <Text
510
+ key={action.id}
511
+ backgroundColor={
512
+ selectedAction === index
513
+ ? action.variant === 'danger'
514
+ ? colors.error
515
+ : colors.primary
516
+ : colors.bgSelected
517
+ }
518
+ color={selectedAction === index ? '#ffffff' : colors.textMuted}
519
+ bold={selectedAction === index}
520
+ >
521
+ {' '}{action.label}{' '}
522
+ </Text>
523
+ ))}
524
+ </Box>
525
+ )}
526
+ </Box>
527
+ );
528
+ }
529
+
530
+ function CommandDetails({
531
+ command,
532
+ isFocused,
533
+ selectedAction,
534
+ }: {
535
+ command: Command | null;
536
+ isFocused: boolean;
537
+ selectedAction: number;
538
+ }) {
539
+ if (!command) {
540
+ return (
541
+ <Box paddingLeft={2} flexGrow={1}>
542
+ <Text color={colors.textDim}>Select a command</Text>
543
+ </Box>
544
+ );
545
+ }
546
+
547
+ const skillInstalled = isSkillInstalled(command.skillName);
548
+ const installed = isCommandInstalled(command.name, command.skillName);
549
+
550
+ // If skill is installed, command comes with it - no standalone uninstall
551
+ const actions = skillInstalled
552
+ ? [{ id: 'view', label: 'View', variant: 'default' }]
553
+ : installed
554
+ ? [
555
+ { id: 'view', label: 'View', variant: 'default' },
556
+ { id: 'uninstall', label: 'Uninstall', variant: 'danger' },
557
+ ]
558
+ : [
559
+ { id: 'view', label: 'View', variant: 'default' },
560
+ { id: 'install', label: 'Install', variant: 'primary' },
561
+ ];
562
+
563
+ return (
564
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
565
+ <Text color={colors.text} bold>/{command.name}</Text>
566
+
567
+ <Box marginTop={1}>
568
+ <Text color={colors.textDim}>
569
+ from {command.skillName}
570
+ {skillInstalled && <Text color={colors.success}> · via skill</Text>}
571
+ {!skillInstalled && installed && <Text color={colors.success}> · installed</Text>}
572
+ </Text>
573
+ </Box>
574
+
575
+ <Box marginTop={1}>
576
+ <Text color={colors.textMuted}>{command.description}</Text>
577
+ </Box>
578
+
579
+ {command.usage.length > 0 && (
580
+ <Box flexDirection="column" marginTop={1}>
581
+ <Text color={colors.textDim}>Usage:</Text>
582
+ {command.usage.map((u, i) => (
583
+ <Text key={i} color={colors.textMuted}>
584
+ {' '}{u}
585
+ </Text>
586
+ ))}
587
+ </Box>
588
+ )}
589
+
590
+ {isFocused && (
591
+ <Box flexDirection="row" marginTop={1}>
592
+ {actions.map((action, index) => (
593
+ <Text
594
+ key={action.id}
595
+ backgroundColor={
596
+ selectedAction === index
597
+ ? action.variant === 'danger'
598
+ ? colors.error
599
+ : colors.primary
600
+ : colors.bgSelected
601
+ }
602
+ color={selectedAction === index ? '#ffffff' : colors.textMuted}
603
+ bold={selectedAction === index}
604
+ >
605
+ {' '}{action.label}{' '}
606
+ </Text>
607
+ ))}
608
+ </Box>
609
+ )}
610
+ </Box>
611
+ );
612
+ }
613
+
614
+ function AgentItem({ agent, isSelected }: { agent: AgentManifest; isSelected: boolean }) {
615
+ const statusDisplay = agent.status === 'alpha' ? '[alpha]' : agent.status === 'beta' ? '[beta]' : '';
616
+
617
+ return (
618
+ <Box paddingX={1}>
619
+ <Text>
620
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
621
+ <Text color={isSelected ? colors.text : colors.textMuted}>{agent.name}</Text>
622
+ <Text color={colors.textDim}> v{agent.version}</Text>
623
+ {statusDisplay && <Text color={colors.textDim}> {statusDisplay}</Text>}
624
+ </Text>
625
+ </Box>
626
+ );
627
+ }
628
+
629
+ function AgentDetails({
630
+ agent,
631
+ isFocused,
632
+ selectedAction,
633
+ }: {
634
+ agent: AgentManifest | null;
635
+ isFocused: boolean;
636
+ selectedAction: number;
637
+ }) {
638
+ if (!agent) {
639
+ return (
640
+ <Box paddingLeft={2} flexGrow={1}>
641
+ <Text color={colors.textDim}>Select an agent</Text>
642
+ </Box>
643
+ );
644
+ }
645
+
646
+ const installed = isAgentInstalled(agent.name);
647
+ const statusDisplay = agent.status === 'alpha' ? '[alpha]' : agent.status === 'beta' ? '[beta]' : '';
648
+ const modeDisplay = agent.mode === 'primary' ? 'primary' : agent.mode === 'all' ? 'primary/subagent' : 'subagent';
649
+
650
+ const actions = installed
651
+ ? [
652
+ { id: 'view', label: 'View', variant: 'default' },
653
+ { id: 'uninstall', label: 'Uninstall', variant: 'danger' },
654
+ ]
655
+ : [
656
+ { id: 'view', label: 'View', variant: 'default' },
657
+ { id: 'install', label: 'Install', variant: 'primary' },
658
+ ];
659
+
660
+ return (
661
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
662
+ <Text color={colors.text} bold>{agent.name}</Text>
663
+
664
+ <Box marginTop={1}>
665
+ <Text color={colors.textDim}>
666
+ v{agent.version}
667
+ {statusDisplay && <Text> · {statusDisplay}</Text>}
668
+ {' · '}{modeDisplay}
669
+ {installed && <Text color={colors.success}> · installed</Text>}
670
+ </Text>
671
+ </Box>
672
+
673
+ <Box marginTop={1}>
674
+ <Text color={colors.textMuted}>{agent.description}</Text>
675
+ </Box>
676
+
677
+ {agent.tools && agent.tools.length > 0 && (
678
+ <Box marginTop={1}>
679
+ <Text color={colors.textDim}>
680
+ Tools: {agent.tools.join(', ')}
681
+ </Text>
682
+ </Box>
683
+ )}
684
+
685
+ {agent.triggers && agent.triggers.length > 0 && (
686
+ <Box flexDirection="column" marginTop={1}>
687
+ <Text color={colors.textDim}>Triggers:</Text>
688
+ {agent.triggers.slice(0, 3).map((trigger, i) => (
689
+ <Text key={i} color={colors.textMuted}>
690
+ {' '}&quot;{trigger}&quot;
691
+ </Text>
692
+ ))}
693
+ </Box>
694
+ )}
695
+
696
+ {isFocused && (
697
+ <Box flexDirection="row" marginTop={1}>
698
+ {actions.map((action, index) => (
699
+ <Text
700
+ key={action.id}
701
+ backgroundColor={
702
+ selectedAction === index
703
+ ? action.variant === 'danger'
704
+ ? colors.error
705
+ : colors.primary
706
+ : colors.bgSelected
707
+ }
708
+ color={selectedAction === index ? '#ffffff' : colors.textMuted}
709
+ bold={selectedAction === index}
710
+ >
711
+ {' '}{action.label}{' '}
712
+ </Text>
713
+ ))}
714
+ </Box>
715
+ )}
716
+ </Box>
717
+ );
718
+ }
719
+
720
+ function MarkdownLine({ line, inCodeBlock }: { line: string; inCodeBlock: boolean }) {
721
+ // Code block content
722
+ if (inCodeBlock) {
723
+ return <Text color="#a5d6ff">{line || ' '}</Text>;
724
+ }
725
+
726
+ // Code block delimiter
727
+ if (line.startsWith('```')) {
728
+ return <Text color={colors.textDim}>{line}</Text>;
729
+ }
730
+
731
+ // Headers
732
+ if (line.startsWith('# ')) {
733
+ return <Text color={colors.text} bold>{line.slice(2)}</Text>;
734
+ }
735
+ if (line.startsWith('## ')) {
736
+ return <Text color={colors.text} bold>{line.slice(3)}</Text>;
737
+ }
738
+ if (line.startsWith('### ')) {
739
+ return <Text color="#c9d1d9" bold>{line.slice(4)}</Text>;
740
+ }
741
+
742
+ // YAML frontmatter delimiter
743
+ if (line === '---') {
744
+ return <Text color={colors.textDim}>{line}</Text>;
745
+ }
746
+
747
+ // List items
748
+ if (line.match(/^[\s]*[-*]\s/)) {
749
+ return <Text color={colors.textMuted}>{line}</Text>;
750
+ }
751
+
752
+ // Blockquotes
753
+ if (line.startsWith('>')) {
754
+ return <Text color="#8b949e" italic>{line}</Text>;
755
+ }
756
+
757
+ // Table rows
758
+ if (line.includes('|')) {
759
+ return <Text color={colors.textMuted}>{line}</Text>;
760
+ }
761
+
762
+ // Default
763
+ return <Text color={colors.textMuted}>{line || ' '}</Text>;
764
+ }
765
+
766
+ function ReadmeViewer({
767
+ title,
768
+ content,
769
+ onClose,
770
+ }: {
771
+ title: string;
772
+ content: string;
773
+ onClose: () => void;
774
+ }) {
775
+ const [scrollOffset, setScrollOffset] = useState(0);
776
+ const lines = useMemo(() => content.split('\n'), [content]);
777
+ const maxVisible = 20;
778
+
779
+ // Pre-compute code block state for each line
780
+ const lineStates = useMemo(() => {
781
+ const states: boolean[] = [];
782
+ let inCode = false;
783
+ for (const line of lines) {
784
+ if (line.startsWith('```')) {
785
+ states.push(false); // Delimiter itself is not "in" code block for styling
786
+ inCode = !inCode;
787
+ } else {
788
+ states.push(inCode);
789
+ }
790
+ }
791
+ return states;
792
+ }, [lines]);
793
+
794
+ // Max offset: when at end, we have top indicator + (maxVisible-1) content lines
795
+ // So max offset is lines.length - (maxVisible - 1) = lines.length - maxVisible + 1
796
+ const maxOffset = Math.max(0, lines.length - maxVisible + 1);
797
+
798
+ useInput((input, key) => {
799
+ if (key.escape) {
800
+ onClose();
801
+ return;
802
+ }
803
+ if (key.upArrow) {
804
+ setScrollOffset((prev) => Math.max(0, prev - 1));
805
+ }
806
+ if (key.downArrow) {
807
+ setScrollOffset((prev) => Math.min(maxOffset, prev + 1));
808
+ }
809
+ if (key.pageDown || input === ' ') {
810
+ setScrollOffset((prev) => Math.min(maxOffset, prev + maxVisible));
811
+ }
812
+ if (key.pageUp) {
813
+ setScrollOffset((prev) => Math.max(0, prev - maxVisible));
814
+ }
815
+ });
816
+
817
+ // Adjust visible lines based on whether indicators are shown
818
+ const showTopIndicator = scrollOffset > 0;
819
+ // Reserve space for bottom indicator if not at end
820
+ const contentLines = maxVisible - (showTopIndicator ? 1 : 0);
821
+ const endIndex = Math.min(scrollOffset + contentLines, lines.length);
822
+ const showBottomIndicator = endIndex < lines.length;
823
+ const actualContentLines = contentLines - (showBottomIndicator ? 1 : 0);
824
+ const visibleLines = lines.slice(scrollOffset, scrollOffset + actualContentLines);
825
+
826
+ return (
827
+ <Box flexDirection="column" padding={1}>
828
+ <Box marginBottom={1}>
829
+ <Text color={colors.text} bold>{title}</Text>
830
+ <Text color={colors.textDim}> · {lines.length} lines</Text>
831
+ </Box>
832
+
833
+ <Box
834
+ flexDirection="column"
835
+ borderStyle="single"
836
+ borderColor={colors.border}
837
+ paddingX={1}
838
+ >
839
+ {showTopIndicator && (
840
+ <Text color={colors.textDim}>↑ {scrollOffset} more lines</Text>
841
+ )}
842
+ {visibleLines.map((line, i) => (
843
+ <MarkdownLine key={scrollOffset + i} line={line} inCodeBlock={lineStates[scrollOffset + i]} />
844
+ ))}
845
+ {showBottomIndicator && (
846
+ <Text color={colors.textDim}>↓ {lines.length - scrollOffset - actualContentLines} more lines</Text>
847
+ )}
848
+ </Box>
849
+
850
+ <Box marginTop={1}>
851
+ <Text color={colors.textDim}>↑↓ scroll · space/pgdn page · esc back</Text>
852
+ </Box>
853
+ </Box>
854
+ );
855
+ }
856
+
857
+ function SettingsDetails({
858
+ onEditSettings,
859
+ isFocused,
860
+ }: {
861
+ onEditSettings: () => void;
862
+ isFocused: boolean;
863
+ }) {
864
+ const config = loadConfig();
865
+ const outputOptions = getOutputOptions();
866
+
867
+ return (
868
+ <Box flexDirection="column" paddingLeft={2} flexGrow={1}>
869
+ <Text color={colors.text} bold>Settings</Text>
870
+
871
+ <Box flexDirection="column" marginTop={1}>
872
+ <Text>
873
+ <Text color={colors.textDim}>AI Tool: </Text>
874
+ <Text color={colors.text}>
875
+ {config.ai_tool === AITool.ClaudeCode ? 'Claude Code' : 'OpenCode'}
876
+ </Text>
877
+ </Text>
878
+ <Text>
879
+ <Text color={colors.textDim}>Your @mention: </Text>
880
+ <Text color={colors.text}>{config.user_mention}</Text>
881
+ </Text>
882
+ <Text>
883
+ <Text color={colors.textDim}>Output: </Text>
884
+ <Text color={colors.text}>
885
+ {outputOptions.find((o) => o.value === config.output_preference)?.label || config.output_preference}
886
+ </Text>
887
+ </Text>
888
+ </Box>
889
+
890
+ <Box marginTop={1}>
891
+ <Text color={colors.textDim}>Config: ~/.droid/config.yaml</Text>
892
+ </Box>
893
+
894
+ {isFocused && (
895
+ <Box marginTop={2}>
896
+ <Text backgroundColor={colors.primary} color="#ffffff" bold>
897
+ {' '}Edit Settings{' '}
898
+ </Text>
899
+ </Box>
900
+ )}
901
+
902
+ {!isFocused && (
903
+ <Box marginTop={2}>
904
+ <Text color={colors.textDim}>press enter to edit</Text>
905
+ </Box>
906
+ )}
907
+ </Box>
908
+ );
909
+ }
910
+
911
+ interface SkillConfigScreenProps {
912
+ skill: SkillManifest;
913
+ onComplete: () => void;
914
+ onCancel: () => void;
915
+ }
916
+
917
+ function SkillConfigScreen({ skill, onComplete, onCancel }: SkillConfigScreenProps) {
918
+ const configSchema = skill.config_schema || {};
919
+ const configKeys = Object.keys(configSchema);
920
+
921
+ const initialOverrides = useMemo(() => loadSkillOverrides(skill.name), [skill.name]);
922
+
923
+ // Initialize values from saved overrides or defaults
924
+ const [values, setValues] = useState<SkillOverrides>(() => {
925
+ const initial: SkillOverrides = {};
926
+ for (const key of configKeys) {
927
+ const option = configSchema[key];
928
+ initial[key] = initialOverrides[key] ?? option.default ?? (option.type === ConfigOptionType.Boolean ? false : '');
929
+ }
930
+ return initial;
931
+ });
932
+
933
+ const [selectedIndex, setSelectedIndex] = useState(0);
934
+ const [editingField, setEditingField] = useState<string | null>(null);
935
+ const [editValue, setEditValue] = useState('');
936
+
937
+ const handleSave = () => {
938
+ saveSkillOverrides(skill.name, values);
939
+ onComplete();
940
+ };
941
+
942
+ const handleSubmitEdit = () => {
943
+ if (editingField) {
944
+ setValues((prev) => ({ ...prev, [editingField]: editValue }));
945
+ setEditingField(null);
946
+ setEditValue('');
947
+ }
948
+ };
949
+
950
+ // Handle text input for string fields
951
+ useInput((input, key) => {
952
+ if (key.escape) {
953
+ setEditingField(null);
954
+ setEditValue('');
955
+ }
956
+ }, { isActive: editingField !== null });
957
+
958
+ // Handle navigation and actions when not editing
959
+ useInput((input, key) => {
960
+ if (key.escape) {
961
+ onCancel();
962
+ return;
963
+ }
964
+
965
+ if (key.upArrow) {
966
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
967
+ }
968
+ if (key.downArrow) {
969
+ // +1 for the Save button at the end
970
+ setSelectedIndex((prev) => Math.min(configKeys.length, prev + 1));
971
+ }
972
+
973
+ if (key.return) {
974
+ // Save button is at index === configKeys.length
975
+ if (selectedIndex === configKeys.length) {
976
+ handleSave();
977
+ return;
978
+ }
979
+
980
+ const key = configKeys[selectedIndex];
981
+ const option = configSchema[key];
982
+
983
+ if (option.type === ConfigOptionType.Boolean) {
984
+ // Toggle boolean
985
+ setValues((prev) => ({ ...prev, [key]: !prev[key] }));
986
+ } else if (option.type === ConfigOptionType.String) {
987
+ // Enter edit mode for string
988
+ setEditingField(key);
989
+ setEditValue(String(values[key] || ''));
990
+ }
991
+ }
992
+ }, { isActive: editingField === null });
993
+
994
+ if (configKeys.length === 0) {
995
+ return (
996
+ <Box flexDirection="column" padding={1}>
997
+ <Box marginBottom={1}>
998
+ <Text>
999
+ <Text color={colors.textDim}>[</Text>
1000
+ <Text color={colors.primary}>●</Text>
1001
+ <Text color={colors.textDim}> </Text>
1002
+ <Text color={colors.primary}>●</Text>
1003
+ <Text color={colors.textDim}>] </Text>
1004
+ <Text color={colors.text} bold>configure {skill.name}</Text>
1005
+ </Text>
1006
+ </Box>
1007
+ <Text color={colors.textMuted}>This skill has no configuration options.</Text>
1008
+ <Box marginTop={1}>
1009
+ <Text color={colors.textDim}>esc to go back</Text>
1010
+ </Box>
1011
+ </Box>
1012
+ );
1013
+ }
1014
+
1015
+ return (
1016
+ <Box flexDirection="column" padding={1}>
1017
+ <Box marginBottom={1}>
1018
+ <Text>
1019
+ <Text color={colors.textDim}>[</Text>
1020
+ <Text color={colors.primary}>●</Text>
1021
+ <Text color={colors.textDim}> </Text>
1022
+ <Text color={colors.primary}>●</Text>
1023
+ <Text color={colors.textDim}>] </Text>
1024
+ <Text color={colors.text} bold>configure {skill.name}</Text>
1025
+ </Text>
1026
+ </Box>
1027
+
1028
+ <Box flexDirection="column">
1029
+ {configKeys.map((key, index) => {
1030
+ const option = configSchema[key];
1031
+ const isSelected = selectedIndex === index;
1032
+ const isEditing = editingField === key;
1033
+
1034
+ return (
1035
+ <Box key={key} flexDirection="column" marginBottom={1}>
1036
+ <Text>
1037
+ <Text color={colors.textDim}>{isSelected ? '>' : ' '} </Text>
1038
+ <Text color={isSelected ? colors.text : colors.textMuted}>{key}</Text>
1039
+ </Text>
1040
+ <Text color={colors.textDim}> {option.description}</Text>
1041
+ <Box>
1042
+ <Text color={colors.textDim}> </Text>
1043
+ {option.type === ConfigOptionType.Boolean ? (
1044
+ <Text color={colors.text}>
1045
+ [{values[key] ? 'x' : ' '}] {values[key] ? 'enabled' : 'disabled'}
1046
+ </Text>
1047
+ ) : isEditing ? (
1048
+ <Box>
1049
+ <Text color={colors.textDim}>{'> '}</Text>
1050
+ <TextInput
1051
+ value={editValue}
1052
+ onChange={setEditValue}
1053
+ onSubmit={handleSubmitEdit}
1054
+ />
1055
+ </Box>
1056
+ ) : (
1057
+ <Text color={colors.text}>{String(values[key]) || '(not set)'}</Text>
1058
+ )}
1059
+ </Box>
1060
+ </Box>
1061
+ );
1062
+ })}
1063
+
1064
+ {/* Save button */}
1065
+ <Box marginTop={1}>
1066
+ <Text>
1067
+ <Text color={colors.textDim}>{selectedIndex === configKeys.length ? '>' : ' '} </Text>
1068
+ <Text
1069
+ backgroundColor={selectedIndex === configKeys.length ? colors.primary : undefined}
1070
+ color={selectedIndex === configKeys.length ? '#ffffff' : colors.textMuted}
1071
+ bold={selectedIndex === configKeys.length}
1072
+ >
1073
+ {' '}Save{' '}
1074
+ </Text>
1075
+ </Text>
1076
+ </Box>
1077
+ </Box>
1078
+
1079
+ <Box marginTop={1}>
1080
+ <Text color={colors.textDim}>
1081
+ {editingField ? 'enter save · esc cancel' : '↑↓ select · enter toggle/edit · esc back'}
1082
+ </Text>
1083
+ </Box>
1084
+ </Box>
1085
+ );
1086
+ }
1087
+
1088
+ function App() {
1089
+ const { exit } = useApp();
1090
+ const tabs: { id: Tab; label: string }[] = [
1091
+ { id: 'skills', label: 'Skills' },
1092
+ { id: 'commands', label: 'Commands' },
1093
+ { id: 'agents', label: 'Agents' },
1094
+ { id: 'settings', label: 'Settings' },
1095
+ ];
1096
+
1097
+ const [activeTab, setActiveTab] = useState<Tab>('skills');
1098
+ const [tabIndex, setTabIndex] = useState(0);
1099
+ const [view, setView] = useState<View>('welcome');
1100
+ const [selectedIndex, setSelectedIndex] = useState(0);
1101
+ const [selectedAction, setSelectedAction] = useState(0);
1102
+ const [scrollOffset, setScrollOffset] = useState(0);
1103
+ const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
1104
+ const [isEditingSettings, setIsEditingSettings] = useState(false);
1105
+ const [readmeContent, setReadmeContent] = useState<{ title: string; content: string } | null>(null);
1106
+
1107
+ const MAX_VISIBLE_ITEMS = 6;
1108
+
1109
+ const skills = getBundledSkills();
1110
+ const commands = getCommandsFromSkills();
1111
+ const agents = getBundledAgents();
1112
+
1113
+ useInput((input, key) => {
1114
+ if (message) setMessage(null);
1115
+
1116
+ if (view === 'welcome') return;
1117
+
1118
+ if (input === 'q') {
1119
+ exit();
1120
+ return;
1121
+ }
1122
+
1123
+ if (view === 'menu') {
1124
+ if (key.leftArrow) {
1125
+ const newIndex = Math.max(0, tabIndex - 1);
1126
+ setTabIndex(newIndex);
1127
+ setActiveTab(tabs[newIndex].id);
1128
+ setSelectedIndex(0);
1129
+ setScrollOffset(0);
1130
+ }
1131
+ if (key.rightArrow) {
1132
+ const newIndex = Math.min(tabs.length - 1, tabIndex + 1);
1133
+ setTabIndex(newIndex);
1134
+ setActiveTab(tabs[newIndex].id);
1135
+ setSelectedIndex(0);
1136
+ setScrollOffset(0);
1137
+ }
1138
+ if (key.upArrow) {
1139
+ setSelectedIndex((prev) => {
1140
+ const newIndex = Math.max(0, prev - 1);
1141
+ // Scroll up if needed
1142
+ if (newIndex < scrollOffset) {
1143
+ setScrollOffset(newIndex);
1144
+ }
1145
+ return newIndex;
1146
+ });
1147
+ setSelectedAction(0);
1148
+ }
1149
+ if (key.downArrow) {
1150
+ const maxIndex =
1151
+ activeTab === 'skills' ? skills.length - 1 : activeTab === 'commands' ? commands.length - 1 : 0;
1152
+ setSelectedIndex((prev) => {
1153
+ const newIndex = Math.min(maxIndex, prev + 1);
1154
+ // Scroll down if needed
1155
+ if (newIndex >= scrollOffset + MAX_VISIBLE_ITEMS) {
1156
+ setScrollOffset(newIndex - MAX_VISIBLE_ITEMS + 1);
1157
+ }
1158
+ return newIndex;
1159
+ });
1160
+ setSelectedAction(0);
1161
+ }
1162
+ if (key.return) {
1163
+ if (activeTab === 'skills' && skills.length > 0) {
1164
+ setView('detail');
1165
+ } else if (activeTab === 'commands' && commands.length > 0) {
1166
+ setView('detail');
1167
+ } else if (activeTab === 'agents' && agents.length > 0) {
1168
+ setView('detail');
1169
+ } else if (activeTab === 'settings') {
1170
+ setIsEditingSettings(true);
1171
+ setView('setup');
1172
+ }
1173
+ }
1174
+ } else if (view === 'detail') {
1175
+ if (key.escape || key.backspace) {
1176
+ setView('menu');
1177
+ setSelectedAction(0);
1178
+ }
1179
+ if (key.leftArrow) {
1180
+ setSelectedAction((prev) => Math.max(0, prev - 1));
1181
+ }
1182
+ if (key.rightArrow) {
1183
+ let maxActions = 0;
1184
+ if (activeTab === 'skills') {
1185
+ const skill = skills[selectedIndex];
1186
+ const installed = skill ? isSkillInstalled(skill.name) : false;
1187
+ maxActions = installed ? 2 : 1; // View, Configure, Uninstall or View, Install
1188
+ } else if (activeTab === 'agents') {
1189
+ maxActions = 1; // View, Install/Uninstall
1190
+ } else if (activeTab === 'commands') {
1191
+ const command = commands[selectedIndex];
1192
+ // If parent skill is installed, only View is available
1193
+ const skillInstalled = command ? isSkillInstalled(command.skillName) : false;
1194
+ maxActions = skillInstalled ? 0 : 1;
1195
+ }
1196
+ setSelectedAction((prev) => Math.min(maxActions, prev + 1));
1197
+ }
1198
+ if (key.return && activeTab === 'skills') {
1199
+ const skill = skills[selectedIndex];
1200
+ if (skill) {
1201
+ const installed = isSkillInstalled(skill.name);
1202
+ // Actions: installed = [View, Configure, Uninstall], not installed = [View, Install]
1203
+ if (selectedAction === 0) {
1204
+ // View
1205
+ const skillMdPath = join(getBundledSkillsDir(), skill.name, 'SKILL.md');
1206
+ if (existsSync(skillMdPath)) {
1207
+ const content = readFileSync(skillMdPath, 'utf-8');
1208
+ setReadmeContent({ title: `${skill.name}/SKILL.md`, content });
1209
+ setView('readme');
1210
+ }
1211
+ } else if (installed && selectedAction === 1) {
1212
+ // Configure
1213
+ setView('configure');
1214
+ } else if (installed && selectedAction === 2) {
1215
+ // Uninstall
1216
+ const result = uninstallSkill(skill.name);
1217
+ setMessage({
1218
+ text: result.success ? `✓ Uninstalled ${skill.name}` : `✗ ${result.message}`,
1219
+ type: result.success ? 'success' : 'error',
1220
+ });
1221
+ if (result.success) {
1222
+ setView('menu');
1223
+ setSelectedAction(0);
1224
+ }
1225
+ } else if (!installed && selectedAction === 1) {
1226
+ // Install
1227
+ const result = installSkill(skill.name);
1228
+ setMessage({
1229
+ text: result.success ? `✓ Installed ${skill.name}` : `✗ ${result.message}`,
1230
+ type: result.success ? 'success' : 'error',
1231
+ });
1232
+ if (result.success) {
1233
+ setView('menu');
1234
+ setSelectedAction(0);
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ if (key.return && activeTab === 'agents') {
1240
+ const agent = agents[selectedIndex];
1241
+ if (agent) {
1242
+ const installed = isAgentInstalled(agent.name);
1243
+ if (selectedAction === 0) {
1244
+ // View
1245
+ const agentMdPath = join(getBundledAgentsDir(), agent.name, 'AGENT.md');
1246
+ if (existsSync(agentMdPath)) {
1247
+ const content = readFileSync(agentMdPath, 'utf-8');
1248
+ setReadmeContent({ title: `${agent.name}/AGENT.md`, content });
1249
+ setView('readme');
1250
+ }
1251
+ } else if (installed && selectedAction === 1) {
1252
+ // Uninstall
1253
+ const result = uninstallAgent(agent.name);
1254
+ setMessage({
1255
+ text: result.success ? `✓ Uninstalled ${agent.name}` : `✗ ${result.message}`,
1256
+ type: result.success ? 'success' : 'error',
1257
+ });
1258
+ if (result.success) {
1259
+ setView('menu');
1260
+ setSelectedAction(0);
1261
+ }
1262
+ } else if (!installed && selectedAction === 1) {
1263
+ // Install
1264
+ const result = installAgent(agent.name);
1265
+ setMessage({
1266
+ text: result.success ? `✓ ${result.message}` : `✗ ${result.message}`,
1267
+ type: result.success ? 'success' : 'error',
1268
+ });
1269
+ if (result.success) {
1270
+ setView('menu');
1271
+ setSelectedAction(0);
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1276
+ if (key.return && activeTab === 'commands') {
1277
+ const command = commands[selectedIndex];
1278
+ if (command) {
1279
+ const installed = isCommandInstalled(command.name, command.skillName);
1280
+ // Command file: extract part after skill name (e.g., "comments check" → "check.md")
1281
+ const cmdPart = command.name.startsWith(command.skillName + ' ')
1282
+ ? command.name.slice(command.skillName.length + 1)
1283
+ : command.name;
1284
+ const commandMdPath = join(getBundledSkillsDir(), command.skillName, 'commands', `${cmdPart}.md`);
1285
+
1286
+ if (selectedAction === 0) {
1287
+ // View
1288
+ if (existsSync(commandMdPath)) {
1289
+ const content = readFileSync(commandMdPath, 'utf-8');
1290
+ setReadmeContent({ title: `/${command.name}`, content });
1291
+ setView('readme');
1292
+ }
1293
+ } else if (installed && selectedAction === 1) {
1294
+ // Uninstall
1295
+ const result = uninstallCommand(command.name, command.skillName);
1296
+ setMessage({
1297
+ text: result.success ? `✓ ${result.message}` : `✗ ${result.message}`,
1298
+ type: result.success ? 'success' : 'error',
1299
+ });
1300
+ if (result.success) {
1301
+ setView('menu');
1302
+ setSelectedAction(0);
1303
+ }
1304
+ } else if (!installed && selectedAction === 1) {
1305
+ // Install
1306
+ const result = installCommand(command.name, command.skillName);
1307
+ setMessage({
1308
+ text: result.success ? `✓ ${result.message}` : `✗ ${result.message}`,
1309
+ type: result.success ? 'success' : 'error',
1310
+ });
1311
+ if (result.success) {
1312
+ setView('menu');
1313
+ setSelectedAction(0);
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ });
1320
+
1321
+ const selectedSkill = activeTab === 'skills' ? skills[selectedIndex] ?? null : null;
1322
+ const selectedCommand = activeTab === 'commands' ? commands[selectedIndex] ?? null : null;
1323
+ const selectedAgent = activeTab === 'agents' ? agents[selectedIndex] ?? null : null;
1324
+
1325
+ if (view === 'welcome') {
1326
+ return (
1327
+ <WelcomeScreen
1328
+ onContinue={() => {
1329
+ // If no config exists, show setup first
1330
+ if (!configExists()) {
1331
+ setView('setup');
1332
+ } else {
1333
+ setView('menu');
1334
+ }
1335
+ }}
1336
+ />
1337
+ );
1338
+ }
1339
+
1340
+ if (view === 'setup') {
1341
+ return (
1342
+ <SetupScreen
1343
+ onComplete={() => {
1344
+ setIsEditingSettings(false);
1345
+ setView('menu');
1346
+ }}
1347
+ onSkip={() => {
1348
+ setIsEditingSettings(false);
1349
+ setView('menu');
1350
+ }}
1351
+ initialConfig={isEditingSettings ? loadConfig() : undefined}
1352
+ />
1353
+ );
1354
+ }
1355
+
1356
+ if (view === 'readme' && readmeContent) {
1357
+ return (
1358
+ <ReadmeViewer
1359
+ title={readmeContent.title}
1360
+ content={readmeContent.content}
1361
+ onClose={() => {
1362
+ setReadmeContent(null);
1363
+ setView('detail');
1364
+ }}
1365
+ />
1366
+ );
1367
+ }
1368
+
1369
+ if (view === 'configure' && selectedSkill) {
1370
+ return (
1371
+ <SkillConfigScreen
1372
+ skill={selectedSkill}
1373
+ onComplete={() => {
1374
+ setMessage({ text: `✓ Configuration saved for ${selectedSkill.name}`, type: 'success' });
1375
+ setView('detail');
1376
+ }}
1377
+ onCancel={() => {
1378
+ setView('detail');
1379
+ }}
1380
+ />
1381
+ );
1382
+ }
1383
+
1384
+ return (
1385
+ <Box flexDirection="row" padding={1}>
1386
+ {/* Left column */}
1387
+ <Box
1388
+ flexDirection="column"
1389
+ width={44}
1390
+ borderStyle="single"
1391
+ borderColor={colors.border}
1392
+ >
1393
+ {/* Header */}
1394
+ <Box paddingX={1}>
1395
+ <Text>
1396
+ <Text color={colors.textDim}>[</Text>
1397
+ <Text color={colors.primary}>●</Text>
1398
+ <Text color={colors.textDim}> </Text>
1399
+ <Text color={colors.primary}>●</Text>
1400
+ <Text color={colors.textDim}>] </Text>
1401
+ <Text color={colors.textMuted}>droid</Text>
1402
+ </Text>
1403
+ </Box>
1404
+
1405
+ {/* Tabs */}
1406
+ <Box paddingX={1} marginTop={1}>
1407
+ <TabBar tabs={tabs} activeTab={activeTab} />
1408
+ </Box>
1409
+
1410
+ {/* List */}
1411
+ <Box flexDirection="column" marginTop={1}>
1412
+ {activeTab === 'skills' && (
1413
+ <>
1414
+ {scrollOffset > 0 && (
1415
+ <Box paddingX={1}>
1416
+ <Text color={colors.textDim}>↑ {scrollOffset} more</Text>
1417
+ </Box>
1418
+ )}
1419
+ {skills.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS).map((skill, index) => (
1420
+ <SkillItem
1421
+ key={skill.name}
1422
+ skill={skill}
1423
+ isSelected={scrollOffset + index === selectedIndex}
1424
+ isActive={scrollOffset + index === selectedIndex && view === 'detail'}
1425
+ />
1426
+ ))}
1427
+ {scrollOffset + MAX_VISIBLE_ITEMS < skills.length && (
1428
+ <Box paddingX={1}>
1429
+ <Text color={colors.textDim}>↓ {skills.length - scrollOffset - MAX_VISIBLE_ITEMS} more</Text>
1430
+ </Box>
1431
+ )}
1432
+ {skills.length > MAX_VISIBLE_ITEMS && (
1433
+ <Box paddingX={1} marginTop={1}>
1434
+ <Text color={colors.textDim}>{skills.length} skills total</Text>
1435
+ </Box>
1436
+ )}
1437
+ </>
1438
+ )}
1439
+
1440
+ {activeTab === 'commands' && (
1441
+ <>
1442
+ {scrollOffset > 0 && (
1443
+ <Box paddingX={1}>
1444
+ <Text color={colors.textDim}>↑ {scrollOffset} more</Text>
1445
+ </Box>
1446
+ )}
1447
+ {commands.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS).map((cmd, index) => (
1448
+ <CommandItem key={cmd.name} command={cmd} isSelected={scrollOffset + index === selectedIndex} />
1449
+ ))}
1450
+ {scrollOffset + MAX_VISIBLE_ITEMS < commands.length && (
1451
+ <Box paddingX={1}>
1452
+ <Text color={colors.textDim}>↓ {commands.length - scrollOffset - MAX_VISIBLE_ITEMS} more</Text>
1453
+ </Box>
1454
+ )}
1455
+ </>
1456
+ )}
1457
+
1458
+ {activeTab === 'agents' && (
1459
+ <>
1460
+ {scrollOffset > 0 && (
1461
+ <Box paddingX={1}>
1462
+ <Text color={colors.textDim}>↑ {scrollOffset} more</Text>
1463
+ </Box>
1464
+ )}
1465
+ {agents.length === 0 ? (
1466
+ <Box paddingX={1}>
1467
+ <Text color={colors.textDim}>No agents available</Text>
1468
+ </Box>
1469
+ ) : (
1470
+ agents.slice(scrollOffset, scrollOffset + MAX_VISIBLE_ITEMS).map((agent, index) => (
1471
+ <AgentItem key={agent.name} agent={agent} isSelected={scrollOffset + index === selectedIndex} />
1472
+ ))
1473
+ )}
1474
+ {scrollOffset + MAX_VISIBLE_ITEMS < agents.length && (
1475
+ <Box paddingX={1}>
1476
+ <Text color={colors.textDim}>↓ {agents.length - scrollOffset - MAX_VISIBLE_ITEMS} more</Text>
1477
+ </Box>
1478
+ )}
1479
+ </>
1480
+ )}
1481
+
1482
+ {activeTab === 'settings' && (
1483
+ <Box paddingX={1}>
1484
+ <Text color={colors.textDim}>View and edit config</Text>
1485
+ </Box>
1486
+ )}
1487
+ </Box>
1488
+
1489
+ {/* Footer */}
1490
+ <Box paddingX={1} marginTop={1}>
1491
+ <Text color={colors.textDim}>
1492
+ {view === 'menu' ? '←→ ↑↓ enter q' : '←→ enter esc q'}
1493
+ </Text>
1494
+ </Box>
1495
+ </Box>
1496
+
1497
+ {/* Right column */}
1498
+ {activeTab === 'skills' && (
1499
+ <SkillDetails skill={selectedSkill} isFocused={view === 'detail'} selectedAction={selectedAction} />
1500
+ )}
1501
+
1502
+ {activeTab === 'commands' && (
1503
+ <CommandDetails command={selectedCommand} isFocused={view === 'detail'} selectedAction={selectedAction} />
1504
+ )}
1505
+
1506
+ {activeTab === 'settings' && (
1507
+ <SettingsDetails onEditSettings={() => setView('setup')} isFocused={false} />
1508
+ )}
1509
+
1510
+ {activeTab === 'agents' && (
1511
+ <AgentDetails agent={selectedAgent} isFocused={view === 'detail'} selectedAction={selectedAction} />
1512
+ )}
1513
+
1514
+ {/* Message */}
1515
+ {message && (
1516
+ <Box position="absolute" marginTop={12}>
1517
+ <Text color={message.type === 'success' ? colors.success : colors.error}>{message.text}</Text>
1518
+ </Box>
1519
+ )}
1520
+ </Box>
1521
+ );
1522
+ }
1523
+
1524
+ export async function tuiCommand(): Promise<void> {
1525
+ // Enter alternate screen (fullscreen mode)
1526
+ process.stdout.write('\x1b[?1049h');
1527
+ process.stdout.write('\x1b[H'); // Move cursor to top-left
1528
+
1529
+ const { unmount, waitUntilExit } = render(<App />);
1530
+
1531
+ await waitUntilExit();
1532
+
1533
+ // Leave alternate screen
1534
+ process.stdout.write('\x1b[?1049l');
1535
+ }