agent-facets 0.1.1

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 (39) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bunfig.toml +2 -0
  3. package/dist/facet +0 -0
  4. package/dist/facet-ink-test +0 -0
  5. package/package.json +41 -0
  6. package/src/__tests__/cli.test.ts +152 -0
  7. package/src/__tests__/create-build.test.ts +207 -0
  8. package/src/commands/build.ts +45 -0
  9. package/src/commands/create/index.ts +30 -0
  10. package/src/commands/create/types.ts +9 -0
  11. package/src/commands/create/wizard.tsx +24 -0
  12. package/src/commands/create-scaffold.ts +180 -0
  13. package/src/commands.ts +31 -0
  14. package/src/help.ts +36 -0
  15. package/src/index.ts +9 -0
  16. package/src/run.ts +55 -0
  17. package/src/suggest.ts +35 -0
  18. package/src/tui/components/asset-inline-input.tsx +79 -0
  19. package/src/tui/components/asset-item.tsx +48 -0
  20. package/src/tui/components/asset-section.tsx +145 -0
  21. package/src/tui/components/button.tsx +92 -0
  22. package/src/tui/components/editable-field.tsx +172 -0
  23. package/src/tui/components/exit-toast.tsx +20 -0
  24. package/src/tui/components/stage-row.tsx +33 -0
  25. package/src/tui/components/version-selector.tsx +79 -0
  26. package/src/tui/context/focus-mode-context.ts +36 -0
  27. package/src/tui/context/focus-order-context.ts +62 -0
  28. package/src/tui/context/form-state-context.ts +229 -0
  29. package/src/tui/gradient.ts +1 -0
  30. package/src/tui/hooks/use-exit-keys.ts +75 -0
  31. package/src/tui/hooks/use-navigation-keys.ts +34 -0
  32. package/src/tui/layouts/wizard-layout.tsx +41 -0
  33. package/src/tui/theme.ts +1 -0
  34. package/src/tui/views/build/build-view.tsx +153 -0
  35. package/src/tui/views/create/confirm-view.tsx +74 -0
  36. package/src/tui/views/create/create-view.tsx +154 -0
  37. package/src/tui/views/create/wizard.tsx +68 -0
  38. package/src/version.ts +3 -0
  39. package/tsconfig.json +4 -0
