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
package/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # agent-facets
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5813b90: Small test for change set management in CI
8
+ - Updated dependencies [5813b90]
9
+ - @agent-facets/core@0.1.1
10
+
11
+ ## 0.1.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 2243bbf: Added basic create command to CLI
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [2243bbf]
20
+ - @agent-facets/core@0.1.0
21
+
22
+ ## 0.0.1
23
+
24
+ ### Patch Changes
25
+
26
+ - 74e3d25: Should be 0.0.1 now
27
+ - 74e3d25: Initial publishing
28
+ - Updated dependencies [74e3d25]
29
+ - Updated dependencies [74e3d25]
30
+ - @agent-facets/core@0.0.1
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test.reporter]
2
+ junit = "../../test-results/cli-junit.xml"
package/dist/facet ADDED
Binary file
Binary file
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "agent-facets",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "https://github.com/agent-facets/facets",
6
+ "directory": "packages/cli"
7
+ },
8
+ "version": "0.1.1",
9
+ "type": "module",
10
+ "bin": {
11
+ "facet": "./dist/facet"
12
+ },
13
+ "scripts": {
14
+ "build": "bun build src/index.ts --compile --outfile dist/facet",
15
+ "types": "tsc --noEmit",
16
+ "test": "bun test --timeout 15000"
17
+ },
18
+ "dependencies": {
19
+ "@agent-facets/brand": "workspace:*",
20
+ "@bomb.sh/args": "0.3.1",
21
+ "@agent-facets/core": "workspace:*",
22
+ "@types/react": "19.2.14",
23
+ "ink": "6.8.0",
24
+ "ink-gradient": "4.0.0",
25
+ "ink-spinner": "5.0.0",
26
+ "ink-text-input": "6.0.0",
27
+ "react": "19.2.4",
28
+ "react-devtools-core": "7.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bun": "1.3.10",
32
+ "ink-testing-library": "4.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "typescript": "^5"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "provenance": false
40
+ }
41
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolve } from 'node:path'
3
+
4
+ const CLI_PATH = resolve(import.meta.dir, '../../dist/facet')
5
+ const COMMAND_NAMES = ['add', 'build', 'create', 'info', 'install', 'list', 'publish', 'remove', 'upgrade']
6
+ const STUB_COMMAND_NAMES = ['add', 'info', 'install', 'list', 'publish', 'remove', 'upgrade']
7
+
8
+ type ExecResult = {
9
+ stdout: string
10
+ stderr: string
11
+ exitCode: number
12
+ }
13
+
14
+ async function runCli(...args: string[]): Promise<ExecResult> {
15
+ const proc = Bun.spawn([CLI_PATH, ...args], {
16
+ stdout: 'pipe',
17
+ stderr: 'pipe',
18
+ })
19
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
20
+ const exitCode = await proc.exited
21
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
22
+ }
23
+
24
+ // --- Help ---
25
+
26
+ describe('CLI — help', () => {
27
+ test('--help prints command list to stdout and exits 0', async () => {
28
+ const result = await runCli('--help')
29
+ expect(result.exitCode).toBe(0)
30
+ expect(result.stdout).toContain('Usage: facet <command>')
31
+ for (const cmd of COMMAND_NAMES) {
32
+ expect(result.stdout).toContain(cmd)
33
+ }
34
+ expect(result.stderr).toBe('')
35
+ })
36
+
37
+ test('help command produces same output as --help', async () => {
38
+ const helpFlag = await runCli('--help')
39
+ const helpCommand = await runCli('help')
40
+ expect(helpCommand.exitCode).toBe(0)
41
+ expect(helpCommand.stdout).toBe(helpFlag.stdout)
42
+ expect(helpCommand.stderr).toBe('')
43
+ })
44
+ })
45
+
46
+ // --- Version ---
47
+
48
+ describe('CLI — version', () => {
49
+ test('--version prints version matching package.json and exits 0', async () => {
50
+ const pkg = await Bun.file(resolve(import.meta.dir, '../../package.json')).json()
51
+ const result = await runCli('--version')
52
+ expect(result.exitCode).toBe(0)
53
+ expect(result.stdout).toBe(pkg.version)
54
+ expect(result.stderr).toBe('')
55
+ })
56
+ })
57
+
58
+ // --- Bare invocation ---
59
+
60
+ describe('CLI — bare invocation', () => {
61
+ test('no arguments prints help and exits 0', async () => {
62
+ const helpResult = await runCli('--help')
63
+ const bareResult = await runCli()
64
+ expect(bareResult.exitCode).toBe(0)
65
+ expect(bareResult.stdout).toBe(helpResult.stdout)
66
+ expect(bareResult.stderr).toBe('')
67
+ })
68
+ })
69
+
70
+ // --- Stub commands ---
71
+
72
+ describe('CLI — stub commands', () => {
73
+ test.each(STUB_COMMAND_NAMES)('"%s" prints not yet implemented with command name and exits 0', async (cmd) => {
74
+ const result = await runCli(cmd)
75
+ expect(result.exitCode).toBe(0)
76
+ expect(result.stdout).toContain(cmd)
77
+ expect(result.stdout).toContain('not yet implemented')
78
+ expect(result.stderr).toBe('')
79
+ })
80
+ })
81
+
82
+ // --- Unknown commands ---
83
+
84
+ describe('CLI — unknown commands', () => {
85
+ test('unknown command prints error to stderr and exits 1', async () => {
86
+ const result = await runCli('xyzzy')
87
+ expect(result.exitCode).toBe(1)
88
+ expect(result.stderr).toContain('Unknown command "xyzzy"')
89
+ expect(result.stdout).toBe('')
90
+ })
91
+
92
+ test('unknown command with close match includes "did you mean?" suggestion', async () => {
93
+ const result = await runCli('bild')
94
+ expect(result.exitCode).toBe(1)
95
+ expect(result.stderr).toContain('Unknown command "bild"')
96
+ expect(result.stderr).toContain('Did you mean "build"')
97
+ expect(result.stdout).toBe('')
98
+ })
99
+
100
+ test('unknown command with no close match does not include suggestion', async () => {
101
+ const result = await runCli('xyzzy')
102
+ expect(result.exitCode).toBe(1)
103
+ expect(result.stderr).toContain('Unknown command "xyzzy"')
104
+ expect(result.stderr).not.toContain('Did you mean')
105
+ expect(result.stdout).toBe('')
106
+ })
107
+ })
108
+
109
+ // --- Per-command help ---
110
+
111
+ describe('CLI — per-command help', () => {
112
+ test('<command> --help prints command-specific help and exits 0', async () => {
113
+ const result = await runCli('build', '--help')
114
+ expect(result.exitCode).toBe(0)
115
+ expect(result.stdout).toContain('facet build')
116
+ expect(result.stdout).toContain('Build a facet from the current directory')
117
+ expect(result.stderr).toBe('')
118
+ })
119
+ })
120
+
121
+ // --- Unexpected error ---
122
+
123
+ describe('CLI — unexpected error', () => {
124
+ test('unexpected error exits with code 2', async () => {
125
+ // This test runs against source (not compiled binary) because it needs to
126
+ // monkey-patch the command registry to inject a crashing command.
127
+ const script = `
128
+ import { commands } from '${resolve(import.meta.dir, '../commands.ts')}'
129
+ import { run } from '${resolve(import.meta.dir, '../run.ts')}'
130
+ commands['crash'] = {
131
+ name: 'crash',
132
+ description: 'Throws an error',
133
+ run: async () => { throw new Error('boom') },
134
+ }
135
+ try {
136
+ const code = await run(['crash'])
137
+ process.exit(code)
138
+ } catch (error) {
139
+ console.error(error instanceof Error ? error.message : 'An unexpected error occurred.')
140
+ process.exit(2)
141
+ }
142
+ `
143
+ const proc = Bun.spawn(['bun', '--eval', script], {
144
+ stdout: 'pipe',
145
+ stderr: 'pipe',
146
+ })
147
+ const stderr = await new Response(proc.stderr).text()
148
+ const exitCode = await proc.exited
149
+ expect(exitCode).toBe(2)
150
+ expect(stderr.trim()).toContain('boom')
151
+ })
152
+ })
@@ -0,0 +1,207 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { writeScaffold } from '../commands/create/index.ts'
6
+
7
+ let testDir: string
8
+
9
+ beforeAll(async () => {
10
+ testDir = await mkdtemp(join(tmpdir(), 'cli-create-build-test-'))
11
+ })
12
+
13
+ afterAll(async () => {
14
+ await rm(testDir, { recursive: true, force: true })
15
+ })
16
+
17
+ async function createFixtureDir(name: string): Promise<string> {
18
+ const dir = join(testDir, name)
19
+ await Bun.write(join(dir, '.keep'), '')
20
+ return dir
21
+ }
22
+
23
+ const CLI_PATH = resolve(import.meta.dir, '../../dist/facet')
24
+
25
+ async function runCli(...args: string[]) {
26
+ const proc = Bun.spawn([CLI_PATH, ...args], {
27
+ stdout: 'pipe',
28
+ stderr: 'pipe',
29
+ env: { ...process.env, NO_COLOR: '1' },
30
+ })
31
+ const stdout = await new Response(proc.stdout).text()
32
+ const stderr = await new Response(proc.stderr).text()
33
+ const exitCode = await proc.exited
34
+
35
+ // Don't let build errors flood test output — capture but don't dump
36
+ if (exitCode !== 0 && stderr.trim()) {
37
+ const lines = stderr.trim().split('\n')
38
+ const summary =
39
+ lines.length > 3 ? [...lines.slice(0, 3), `... (${lines.length - 3} more lines)`].join('\n') : stderr.trim()
40
+ return { stdout: stdout.trim(), stderr: summary, exitCode }
41
+ }
42
+
43
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
44
+ }
45
+
46
+ // --- Scaffold generation (unit) ---
47
+
48
+ describe('writeScaffold', () => {
49
+ test('scaffolds with named assets across all types', async () => {
50
+ const dir = await createFixtureDir('scaffold-all')
51
+ const files = await writeScaffold(
52
+ {
53
+ name: 'my-facet',
54
+ version: '0.1.0',
55
+ description: 'A test facet',
56
+ skills: ['code-review', 'testing-guide'],
57
+ agents: ['reviewer'],
58
+ commands: ['deploy'],
59
+ },
60
+ dir,
61
+ )
62
+
63
+ expect(files).toContain('facet.json')
64
+ expect(files).toContain('skills/code-review.md')
65
+ expect(files).toContain('skills/testing-guide.md')
66
+ expect(files).toContain('agents/reviewer.md')
67
+ expect(files).toContain('commands/deploy.md')
68
+
69
+ // Verify manifest content (JSON)
70
+ const manifestText = await Bun.file(join(dir, 'facet.json')).text()
71
+ const manifest = JSON.parse(manifestText)
72
+ expect(manifest.name).toBe('my-facet')
73
+ expect(manifest.version).toBe('0.1.0')
74
+ expect(manifest.description).toBe('A test facet')
75
+ expect(manifest.skills).toBeDefined()
76
+ expect(manifest.skills['code-review']).toBeDefined()
77
+ expect(manifest.skills['testing-guide']).toBeDefined()
78
+ expect(manifest.agents).toBeDefined()
79
+ expect(manifest.agents.reviewer).toBeDefined()
80
+ expect(manifest.commands).toBeDefined()
81
+ expect(manifest.commands.deploy).toBeDefined()
82
+
83
+ // Verify starter files exist and have named template content
84
+ const skill = await Bun.file(join(dir, 'skills/code-review.md')).text()
85
+ expect(skill).toContain('# Code Review')
86
+
87
+ const skill2 = await Bun.file(join(dir, 'skills/testing-guide.md')).text()
88
+ expect(skill2).toContain('# Testing Guide')
89
+
90
+ const agent = await Bun.file(join(dir, 'agents/reviewer.md')).text()
91
+ expect(agent).toContain('# Reviewer')
92
+
93
+ const command = await Bun.file(join(dir, 'commands/deploy.md')).text()
94
+ expect(command).toContain('# Deploy')
95
+ })
96
+
97
+ test('scaffolds with only one skill', async () => {
98
+ const dir = await createFixtureDir('scaffold-skills-only')
99
+ const files = await writeScaffold(
100
+ {
101
+ name: 'minimal',
102
+ version: '0.1.0',
103
+ description: '',
104
+ skills: ['minimal'],
105
+ agents: [],
106
+ commands: [],
107
+ },
108
+ dir,
109
+ )
110
+
111
+ expect(files).toContain('facet.json')
112
+ expect(files).toContain('skills/minimal.md')
113
+ expect(files).not.toContain('agents/')
114
+ expect(files).not.toContain('commands/')
115
+
116
+ const manifestText = await Bun.file(join(dir, 'facet.json')).text()
117
+ const manifest = JSON.parse(manifestText)
118
+ expect(manifest.skills).toBeDefined()
119
+ expect(manifest.agents).toBeUndefined()
120
+ expect(manifest.commands).toBeUndefined()
121
+ })
122
+
123
+ test('scaffolded project passes build', async () => {
124
+ const dir = await createFixtureDir('scaffold-buildable')
125
+ await writeScaffold(
126
+ {
127
+ name: 'buildable',
128
+ version: '0.1.0',
129
+ description: 'A buildable facet',
130
+ skills: ['helper'],
131
+ agents: ['assistant'],
132
+ commands: [],
133
+ },
134
+ dir,
135
+ )
136
+
137
+ // Run facet build against the scaffolded project
138
+ const result = await runCli('build', dir)
139
+ expect(result.exitCode).toBe(0)
140
+ expect(result.stdout).toContain('Built buildable')
141
+
142
+ // Verify dist/ output exists — archive + build manifest
143
+ const distArchive = await Bun.file(join(dir, 'dist/buildable-0.1.0.facet')).exists()
144
+ expect(distArchive).toBe(true)
145
+
146
+ const distManifest = await Bun.file(join(dir, 'dist/build-manifest.json')).exists()
147
+ expect(distManifest).toBe(true)
148
+
149
+ // No loose files
150
+ const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists()
151
+ expect(looseManifest).toBe(false)
152
+ })
153
+ })
154
+
155
+ // --- Build command (e2e) ---
156
+
157
+ describe('facet build', () => {
158
+ test('build succeeds on valid project', async () => {
159
+ const dir = await createFixtureDir('build-valid')
160
+ await Bun.write(join(dir, 'skills/review.md'), '# Review skill content')
161
+ await Bun.write(
162
+ join(dir, 'facet.json'),
163
+ JSON.stringify({
164
+ name: 'test-facet',
165
+ version: '1.0.0',
166
+ skills: {
167
+ review: {
168
+ description: 'Code review skill',
169
+ },
170
+ },
171
+ }),
172
+ )
173
+
174
+ const result = await runCli('build', dir)
175
+ expect(result.exitCode).toBe(0)
176
+ expect(result.stdout).toContain('Built test-facet')
177
+ expect(result.stdout).toContain('sha256:')
178
+ })
179
+
180
+ test('build fails on missing manifest', async () => {
181
+ const dir = await createFixtureDir('build-no-manifest')
182
+
183
+ const result = await runCli('build', dir)
184
+ expect(result.exitCode).toBe(1)
185
+ expect(result.stdout).toContain('Build failed')
186
+ })
187
+
188
+ test('build fails on missing asset file', async () => {
189
+ const dir = await createFixtureDir('build-missing-file')
190
+ await Bun.write(
191
+ join(dir, 'facet.json'),
192
+ JSON.stringify({
193
+ name: 'test-facet',
194
+ version: '1.0.0',
195
+ skills: {
196
+ review: {
197
+ description: 'Code review skill',
198
+ },
199
+ },
200
+ }),
201
+ )
202
+
203
+ const result = await runCli('build', dir)
204
+ expect(result.exitCode).toBe(1)
205
+ expect(result.stdout).toContain('Build failed')
206
+ })
207
+ })
@@ -0,0 +1,45 @@
1
+ import { render } from 'ink'
2
+ import { createElement } from 'react'
3
+ import type { Command } from '../commands.ts'
4
+ import { BuildView } from '../tui/views/build/build-view.tsx'
5
+
6
+ export const buildCommand: Command = {
7
+ name: 'build',
8
+ description: 'Build a facet from the current directory',
9
+ run: async (args: string[]): Promise<number> => {
10
+ const rootDir = args[0] || process.cwd()
11
+
12
+ // Track result for stdout summary after Ink exits
13
+ let buildName = ''
14
+ let buildVersion = ''
15
+ let artifactCount = 0
16
+ let integrity = ''
17
+ let errorCount = 0
18
+
19
+ const instance = render(
20
+ createElement(BuildView, {
21
+ rootDir,
22
+ onSuccess: (name: string, version: string, fileCount: number, hash: string) => {
23
+ buildName = name
24
+ buildVersion = version
25
+ artifactCount = fileCount
26
+ integrity = hash
27
+ },
28
+ onFailure: (count: number) => {
29
+ errorCount = count
30
+ },
31
+ }),
32
+ )
33
+
34
+ try {
35
+ await instance.waitUntilExit()
36
+ // Ink has unmounted — print stdout summary for scroll-back
37
+ const shortHash = integrity.length > 20 ? `${integrity.slice(0, 20)}...` : integrity
38
+ process.stdout.write(`✓ Built ${buildName} v${buildVersion} → dist/ (${artifactCount} assets, ${shortHash})\n`)
39
+ return 0
40
+ } catch {
41
+ process.stdout.write(`✗ Build failed — ${errorCount} error${errorCount !== 1 ? 's' : ''}\n`)
42
+ return 1
43
+ }
44
+ },
45
+ }
@@ -0,0 +1,30 @@
1
+ import type { Command } from '../../commands.ts'
2
+ import { writeScaffold } from '../create-scaffold.ts'
3
+ import { runCreateWizardInk } from './wizard.tsx'
4
+
5
+ export type { CreateOptions } from '../create-scaffold.ts'
6
+ export { writeScaffold } from '../create-scaffold.ts'
7
+
8
+ export const createCommand: Command = {
9
+ name: 'create',
10
+ description: 'Create a new facet project interactively',
11
+ run: async (args: string[]): Promise<number> => {
12
+ const targetDir = args[0] || process.cwd()
13
+
14
+ const opts = await runCreateWizardInk()
15
+ if (!opts) {
16
+ console.log('\nCancelled.')
17
+ return 1
18
+ }
19
+
20
+ const files = await writeScaffold(opts, targetDir)
21
+
22
+ console.log(`\nFacet created: ${opts.name}`)
23
+ for (const file of files) {
24
+ console.log(` ${file}`)
25
+ }
26
+ console.log('\nRun "facet build" to validate your facet.')
27
+
28
+ return 0
29
+ },
30
+ }
@@ -0,0 +1,9 @@
1
+ export type AssetType = 'skill' | 'agent' | 'command'
2
+
3
+ export const ASSET_TYPES: AssetType[] = ['skill', 'command', 'agent']
4
+
5
+ export const ASSET_LABELS: Record<AssetType, string> = {
6
+ skill: 'Skills',
7
+ agent: 'Agents',
8
+ command: 'Commands',
9
+ }
@@ -0,0 +1,24 @@
1
+ import { render } from 'ink'
2
+ import { CreateWizard } from '../../tui/views/create/wizard.tsx'
3
+ import type { CreateOptions } from '../create-scaffold.ts'
4
+
5
+ export async function runCreateWizardInk(): Promise<CreateOptions | null> {
6
+ return new Promise<CreateOptions | null>((resolve) => {
7
+ let result: CreateOptions | null = null
8
+
9
+ const instance = render(
10
+ <CreateWizard
11
+ onComplete={(opts) => {
12
+ result = opts
13
+ }}
14
+ onCancel={() => {
15
+ result = null
16
+ }}
17
+ />,
18
+ )
19
+
20
+ instance.waitUntilExit().then(() => {
21
+ resolve(result)
22
+ })
23
+ })
24
+ }