agent-facets 0.1.2 → 0.2.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 (40) hide show
  1. package/.package.json.bak +44 -0
  2. package/.turbo/turbo-build.log +2 -2
  3. package/CHANGELOG.md +33 -0
  4. package/dist/facet +0 -0
  5. package/package.json +7 -4
  6. package/src/__tests__/cli.test.ts +69 -26
  7. package/src/__tests__/create-build.test.ts +32 -12
  8. package/src/__tests__/edit-integration.test.ts +171 -0
  9. package/src/__tests__/resolve-dir.test.ts +95 -0
  10. package/src/commands/build.ts +17 -4
  11. package/src/commands/create/index.ts +51 -5
  12. package/src/commands/create/wizard.tsx +66 -15
  13. package/src/commands/create-scaffold.ts +14 -10
  14. package/src/commands/edit/index.ts +144 -0
  15. package/src/commands/edit/wizard.tsx +74 -0
  16. package/src/commands/resolve-dir.ts +98 -0
  17. package/src/commands.ts +11 -2
  18. package/src/help.ts +17 -10
  19. package/src/index.ts +2 -1
  20. package/src/run.ts +32 -5
  21. package/src/tui/components/asset-description.tsx +17 -0
  22. package/src/tui/components/asset-field-picker.tsx +78 -0
  23. package/src/tui/components/asset-inline-input.tsx +13 -1
  24. package/src/tui/components/asset-item.tsx +3 -7
  25. package/src/tui/components/asset-section.tsx +72 -26
  26. package/src/tui/components/reconciliation-item.tsx +129 -0
  27. package/src/tui/components/stage-row.tsx +16 -4
  28. package/src/tui/context/focus-order-context.ts +8 -2
  29. package/src/tui/context/form-state-context.ts +34 -3
  30. package/src/tui/editor.ts +40 -0
  31. package/src/tui/views/build/build-view.tsx +43 -44
  32. package/src/tui/views/create/create-view.tsx +17 -13
  33. package/src/tui/views/create/wizard.tsx +35 -6
  34. package/src/tui/views/edit/edit-confirm-view.tsx +93 -0
  35. package/src/tui/views/edit/edit-types.ts +34 -0
  36. package/src/tui/views/edit/edit-view.tsx +140 -0
  37. package/src/tui/views/edit/manifest-to-form.ts +38 -0
  38. package/src/tui/views/edit/reconciliation-view.tsx +170 -0
  39. package/src/tui/views/edit/use-edit-session.ts +125 -0
  40. package/src/tui/views/edit/wizard.tsx +129 -0
@@ -2,12 +2,21 @@ import { render } from 'ink'
2
2
  import { createElement } from 'react'
3
3
  import type { Command } from '../commands.ts'
4
4
  import { BuildView } from '../tui/views/build/build-view.tsx'
5
+ import { resolveTargetDir } from './resolve-dir.ts'
5
6
 