@@ -0,0 +1,180 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { FACET_MANIFEST_FILE } from '@agent-facets/core'
4
+
5
+ // --- Types ---
6
+
7
+ export interface CreateOptions {
8
+ name: string
9
+ version: string
10
+ description: string
11
+ skills: string[]
12
+ agents: string[]
13
+ commands: string[]
14
+ }
15
+
16
+ // --- Validation ---
17
+
18
+ export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
19
+ export const SEMVER = /^\d+\.\d+\.\d+$/
20
+
21
+ export function isValidKebabCase(value: string): boolean {
22
+ return KEBAB_CASE.test(value)
23
+ }
24
+
25
+ export function isValidSemVer(value: string): boolean {
26
+ return SEMVER.test(value)
27
+ }
28
+
29
+ // --- Template generation ---
30
+
31
+ function toTitleCase(kebab: string): string {
32
+ return kebab
33
+ .split('-')
34
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
35
+ .join(' ')
36
+ }
37
+
38
+ function skillTemplate(name: string): string {
39
+ return `# ${toTitleCase(name)}
40
+
41
+ <!-- This is a starter skill template. Replace this content with your skill's instructions. -->
42
+ <!-- Skills provide reusable knowledge and guidelines that agents and commands can reference. -->
43
+ <!-- A skill needs a description (required) — the description helps consumers decide -->
44
+ <!-- whether to use this skill. The prompt content is this file. -->
45
+
46
+ ## Purpose
47
+
48
+ Describe what this skill teaches or what guidelines it provides.
49
+
50
+ ## Guidelines
51
+
52
+ - Add your skill's guidelines here
53
+ - Each guideline should be clear and actionable
54
+ `
55
+ }
56
+
57
+ function agentTemplate(name: string): string {
58
+ return `# ${toTitleCase(name)}
59
+
60
+ <!-- This is a starter agent template. Replace this content with your agent's prompt. -->
61
+ <!-- Agents are AI assistant personas with specific roles, behaviors, and tool access. -->
62
+
63
+ ## Role
64
+
65
+ Describe this agent's role and responsibilities.
66
+
67
+ ## Behavior
68
+
69
+ - Define how this agent should behave
70
+ - Specify what tools it should use
71
+ - Describe its communication style
72
+ `
73
+ }
74
+
75
+ function commandTemplate(name: string): string {
76
+ return `# ${toTitleCase(name)}
77
+
78
+ <!-- This is a starter command template. Replace this content with your command's prompt. -->
79
+ <!-- Commands are user-invokable actions that perform specific tasks. -->
80
+
81
+ ## Task
82
+
83
+ Describe what this command does when invoked.
84
+
85
+ ## Steps
86
+
87
+ 1. First step
88
+ 2. Second step
89
+ 3. Final step
90
+ `
91
+ }
92
+
93
+ // --- Manifest generation ---
94
+
95
+ export function generateManifest(opts: CreateOptions): string {
96
+ const manifest: Record<string, unknown> = {
97
+ name: opts.name,
98
+ version: opts.version,
99
+ }
100
+
101
+ if (opts.description) {
102
+ manifest.description = opts.description
103
+ }
104
+
105
+ if (opts.skills.length > 0) {
106
+ const skills: Record<string, { description: string }> = {}
107
+ for (const skill of opts.skills) {
108
+ skills[skill] = { description: `A ${toTitleCase(skill)} skill` }
109
+ }
110
+ manifest.skills = skills
111
+ }
112
+
113
+ if (opts.agents.length > 0) {
114
+ const agents: Record<string, { description: string }> = {}
115
+ for (const agent of opts.agents) {
116
+ agents[agent] = { description: `A ${toTitleCase(agent)} agent` }
117
+ }
118
+ manifest.agents = agents
119
+ }
120
+
121
+ if (opts.commands.length > 0) {
122
+ const commands: Record<string, { description: string }> = {}
123
+ for (const command of opts.commands) {
124
+ commands[command] = { description: `A ${toTitleCase(command)} command` }
125
+ }
126
+ manifest.commands = commands
127
+ }
128
+
129
+ return JSON.stringify(manifest, null, 2)
130
+ }
131
+
132
+ // --- File listing preview ---
133
+
134
+ export function previewFiles(opts: CreateOptions): string[] {
135
+ const files: string[] = [FACET_MANIFEST_FILE]
136
+ for (const skill of opts.skills) {
137
+ files.push(`skills/${skill}.md`)
138
+ }
139
+ for (const agent of opts.agents) {
140
+ files.push(`agents/${agent}.md`)
141
+ }
142
+ for (const command of opts.commands) {
143
+ files.push(`commands/${command}.md`)
144
+ }
145
+ return files
146
+ }
147
+
148
+ // --- Scaffold writing ---
149
+
150
+ export async function writeScaffold(opts: CreateOptions, targetDir: string): Promise<string[]> {
151
+ const files: string[] = []
152
+
153
+ // Write manifest
154
+ const manifestPath = join(targetDir, FACET_MANIFEST_FILE)
155
+ await Bun.write(manifestPath, generateManifest(opts))
156
+ files.push(FACET_MANIFEST_FILE)
157
+
158
+ // Write skill files
159
+ for (const skill of opts.skills) {
160
+ await mkdir(join(targetDir, 'skills'), { recursive: true })
161
+ await Bun.write(join(targetDir, `skills/${skill}.md`), skillTemplate(skill))
162
+ files.push(`skills/${skill}.md`)
163
+ }
164
+
165
+ // Write agent files
166
+ for (const agent of opts.agents) {
167
+ await mkdir(join(targetDir, 'agents'), { recursive: true })
168
+ await Bun.write(join(targetDir, `agents/${agent}.md`), agentTemplate(agent))
169
+ files.push(`agents/${agent}.md`)
170
+ }
171
+
172
+ // Write command files
173
+ for (const command of opts.commands) {
174
+ await mkdir(join(targetDir, 'commands'), { recursive: true })
175
+ await Bun.write(join(targetDir, `commands/${command}.md`), commandTemplate(command))
176
+ files.push(`commands/${command}.md`)
177
+ }
178
+
179
+ return files
180
+ }
@@ -0,0 +1,31 @@
1
+ import { buildCommand } from './commands/build.ts'
2
+ import { createCommand } from './commands/create/index.ts'
3
+
4
+ export type Command = {
5
+ name: string
6
+ description: string
7
+ run: (args: string[]) => Promise<number>
8
+ }
9
+
10
+ function stubCommand(name: string, description: string): Command {
11
+ return {
12
+ name,
13
+ description,
14
+ run: async () => {
15
+ console.log(`"${name}" is not yet implemented.`)
16
+ return 0
17
+ },
18
+ }
19
+ }
20
+
21
+ export const commands: Record<string, Command> = {
22
+ add: stubCommand('add', 'Add a facet to the project'),
23
+ build: buildCommand,
24
+ create: createCommand,
25
+ info: stubCommand('info', 'Show information about a facet'),
26
+ install: stubCommand('install', 'Install all facets from the lockfile'),
27
+ list: stubCommand('list', 'List installed facets'),
28
+ publish: stubCommand('publish', 'Publish a facet to the registry'),
29
+ remove: stubCommand('remove', 'Remove a facet from the project'),
30
+ upgrade: stubCommand('upgrade', 'Upgrade installed facets'),
31
+ }
package/src/help.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Command } from './commands.ts'
2
+ import { commands } from './commands.ts'
3
+ import { version } from './version.ts'
4
+
5
+ export function printGlobalHelp(): void {
6
+ const entries = Object.values(commands)
7
+ const maxNameLength = Math.max(...entries.map((c) => c.name.length))
8
+
9
+ const lines = [
10
+ `facet v${version}`,
11
+ '',
12
+ 'Usage: facet <command> [options]',
13
+ '',
14
+ 'Commands:',
15
+ ...entries.map((c) => ` ${c.name.padEnd(maxNameLength + 2)}${c.description}`),
16
+ '',
17
+ 'Options:',
18
+ ' --help Show help',
19
+ ' --version Show version',
20
+ ]
21
+
22
+ console.log(lines.join('\n'))
23
+ }
24
+
25
+ export function printCommandHelp(command: Command): void {
26
+ const lines = [
27
+ `Usage: facet ${command.name} [options]`,
28
+ '',
29
+ ` ${command.description}`,
30
+ '',
31
+ 'Options:',
32
+ ' --help Show help',
33
+ ]
34
+
35
+ console.log(lines.join('\n'))
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { run } from './run.ts'
2
+
3
+ try {
4
+ const code = await run(process.argv.slice(2))
5
+ process.exit(code)
6
+ } catch (error) {
7
+ console.error(error instanceof Error ? error.message : 'An unexpected error occurred.')
8
+ process.exit(2)
9
+ }
package/src/run.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { parse } from '@bomb.sh/args'
2
+ import { commands } from './commands.ts'
3
+ import { printCommandHelp, printGlobalHelp } from './help.ts'
4
+ import { findClosestCommand } from './suggest.ts'
5
+ import { version } from './version.ts'
6
+
7
+ export async function run(argv: string[]): Promise<number> {
8
+ const args = parse(argv, {
9
+ boolean: ['help', 'version'],
10
+ })
11
+
12
+ if (args.version) {
13
+ console.log(version)
14
+ return 0
15
+ }
16
+
17
+ const commandName = String(args._[0] ?? '')
18
+
19
+ // No command given — show global help
20
+ if (!commandName) {
21
+ printGlobalHelp()
22
+ return 0
23
+ }
24
+
25
+ // Explicit `help` command: `facets help` or `facets help build`
26
+ if (commandName === 'help') {
27
+ const subCommandName = String(args._[1] ?? '')
28
+ const subCommand = subCommandName ? commands[subCommandName] : undefined
29
+ if (subCommand) {
30
+ printCommandHelp(subCommand)
31
+ } else {
32
+ printGlobalHelp()
33
+ }
34
+ return 0
35
+ }
36
+
37
+ const command = commands[commandName]
38
+
39
+ if (!command) {
40
+ const suggestion = findClosestCommand(commandName, Object.keys(commands))
41
+ const message = suggestion
42
+ ? `Unknown command "${commandName}". Did you mean "${suggestion}"?`
43
+ : `Unknown command "${commandName}".`
44
+ console.error(message)
45
+ return 1
46
+ }
47
+
48
+ // Per-command help: `facets build --help`
49
+ if (args.help) {
50
+ printCommandHelp(command)
51
+ return 0
52
+ }
53
+
54
+ return command.run(argv.slice(1))
55
+ }
package/src/suggest.ts ADDED
@@ -0,0 +1,35 @@
1
+ function levenshtein(a: string, b: string): number {
2
+ const m = a.length
3
+ const n = b.length
4
+ const row: number[] = []
5
+
6
+ for (let j = 0; j <= n; j++) row.push(j)
7
+
8
+ for (let i = 1; i <= m; i++) {
9
+ let prev = row[0] ?? 0
10
+ row[0] = i
11
+ for (let j = 1; j <= n; j++) {
12
+ const current = row[j] ?? 0
13
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
14
+ row[j] = Math.min(current + 1, (row[j - 1] ?? 0) + 1, prev + cost)
15
+ prev = current
16
+ }
17
+ }
18
+
19
+ return row[n] ?? 0
20
+ }
21
+
22
+ export function findClosestCommand(input: string, commandNames: string[]): string | undefined {
23
+ let best: string | undefined
24
+ let bestDistance = Infinity
25
+
26
+ for (const name of commandNames) {
27
+ const distance = levenshtein(input, name)
28
+ if (distance < bestDistance) {
29
+ bestDistance = distance
30
+ best = name
31
+ }
32
+ }
33
+
34
+ return bestDistance <= 3 ? best : undefined
35
+ }
@@ -0,0 +1,79 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+ import TextInput from 'ink-text-input'
3
+ import { THEME } from '../theme.ts'
4
+
5
+ export function AssetInlineInput({
6
+ value,
7
+ placeholder,
8
+ error,
9
+ isFocused,
10
+ onChange,
11
+ validate,
12
+ onError,
13
+ onSubmit,
14
+ onCancel,
15
+ }: {
16
+ id: string
17
+ value: string
18
+ placeholder?: string
19
+ error: string
20
+ isFocused: boolean
21
+ onChange: (value: string) => void
22
+ validate?: (value: string) => string | undefined
23
+ onError: (error: string) => void
24
+ onSubmit: (name: string) => void
25
+ onCancel: () => void
26
+ }) {
27
+ useInput(
28
+ (_input, key) => {
29
+ if (!isFocused) return
30
+ if (key.return) {
31
+ const name = value || placeholder || ''
32
+ if (!name) {
33
+ onCancel()
34
+ return
35
+ }
36
+ if (validate) {
37
+ const err = validate(name)
38
+ if (err) {
39
+ onError(err)
40
+ return
41
+ }
42
+ }
43
+ onSubmit(name)
44
+ return
45
+ }
46
+ if (key.escape) {
47
+ onCancel()
48
+ return
49
+ }
50
+ if (key.tab && !value && placeholder) {
51
+ onChange(placeholder)
52
+ return
53
+ }
54
+ },
55
+ { isActive: isFocused },
56
+ )
57
+
58
+ return (
59
+ <Box marginLeft={2} gap={1}>
60
+ <Text color={THEME.tertiary}>{'>'}</Text>
61
+ <TextInput value={value} onChange={onChange} placeholder={placeholder} focus={isFocused} />
62
+ {error ? (
63
+ <Text color={THEME.warning}>· {error}</Text>
64
+ ) : value ? (
65
+ <Text color={THEME.hint}>
66
+ · <Text color={THEME.keyword}>Enter</Text> to save · <Text color={THEME.keyword}>Escape</Text> to revert
67
+ </Text>
68
+ ) : placeholder ? (
69
+ <Text color={THEME.hint}>
70
+ · <Text color={THEME.keyword}>Tab</Text> to fill · <Text color={THEME.keyword}>Escape</Text> to cancel
71
+ </Text>
72
+ ) : (
73
+ <Text color={THEME.hint}>
74
+ · <Text color={THEME.keyword}>Enter</Text> to add · <Text color={THEME.keyword}>Escape</Text> to cancel
75
+ </Text>
76
+ )}
77
+ </Box>
78
+ )
79
+ }
@@ -0,0 +1,48 @@
1
+ import { Box, Text, useInput } from 'ink'
2
+ import { THEME } from '../theme.ts'
3
+
4
+ export function AssetItem({
5
+ name,
6
+ isFocused,
7
+ onEdit,
8
+ onRemove,
9
+ }: {
10
+ id: string
11
+ name: string
12
+ isFocused: boolean
13
+ onEdit: () => void
14
+ onRemove: () => void
15
+ }) {
16
+ useInput(
17
+ (_input, key) => {
18
+ if (key.return) {
19
+ onEdit()
20
+ }
21
+ if (key.delete || key.backspace) {
22
+ onRemove()
23
+ }
24
+ },
25
+ { isActive: isFocused },
26
+ )
27
+
28
+ return (
29
+ <Box gap={1} marginLeft={2}>
30
+ {isFocused ? (
31
+ <>
32
+ <Text color={THEME.primary} bold>
33
+
34
+ </Text>
35
+ <Text color={THEME.primary}>{name}</Text>
36
+ <Text color={THEME.hint}>
37
+ <Text color={THEME.keyword}>Enter</Text> to edit · <Text color={THEME.keyword}>Delete</Text> to remove
38
+ </Text>
39
+ </>
40
+ ) : (
41
+ <>
42
+ <Text color={THEME.success}>•</Text>
43
+ <Text>{name}</Text>
44
+ </>
45
+ )}
46
+ </Box>
47
+ )
48
+ }
@@ -0,0 +1,145 @@
1
+ import { Box, Text } from 'ink'
2
+ import { useState } from 'react'
3
+ import { useFocusMode } from '../context/focus-mode-context.ts'
4
+ import { useFocusOrder } from '../context/focus-order-context.ts'
5
+ import type { AssetSectionKey } from '../context/form-state-context.ts'
6
+ import { useFormState } from '../context/form-state-context.ts'
7
+ import { AssetInlineInput } from './asset-inline-input.tsx'
8
+ import { AssetItem } from './asset-item.tsx'
9
+ import { Button } from './button.tsx'
10
+
11
+ export function AssetSection({
12
+ section,
13
+ label,
14
+ defaultName,
15
+ dimmed,
16
+ validate,
17
+ }: {
18
+ section: AssetSectionKey
19
+ label: string
20
+ defaultName?: string
21
+ dimmed?: boolean
22
+ validate?: (value: string) => string | undefined
23
+ }) {
24
+ const { form, addAsset, removeAsset, renameAsset, setAssetAdding, setAssetEditing } = useFormState()
25
+ const { items, editing, adding } = form.assets[section]
26
+ const { setMode } = useFocusMode()
27
+ const { focusedId, focus } = useFocusOrder()
28
+ const [inputValue, setInputValue] = useState('')
29
+ const [error, setError] = useState('')
30
+
31
+ const startAdding = () => {
32
+ setAssetAdding(section, true)
33
+ setInputValue('')
34
+ setError('')
35
+ setMode('field-revision')
36
+ }
37
+
38
+ const startEditing = (name: string) => {
39
+ setAssetEditing(section, name)
40
+ setInputValue(name)
41
+ setError('')
42
+ setMode('field-revision')
43
+ }
44
+
45
+ const closeInput = () => {
46
+ setAssetAdding(section, false)
47
+ setAssetEditing(section, undefined)
48
+ setInputValue('')
49
+ setError('')
50
+ setMode('form-navigation')
51
+ focus(`add-${section}`)
52
+ }
53
+
54
+ const handleRemove = (name: string) => {
55
+ const index = items.indexOf(name)
56
+ removeAsset(section, name)
57
+
58
+ if (index < items.length - 1) {
59
+ focus(`item-${section}-${index}`)
60
+ } else if (index > 0) {
61
+ focus(`item-${section}-${index - 1}`)
62
+ } else {
63
+ focus(`add-${section}`)
64
+ }
65
+ }
66
+
67
+ return (
68
+ <Box flexDirection="column" gap={0}>
69
+ <Box gap={1}>
70
+ <Text bold dimColor={dimmed}>
71
+ {label}
72
+ </Text>
73
+ {items.length === 0 && !adding && <Text dimColor>(none)</Text>}
74
+ </Box>
75
+
76
+ {items.map((item, i) => {
77
+ const itemId = `item-${section}-${i}`
78
+ const isFocusedItem = focusedId === itemId
79
+
80
+ if (editing === item) {
81
+ return (
82
+ <AssetInlineInput
83
+ key={itemId}
84
+ id={itemId}
85
+ value={inputValue}
86
+ placeholder={item}
87
+ error={error}
88
+ isFocused={isFocusedItem}
89
+ onChange={setInputValue}
90
+ validate={validate}
91
+ onError={setError}
92
+ onSubmit={(newName) => {
93
+ renameAsset(section, item, newName)
94
+ closeInput()
95
+ }}
96
+ onCancel={closeInput}
97
+ />
98
+ )
99
+ }
100
+
101
+ return (
102
+ <AssetItem
103
+ key={itemId}
104
+ id={itemId}
105
+ name={item}
106
+ isFocused={isFocusedItem}
107
+ onEdit={() => startEditing(item)}
108
+ onRemove={() => handleRemove(item)}
109
+ />
110
+ )
111
+ })}
112
+
113
+ {adding ? (
114
+ <AssetInlineInput
115
+ id={`add-${section}`}
116
+ value={inputValue}
117
+ placeholder={defaultName}
118
+ error={error}
119
+ isFocused={focusedId === `add-${section}`}
120
+ onChange={setInputValue}
121
+ validate={validate}
122
+ onError={setError}
123
+ onSubmit={(name) => {
124
+ addAsset(section, name)
125
+ closeInput()
126
+ }}
127
+ onCancel={closeInput}
128
+ />
129
+ ) : (
130
+ <Box marginLeft={2}>
131
+ <Button
132
+ id={`add-${section}`}
133
+ label="+ Add"
134
+ hint={
135
+ <Text dimColor>
136
+ <Text>Enter</Text> to add
137
+ </Text>
138
+ }
139
+ onPress={startAdding}
140
+ />
141
+ </Box>
142
+ )}
143
+ </Box>
144
+ )
145
+ }