6
7
  export const buildCommand: Command = {
7
8
  name: 'build',
8
9
  description: 'Build a facet from the current directory',
9
- run: async (args: string[]): Promise<number> => {
10
- const rootDir = args[0] || process.cwd()
10
+ usage: '[directory]',
11
+ run: async (args: string[], _flags: Record<string, unknown>): Promise<number> => {
12
+ const resolved = await resolveTargetDir(args[0], { mustExist: true, facetMustExist: true })
13
+ if (!resolved.ok) {
14
+ console.error(resolved.message)
15
+ return 1
16
+ }
17
+
18
+ const rootDir = resolved.dir
19
+ const displayDir = resolved.display
11
20
 
12
21
  // Track result for stdout summary after Ink exits
13
22
  let buildName = ''
@@ -35,10 +44,14 @@ export const buildCommand: Command = {
35
44
  await instance.waitUntilExit()
36
45
  // Ink has unmounted — print stdout summary for scroll-back
37
46
  const shortHash = integrity.length > 20 ? `${integrity.slice(0, 20)}...` : integrity
38
- process.stdout.write(`✓ Built ${buildName} v${buildVersion} → dist/ (${artifactCount} assets, ${shortHash})\n`)
47
+ process.stdout.write(
48
+ `✓ Built ${buildName} v${buildVersion} → ${displayDir}/dist/ (${artifactCount} assets, ${shortHash})\n`,
49
+ )
39
50
  return 0
40
51
  } catch {
41
- process.stdout.write(`✗ Build failed — ${errorCount} error${errorCount !== 1 ? 's' : ''}\n`)
52
+ process.stdout.write(
53
+ `✗ Build failed — ${errorCount} error${errorCount !== 1 ? 's' : ''}. Run \`facet edit${args[0] ? ` ${displayDir}` : ''}\` to fix.\n`,
54
+ )
42
55
  return 1
43
56
  }
44
57
  },
@@ -1,15 +1,61 @@
1
+ import { join } from 'node:path'
2
+ import { createInterface } from 'node:readline'
3
+ import { FACET_MANIFEST_FILE } from '@agent-facets/core'
4
+ import { type } from 'arktype'
1
5
  import type { Command } from '../../commands.ts'
2
6
  import { writeScaffold } from '../create-scaffold.ts'
7
+ import { resolveTargetDir } from '../resolve-dir.ts'
3
8
  import { runCreateWizardInk } from './wizard.tsx'
4
9
 
5
10
  export type { CreateOptions } from '../create-scaffold.ts'
6
11
  export { writeScaffold } from '../create-scaffold.ts'
7
12
 
13
+ const CreateFlags = type({ 'force?': 'boolean' })
14
+
15
+ async function confirmOverwrite(display: string): Promise<boolean> {
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
17
+
18
+ return new Promise((resolve) => {
19
+ rl.question(`A facet already exists in ${display}. Overwrite? (y/N) `, (answer) => {
20
+ rl.close()
21
+ resolve(answer.toLowerCase() === 'y')
22
+ })
23
+ })
24
+ }
25
+
8
26
  export const createCommand: Command = {
9
27
  name: 'create',
10
28
  description: 'Create a new facet project interactively',
11
- run: async (args: string[]): Promise<number> => {
12
- const targetDir = args[0] || process.cwd()
29
+ usage: '[directory]',
30
+ flags: {
31
+ force: { type: 'boolean', description: 'Overwrite existing facet.json' },
32
+ },
33
+ run: async (args: string[], flags: Record<string, unknown>): Promise<number> => {
34
+ const resolved = await resolveTargetDir(args[0], { mustExist: false })
35
+ if (!resolved.ok) {
36
+ console.error(resolved.message)
37
+ return 1
38
+ }
39
+
40
+ const targetDir = resolved.dir
41
+ const displayDir = resolved.display
42
+
43
+ // Validate flags via Arktype
44
+ const validatedFlags = CreateFlags(flags)
45
+ if (validatedFlags instanceof type.errors) {
46
+ console.error(`Invalid flags: ${validatedFlags.summary}`)
47
+ return 1
48
+ }
49
+
50
+ // Overwrite protection
51
+ const manifestExists = await Bun.file(join(targetDir, FACET_MANIFEST_FILE)).exists()
52
+ if (manifestExists && !validatedFlags.force) {
53
+ const confirmed = await confirmOverwrite(displayDir)
54
+ if (!confirmed) {
55
+ console.log('Cancelled.')
56
+ return 1
57
+ }
58
+ }
13
59
 
14
60
  const opts = await runCreateWizardInk()
15
61
  if (!opts) {
@@ -19,11 +65,11 @@ export const createCommand: Command = {
19
65
 
20
66
  const files = await writeScaffold(opts, targetDir)
21
67
 
22
- console.log(`\nFacet created: ${opts.name}`)
68
+ console.log(`\nFacet created: ${opts.name} → ${displayDir}`)
23
69
  for (const file of files) {
24
- console.log(` ${file}`)
70
+ console.log(` ${displayDir}/${file}`)
25
71
  }
26
- console.log('\nRun "facet build" to validate your facet.')
72
+ console.log(`\nRun "facet build${args[0] ? ` ${displayDir}` : ''}" to validate your facet.`)
27
73
 
28
74
  return 0
29
75
  },
@@ -1,24 +1,75 @@
1
1
  import { render } from 'ink'
2
+ import type { AssetSectionKey } from '../../tui/context/form-state-context.ts'
3
+ import { openInEditorSync } from '../../tui/editor.ts'
4
+ import type { WizardSnapshot } from '../../tui/views/create/wizard.tsx'
2
5
  import { CreateWizard } from '../../tui/views/create/wizard.tsx'
3
6
  import type { CreateOptions } from '../create-scaffold.ts'
4
7
 
8
+ interface EditorRequest {
9
+ section: AssetSectionKey
10
+ name: string
11
+ description: string
12
+ }
13
+
5
14
  export async function runCreateWizardInk(): Promise<CreateOptions | null> {
6
- return new Promise<CreateOptions | null>((resolve) => {
7
- let result: CreateOptions | null = null
15
+ let result: CreateOptions | null = null
16
+ let snapshot: WizardSnapshot | undefined
17
+ let pendingEditor: EditorRequest | null = null
18
+ let done = false
19
+
20
+ while (!done) {
21
+ pendingEditor = null
8
22
 
9
- const instance = render(
10
- <CreateWizard
11
- onComplete={(opts) => {
12
- result = opts
13
- }}
14
- onCancel={() => {
15
- result = null
16
- }}
17
- />,
18
- )
23
+ await new Promise<void>((resolve) => {
24
+ const instance = render(
25
+ <CreateWizard
26
+ snapshot={snapshot}
27
+ onComplete={(opts) => {
28
+ result = opts
29
+ }}
30
+ onCancel={() => {
31
+ result = null
32
+ }}
33
+ onSnapshot={(s) => {
34
+ snapshot = s
35
+ }}
36
+ onRequestEditor={(section, name, description) => {
37
+ pendingEditor = { section, name, description }
38
+ instance.unmount()
39
+ }}
40
+ />,
41
+ )
19
42
 
20
- instance.waitUntilExit().then(() => {
21
- resolve(result)
43
+ instance.waitUntilExit().then(() => resolve())
22
44
  })
23
- })
45
+
46
+ if (pendingEditor) {
47
+ const req = pendingEditor as EditorRequest
48
+ const edited = openInEditorSync(req.description, `${req.name}.md`)
49
+ if (snapshot) {
50
+ const section = snapshot.form.assets[req.section]
51
+ snapshot = {
52
+ ...snapshot,
53
+ selectedItem: undefined,
54
+ form: {
55
+ ...snapshot.form,
56
+ assets: {
57
+ ...snapshot.form.assets,
58
+ [req.section]: {
59
+ ...section,
60
+ descriptions: {
61
+ ...section.descriptions,
62
+ ...(edited !== null ? { [req.name]: edited.trim() } : {}),
63
+ },
64
+ },
65
+ },
66
+ },
67
+ }
68
+ }
69
+ } else {
70
+ done = true
71
+ }
72
+ }
73
+
74
+ return result
24
75
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { FACET_MANIFEST_FILE } from '@agent-facets/core'
3
+ import { FACET_MANIFEST_FILE, KEBAB_CASE } from '@agent-facets/core'
4
4
 
5
5
  // --- Types ---
6
6
 
@@ -13,9 +13,13 @@ export interface CreateOptions {
13
13
  commands: string[]
14
14
  }
15
15
 
16
+ // --- Defaults ---
17
+
18
+ export const DEFAULT_VERSION = '0.0.0'
19
+
16
20
  // --- Validation ---
17
21
 
18
- export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
22
+ export { KEBAB_CASE }
19
23
  export const SEMVER = /^\d+\.\d+\.\d+$/
20
24
 
21
25
  export function isValidKebabCase(value: string): boolean {
@@ -35,7 +39,7 @@ function toTitleCase(kebab: string): string {
35
39
  .join(' ')
36
40
  }
37
41
 
38
- function skillTemplate(name: string): string {
42
+ export function skillTemplate(name: string): string {
39
43
  return `# ${toTitleCase(name)}
40
44
 
41
45
  <!-- This is a starter skill template. Replace this content with your skill's instructions. -->
@@ -54,7 +58,7 @@ Describe what this skill teaches or what guidelines it provides.
54
58
  `
55
59
  }
56
60
 
57
- function agentTemplate(name: string): string {
61
+ export function agentTemplate(name: string): string {
58
62
  return `# ${toTitleCase(name)}
59
63
 
60
64
  <!-- This is a starter agent template. Replace this content with your agent's prompt. -->
@@ -72,7 +76,7 @@ Describe this agent's role and responsibilities.
72
76
  `
73
77
  }
74
78
 
75
- function commandTemplate(name: string): string {
79
+ export function commandTemplate(name: string): string {
76
80
  return `# ${toTitleCase(name)}
77
81
 
78
82
  <!-- This is a starter command template. Replace this content with your command's prompt. -->
@@ -134,7 +138,7 @@ export function generateManifest(opts: CreateOptions): string {
134
138
  export function previewFiles(opts: CreateOptions): string[] {
135
139
  const files: string[] = [FACET_MANIFEST_FILE]
136
140
  for (const skill of opts.skills) {
137
- files.push(`skills/${skill}.md`)
141
+ files.push(`skills/${skill}/SKILL.md`)
138
142
  }
139
143
  for (const agent of opts.agents) {
140
144
  files.push(`agents/${agent}.md`)
@@ -155,11 +159,11 @@ export async function writeScaffold(opts: CreateOptions, targetDir: string): Pro
155
159
  await Bun.write(manifestPath, generateManifest(opts))
156
160
  files.push(FACET_MANIFEST_FILE)
157
161
 
158
- // Write skill files
162
+ // Write skill files (Agent Skills directory convention: skills/<name>/SKILL.md)
159
163
  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`)
164
+ await mkdir(join(targetDir, 'skills', skill), { recursive: true })
165
+ await Bun.write(join(targetDir, `skills/${skill}/SKILL.md`), skillTemplate(skill))
166
+ files.push(`skills/${skill}/SKILL.md`)
163
167
  }
164
168
 
165
169
  // Write agent files
@@ -0,0 +1,144 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import {
4
+ type FacetManifest,
5
+ hasFrontMatter,
6
+ loadManifest,
7
+ reconcile,
8
+ scanAssets,
9
+ writeManifest,
10
+ } from '@agent-facets/core'
11
+ import type { Command } from '../../commands.ts'
12
+ import type { EditContext, EditOperation, EditResult, ReconciliationItem } from '../../tui/views/edit/edit-types.ts'
13
+ import { agentTemplate, commandTemplate, skillTemplate } from '../create-scaffold.ts'
14
+ import { resolveTargetDir } from '../resolve-dir.ts'
15
+ import { runEditWizardInk } from './wizard.tsx'
16
+
17
+ export async function buildEditContext(
18
+ rootDir: string,
19
+ ): Promise<{ ok: true; context: EditContext } | { ok: false; exitCode: number }> {
20
+ // Load manifest
21
+ const loadResult = await loadManifest(rootDir)
22
+ if (!loadResult.ok) {
23
+ // Hard error — show errors and exit
24
+ console.error('Manifest is invalid:')
25
+ for (const err of loadResult.errors) {
26
+ console.error(` ${err.message}`)
27
+ }
28
+ console.error('\nFix facet.json and try again.')
29
+ return { ok: false, exitCode: 1 }
30
+ }
31
+
32
+ const manifest = loadResult.data
33
+
34
+ // Scan disk for assets
35
+ const discovered = await scanAssets(rootDir)
36
+
37
+ // Run reconciliation
38
+ const recon = reconcile(manifest, discovered)
39
+
40
+ // Build reconciliation items
41
+ const items: ReconciliationItem[] = []
42
+
43
+ for (const addition of recon.additions) {
44
+ items.push({ kind: 'addition', type: addition.type, name: addition.name, path: addition.path })
45
+ }
46
+
47
+ for (const missing of recon.missing) {
48
+ items.push({ kind: 'missing', type: missing.type, name: missing.name, expectedPath: missing.expectedPath })
49
+ }
50
+
51
+ // Check matched assets for front matter
52
+ for (const matched of recon.matched) {
53
+ const filePath = join(rootDir, matched.path)
54
+ const content = await Bun.file(filePath).text()
55
+ if (hasFrontMatter(content)) {
56
+ items.push({ kind: 'front-matter', type: matched.type, name: matched.name, path: matched.path })
57
+ }
58
+ }
59
+
60
+ return { ok: true, context: { rootDir, manifest, reconciliationItems: items } }
61
+ }
62
+
63
+ export async function applyOperations(
64
+ manifest: FacetManifest,
65
+ operations: EditOperation[],
66
+ rootDir: string,
67
+ ): Promise<void> {
68
+ for (const op of operations) {
69
+ switch (op.op) {
70
+ case 'write-manifest':
71
+ await writeManifest(manifest, rootDir)
72
+ break
73
+
74
+ case 'scaffold': {
75
+ if (op.type === 'skills') {
76
+ const dir = join(rootDir, 'skills', op.name)
77
+ await mkdir(dir, { recursive: true })
78
+ await Bun.write(join(dir, 'SKILL.md'), skillTemplate(op.name))
79
+ } else if (op.type === 'agents') {
80
+ await mkdir(join(rootDir, 'agents'), { recursive: true })
81
+ await Bun.write(join(rootDir, `agents/${op.name}.md`), agentTemplate(op.name))
82
+ } else if (op.type === 'commands') {
83
+ await mkdir(join(rootDir, 'commands'), { recursive: true })
84
+ await Bun.write(join(rootDir, `commands/${op.name}.md`), commandTemplate(op.name))
85
+ }
86
+ break
87
+ }
88
+
89
+ case 'delete-file': {
90
+ const path =
91
+ op.type === 'skills' ? join(rootDir, 'skills', op.name, 'SKILL.md') : join(rootDir, op.type, `${op.name}.md`)
92
+ try {
93
+ const { unlink } = await import('node:fs/promises')
94
+ await unlink(path)
95
+ } catch {
96
+ // File already gone — that's fine
97
+ }
98
+ break
99
+ }
100
+
101
+ case 'strip-front-matter': {
102
+ const { extractFrontMatter } = await import('@agent-facets/core')
103
+ const filePath = join(rootDir, op.path)
104
+ const raw = await Bun.file(filePath).text()
105
+ const { content } = extractFrontMatter(raw)
106
+ await Bun.write(filePath, content)
107
+ break
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ export const editCommand: Command = {
114
+ name: 'edit',
115
+ description: 'Edit a facet project interactively',
116
+ usage: '[directory]',
117
+ run: async (args: string[], _flags: Record<string, unknown>): Promise<number> => {
118
+ const resolved = await resolveTargetDir(args[0], { mustExist: true, facetMustExist: true })
119
+ if (!resolved.ok) {
120
+ console.error(resolved.message)
121
+ return 1
122
+ }
123
+
124
+ const rootDir = resolved.dir
125
+ const displayDir = resolved.display
126
+
127
+ const loaded = await buildEditContext(rootDir)
128
+ if (!loaded.ok) return loaded.exitCode
129
+
130
+ const result: EditResult = await runEditWizardInk(loaded.context)
131
+
132
+ if (result.outcome === 'cancelled') {
133
+ console.log('\nCancelled — no changes applied.')
134
+ return 1
135
+ }
136
+
137
+ await applyOperations(result.manifest, result.operations, rootDir)
138
+
139
+ console.log(`\nChanges applied to ${displayDir}`)
140
+ console.log(`Run "facet build${args[0] ? ` ${displayDir}` : ''}" to validate your facet.`)
141
+
142
+ return 0
143
+ },
144
+ }
@@ -0,0 +1,74 @@
1
+ import { render } from 'ink'
2
+ import type { AssetSectionKey } from '../../tui/context/form-state-context.ts'
3
+ import { openInEditorSync } from '../../tui/editor.ts'
4
+ import type { EditContext, EditResult } from '../../tui/views/edit/edit-types.ts'
5
+ import type { EditWizardSnapshot } from '../../tui/views/edit/wizard.tsx'
6
+ import { EditWizard } from '../../tui/views/edit/wizard.tsx'
7
+
8
+ interface EditorRequest {
9
+ section: AssetSectionKey
10
+ name: string
11
+ description: string
12
+ }
13
+
14
+ export async function runEditWizardInk(context: EditContext): Promise<EditResult> {
15
+ let result: EditResult = { outcome: 'cancelled' }
16
+ let snapshot: EditWizardSnapshot | undefined
17
+ let pendingEditor: EditorRequest | null = null
18
+ let done = false
19
+
20
+ while (!done) {
21
+ pendingEditor = null
22
+
23
+ await new Promise<void>((resolve) => {
24
+ const instance = render(
25
+ <EditWizard
26
+ context={context}
27
+ snapshot={snapshot}
28
+ onComplete={(r) => {
29
+ result = r
30
+ }}
31
+ onSnapshot={(s) => {
32
+ snapshot = s
33
+ }}
34
+ onRequestEditor={(section, name, description) => {
35
+ pendingEditor = { section, name, description }
36
+ instance.unmount()
37
+ }}
38
+ />,
39
+ )
40
+
41
+ instance.waitUntilExit().then(() => resolve())
42
+ })
43
+
44
+ if (pendingEditor) {
45
+ const req = pendingEditor as EditorRequest
46
+ const edited = openInEditorSync(req.description, `${req.name}.md`)
47
+ if (snapshot) {
48
+ snapshot = {
49
+ ...snapshot,
50
+ selectedItem: undefined,
51
+ formState: snapshot.formState
52
+ ? {
53
+ ...snapshot.formState,
54
+ assets: {
55
+ ...snapshot.formState.assets,
56
+ [req.section]: {
57
+ ...snapshot.formState.assets[req.section],
58
+ descriptions: {
59
+ ...snapshot.formState.assets[req.section].descriptions,
60
+ ...(edited !== null ? { [req.name]: edited.trim() } : {}),
61
+ },
62
+ },
63
+ },
64
+ }
65
+ : undefined,
66
+ }
67
+ }
68
+ } else {
69
+ done = true
70
+ }
71
+ }
72
+
73
+ return result
74
+ }
@@ -0,0 +1,98 @@
1
+ import { mkdir, stat } from 'node:fs/promises'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { FACET_MANIFEST_FILE } from '@agent-facets/core'
4
+
5
+ export interface ResolvedDir {
6
+ ok: true
7
+ dir: string
8
+ display: string
9
+ }
10
+
11
+ export interface ResolvedDirError {
12
+ ok: false
13
+ message: string
14
+ }
15
+
16
+ export type ResolveResult = ResolvedDir | ResolvedDirError
17
+
18
+ export interface ResolveOptions {
19
+ mustExist: boolean
20
+ facetMustExist?: boolean
21
+ }
22
+
23
+ /**
24
+ * Validates and resolves a directory argument for CLI commands.
25
+ *
26
+ * Handles:
27
+ * - No argument → uses process.cwd(), display as '.'
28
+ * - Argument ending with facet.json → silently uses parent directory
29
+ * - Argument is a non-directory file → error
30
+ * - Directory doesn't exist + mustExist false → auto-creates it
31
+ * - Directory doesn't exist + mustExist true → error
32
+ * - facetMustExist true but no facet.json → error
33
+ */
34
+ export async function resolveTargetDir(arg: string | undefined, opts: ResolveOptions): Promise<ResolveResult> {
35
+ const display = arg || '.'
36
+
37
+ // No argument → current directory
38
+ if (!arg) {
39
+ const dir = process.cwd()
40
+
41
+ if (opts.facetMustExist) {
42
+ const manifestExists = await Bun.file(join(dir, FACET_MANIFEST_FILE)).exists()
43
+ if (!manifestExists) {
44
+ return { ok: false, message: `No ${FACET_MANIFEST_FILE} found in ${display}` }
45
+ }
46
+ }
47
+
48
+ return { ok: true, dir, display }
49
+ }
50
+
51
+ // Pointing to facet.json directly → use parent directory
52
+ if (arg === FACET_MANIFEST_FILE || arg.endsWith(`/${FACET_MANIFEST_FILE}`)) {
53
+ const dir = resolve(dirname(arg))
54
+
55
+ const dirStat = await stat(dir).catch(() => null)
56
+ if (!dirStat?.isDirectory()) {
57
+ return { ok: false, message: `Directory does not exist: ${dirname(arg)}` }
58
+ }
59
+
60
+ if (opts.facetMustExist) {
61
+ const manifestExists = await Bun.file(join(dir, FACET_MANIFEST_FILE)).exists()
62
+ if (!manifestExists) {
63
+ return { ok: false, message: `No ${FACET_MANIFEST_FILE} found in ${display}` }
64
+ }
65
+ }
66
+
67
+ return { ok: true, dir, display: dirname(arg) || '.' }
68
+ }
69
+
70
+ // Check if the path exists
71
+ const pathStat = await stat(arg).catch(() => null)
72
+
73
+ // Path exists but is a file, not a directory
74
+ if (pathStat && !pathStat.isDirectory()) {
75
+ return { ok: false, message: `Expected a directory, not a file: ${arg}` }
76
+ }
77
+
78
+ // Path doesn't exist
79
+ if (!pathStat) {
80
+ if (opts.mustExist) {
81
+ return { ok: false, message: `Directory does not exist: ${arg}` }
82
+ }
83
+
84
+ // Auto-create for commands that allow it (e.g., create)
85
+ await mkdir(arg, { recursive: true })
86
+ }
87
+
88
+ const dir = resolve(arg)
89
+
90
+ if (opts.facetMustExist) {
91
+ const manifestExists = await Bun.file(join(dir, FACET_MANIFEST_FILE)).exists()
92
+ if (!manifestExists) {
93
+ return { ok: false, message: `No ${FACET_MANIFEST_FILE} found in ${display}` }
94
+ }
95
+ }
96
+
97
+ return { ok: true, dir, display }
98
+ }
package/src/commands.ts CHANGED
@@ -1,17 +1,25 @@
1
1
  import { buildCommand } from './commands/build.ts'
2
2
  import { createCommand } from './commands/create/index.ts'
3
+ import { editCommand } from './commands/edit/index.ts'
4
+
5
+ export type FlagDef = {
6
+ type: 'boolean' | 'string'
7
+ description: string
8
+ }
3
9
 
4
10
  export type Command = {
5
11
  name: string
6
12
  description: string
7
- run: (args: string[]) => Promise<number>
13
+ usage?: string
14
+ flags?: Record<string, FlagDef>
15
+ run: (args: string[], flags: Record<string, unknown>) => Promise<number>
8
16
  }
9
17
 
10
18
  function stubCommand(name: string, description: string): Command {
11
19
  return {
12
20
  name,
13
21
  description,
14
- run: async () => {
22
+ run: async (_args, _flags) => {
15
23
  console.log(`"${name}" is not yet implemented.`)
16
24
  return 0
17
25
  },
@@ -22,6 +30,7 @@ export const commands: Record<string, Command> = {
22
30
  add: stubCommand('add', 'Add a facet to the project'),
23
31
  build: buildCommand,
24
32
  create: createCommand,
33
+ edit: editCommand,
25
34
  info: stubCommand('info', 'Show information about a facet'),
26
35
  install: stubCommand('install', 'Install all facets from the lockfile'),
27
36
  list: stubCommand('list', 'List installed facets'),
package/src/help.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { Command } from './commands.ts'
2
- import { commands } from './commands.ts'
3
2
  import { version } from './version.ts'
4
3
 
5
- export function printGlobalHelp(): void {
4
+ export function printGlobalHelp(commands: Record<string, Command>): void {
6
5
  const entries = Object.values(commands)
7
6
  const maxNameLength = Math.max(...entries.map((c) => c.name.length))
8
7
 
@@ -23,14 +22,22 @@ export function printGlobalHelp(): void {
23
22
  }
24
23
 
25
24
  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
- ]
25
+ const usage = command.usage ? ` ${command.usage}` : ''
26
+
27
+ const lines = [`Usage: facet ${command.name}${usage} [options]`, '', ` ${command.description}`, '', 'Options:']
28
+
29
+ if (command.flags) {
30
+ const flagEntries = Object.entries(command.flags)
31
+ const maxFlagLength = Math.max(...flagEntries.map(([name]) => `--${name}`.length), '--help'.length)
32
+
33
+ for (const [name, def] of flagEntries) {
34
+ lines.push(` ${`--${name}`.padEnd(maxFlagLength + 4)}${def.description}`)
35
+ }
36
+
37
+ lines.push(` ${'--help'.padEnd(maxFlagLength + 4)}Show help`)
38
+ } else {
39
+ lines.push(' --help Show help')
40
+ }
34
41
 
35
42
  console.log(lines.join('\n'))
36
43
  }
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { commands } from './commands.ts'
1
2
  import { run } from './run.ts'
2
3
 
3
4
  try {
4
- const code = await run(process.argv.slice(2))
5
+ const code = await run(process.argv.slice(2), commands)
5
6
  process.exit(code)
6
7
  } catch (error) {
7
8
  console.error(error instanceof Error ? error.message : 'An unexpected error occurred.')