@frontera-sdk/cli 0.1.0 → 1.43.5

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 (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
@@ -0,0 +1,254 @@
1
+ import { CliError } from '../errors'
2
+ import {
3
+ ARTIFACT_KINDS,
4
+ KIND_DIRECTORY,
5
+ type AuthoredFile,
6
+ type AuthoredModel,
7
+ type BundleObject,
8
+ type BundleRelationship,
9
+ type DefinitionBundle,
10
+ type LiveIndex,
11
+ } from './model'
12
+
13
+ /**
14
+ * The one place the file format and the definition bundle are translated.
15
+ *
16
+ * Nothing else may translate. A second translation site is how a projection stops
17
+ * being total — one side learns about a field and the other does not, and `pull`
18
+ * quietly drops it, and the next `apply` deletes it from the draft.
19
+ *
20
+ * Both directions are here so they can be read as a pair and tested as one:
21
+ * `fromFiles(toFiles(bundle))` compiles back to `bundle`.
22
+ */
23
+
24
+ /** Fields the projection resolves rather than copies. Never written to a file. */
25
+ const OBJECT_RESOLVED = new Set([
26
+ 'id',
27
+ 'primaryKeyPropertyId',
28
+ 'titlePropertyId',
29
+ 'properties',
30
+ 'governance',
31
+ ])
32
+
33
+ const LINK_RESOLVED = new Set([
34
+ 'id',
35
+ 'fromObjectId',
36
+ 'toObjectId',
37
+ 'fromPropertyId',
38
+ 'toPropertyId',
39
+ 'fromDisplayName',
40
+ 'toDisplayName',
41
+ 'fromPhrase',
42
+ 'toPhrase',
43
+ 'datasetBacking',
44
+ ])
45
+
46
+ function rest(source: Record<string, unknown>, resolved: Set<string>): Record<string, unknown> {
47
+ const out: Record<string, unknown> = {}
48
+ for (const [key, value] of Object.entries(source)) {
49
+ if (!resolved.has(key)) out[key] = value
50
+ }
51
+ return out
52
+ }
53
+
54
+ /**
55
+ * A property entry, with its shared field named rather than identified.
56
+ *
57
+ * The wire's `sharedPropertyId` is the one uuid a property carries that HAS a name
58
+ * to resolve to, so it is resolved rather than stripped — a file that kept it would
59
+ * be unportable in the precise sense this whole tree exists to avoid. The authored
60
+ * key is `sharedField`: the tree is what a person writes, and a person writes field.
61
+ *
62
+ * An id the bundle does not declare is a corrupt export, and it fails here rather
63
+ * than writing the uuid: a tree that round-trips a per-deployment identifier reads
64
+ * as portable and is not, and the failure would then appear at the second
65
+ * deployment instead of at the `pull` that produced it.
66
+ */
67
+ function propertyToFile(
68
+ property: Record<string, unknown>,
69
+ objectApiName: string,
70
+ sharedApiNameById: Map<string, string>,
71
+ ): Record<string, unknown> {
72
+ const body = rest(property, new Set(['id', 'sharedPropertyId']))
73
+ const sharedPropertyId = property.sharedPropertyId
74
+ if (sharedPropertyId === undefined || sharedPropertyId === null) return body
75
+ const apiName = sharedApiNameById.get(String(sharedPropertyId))
76
+ if (!apiName) {
77
+ throw new CliError(
78
+ `Field "${String(property.apiName)}" on ${objectApiName} references shared field `
79
+ + `${String(sharedPropertyId)}, which this draft does not declare.`,
80
+ {
81
+ code: 'FAILURE',
82
+ hint: 'The draft is inconsistent — re-read it, and report it if it persists. '
83
+ + 'Writing the identifier into the file would make the tree unportable.',
84
+ },
85
+ )
86
+ }
87
+ return { ...body, sharedField: apiName }
88
+ }
89
+
90
+ /**
91
+ * `governance.sourceMappings[0]` → the portable `backing:` a file writes.
92
+ *
93
+ * The dataset NAME, never the revision id: the name means the same thing in two
94
+ * deployments and the revision it points at does not. Emitted only when a name is
95
+ * resolvable — `diff` calls `toFiles` without a resolver precisely so that backings
96
+ * do not enter the comparison, because `bind` owns them and `apply` must not try to.
97
+ */
98
+ function backingToFile(
99
+ object: BundleObject,
100
+ datasetNameByRevisionId: Map<string, string> | undefined,
101
+ ): Record<string, unknown> | undefined {
102
+ if (!datasetNameByRevisionId) return undefined
103
+ const mapping = object.governance.sourceMappings[0] as {
104
+ datasetRevisionId?: string
105
+ propertyMappings?: Array<{ propertyId: string; sourcePath: string; sourceField?: string | null }>
106
+ editableProperties?: string[]
107
+ } | undefined
108
+ if (!mapping?.datasetRevisionId) return undefined
109
+ const dataset = datasetNameByRevisionId.get(mapping.datasetRevisionId)
110
+ if (!dataset) return undefined
111
+ const apiNameById = new Map(object.properties.map((property) => [property.id, property.apiName]))
112
+ const entries: Record<string, unknown> = {}
113
+ for (const entry of mapping.propertyMappings ?? []) {
114
+ const apiName = apiNameById.get(entry.propertyId)
115
+ if (!apiName) continue
116
+ entries[apiName] = entry.sourceField
117
+ ? { column: entry.sourcePath, field: entry.sourceField }
118
+ : entry.sourcePath
119
+ }
120
+ const editable = (mapping.editableProperties ?? [])
121
+ .map((id) => apiNameById.get(id))
122
+ .filter((name): name is string => Boolean(name))
123
+ return {
124
+ dataset,
125
+ mapping: entries,
126
+ ...(editable.length ? { editable } : {}),
127
+ }
128
+ }
129
+
130
+ function objectToFile(
131
+ object: BundleObject,
132
+ sharedApiNameById: Map<string, string>,
133
+ datasetNameByRevisionId?: Map<string, string>,
134
+ ): Record<string, unknown> {
135
+ const byId = new Map(object.properties.map((property) => [property.id, property.apiName]))
136
+ const backing = backingToFile(object, datasetNameByRevisionId)
137
+ return {
138
+ ...rest(object, OBJECT_RESOLVED),
139
+ // `governance.sourceMappings` is the binding, which `bind` owns. Everything else
140
+ // in governance — ownership, sensitivity — is authored, so it stays.
141
+ governance: rest(object.governance, new Set(['sourceMappings'])),
142
+ ...(backing ? { backing } : {}),
143
+ primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
144
+ title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
145
+ properties: object.properties.map((property) =>
146
+ propertyToFile(property, object.apiName, sharedApiNameById)),
147
+ }
148
+ }
149
+
150
+ function linkToFile(link: BundleRelationship, objectApiNameById: Map<string, string>, propertyApiNameById: Map<string, string>): Record<string, unknown> {
151
+ const endpoint = (
152
+ objectId: string,
153
+ propertyId: string,
154
+ displayName: unknown,
155
+ phrase: unknown,
156
+ ) => ({
157
+ objectType: objectApiNameById.get(objectId) ?? objectId,
158
+ property: propertyApiNameById.get(propertyId) ?? propertyId,
159
+ ...(displayName === undefined ? {} : { displayName }),
160
+ ...(phrase === undefined ? {} : { phrase }),
161
+ })
162
+ return {
163
+ ...rest(link, LINK_RESOLVED),
164
+ from: endpoint(link.fromObjectId, link.fromPropertyId, link.fromDisplayName, link.fromPhrase),
165
+ to: endpoint(link.toObjectId, link.toPropertyId, link.toDisplayName, link.toPhrase),
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Live draft → the file tree a person edits. Lossless but for the resolved ids.
171
+ *
172
+ * `datasetNameByRevisionId` turns each object's pinned revision back into the dataset
173
+ * name a file can carry. `pull` supplies it; `diff` does not, and that asymmetry is
174
+ * deliberate — see `backingToFile`.
175
+ */
176
+ export function toFiles(
177
+ bundle: DefinitionBundle,
178
+ datasetNameByRevisionId?: Map<string, string>,
179
+ ): AuthoredModel {
180
+ const objectApiNameById = new Map(bundle.objects.map((object) => [object.id, object.apiName]))
181
+ const propertyApiNameById = new Map(
182
+ bundle.objects.flatMap((object) => object.properties.map((p) => [p.id, p.apiName] as const)),
183
+ )
184
+ const sharedApiNameById = new Map(
185
+ (bundle.sharedProperties ?? []).map((shared) => [shared.id, shared.apiName]),
186
+ )
187
+ const files: AuthoredFile[] = []
188
+ const push = (kind: AuthoredFile['kind'], apiName: string, document: Record<string, unknown>) => {
189
+ files.push({ kind, apiName, path: `${KIND_DIRECTORY[kind]}/${apiName}.yaml`, document })
190
+ }
191
+
192
+ for (const shared of bundle.sharedProperties ?? []) {
193
+ // A shared field owns no backing and references nothing, so stripping its id
194
+ // is the whole projection: what is left is portable as it stands.
195
+ push('shared-field', shared.apiName, rest(shared, new Set(['id'])))
196
+ }
197
+ for (const object of bundle.objects) {
198
+ push('object-type', object.apiName, objectToFile(object, sharedApiNameById, datasetNameByRevisionId))
199
+ }
200
+ for (const link of bundle.relationships) {
201
+ push('link-type', link.apiName, linkToFile(link, objectApiNameById, propertyApiNameById))
202
+ }
203
+ for (const metric of bundle.metrics) {
204
+ // A metric names the type it measures by `objectId`. Stripping only `id` left that
205
+ // uuid in the file — unportable, and unusable besides: the create route takes an
206
+ // `objectTypeApiName`, so a pulled metric could never be applied anywhere.
207
+ const { id: _id, objectId, ...body } = metric as Record<string, unknown> & { objectId?: string }
208
+ push('metric', metric.apiName, {
209
+ ...body,
210
+ ...(typeof objectId === 'string'
211
+ ? { objectType: objectApiNameById.get(objectId) ?? objectId }
212
+ : {}),
213
+ })
214
+ }
215
+
216
+ return { schemaVersion: bundle.schemaVersion, files: files.sort(byPath) }
217
+ }
218
+
219
+ function byPath(left: AuthoredFile, right: AuthoredFile): number {
220
+ return left.path.localeCompare(right.path)
221
+ }
222
+
223
+ /**
224
+ * The file tree, grouped by kind, ready for the compiler.
225
+ *
226
+ * This does not resolve anything — resolution needs the live draft, which a pure
227
+ * read of the tree does not have. See `compile.ts`.
228
+ */
229
+ export function fromFiles(files: AuthoredFile[], schemaVersion: number): AuthoredModel {
230
+ return { schemaVersion, files: [...files].sort(byPath) }
231
+ }
232
+
233
+ export function filesOfKind(model: AuthoredModel, kind: AuthoredFile['kind']): AuthoredFile[] {
234
+ return model.files.filter((file) => file.kind === kind)
235
+ }
236
+
237
+ /** Every kind, in a fixed order, so callers never depend on tree iteration order. */
238
+ export function kinds(): readonly AuthoredFile['kind'][] {
239
+ return ARTIFACT_KINDS
240
+ }
241
+
242
+ /**
243
+ * Does this bundle's schema version carry actions?
244
+ *
245
+ * v1 has no `actions` key at all and its schema is strict, so emitting an empty
246
+ * array would make a v1 bundle unparseable. The absence is meaningful.
247
+ */
248
+ export function carriesActions(schemaVersion: number): boolean {
249
+ return schemaVersion >= 2
250
+ }
251
+
252
+ export function liveObject(index: LiveIndex, apiName: string): BundleObject | undefined {
253
+ return index.objectByApiName.get(apiName)
254
+ }
@@ -0,0 +1,73 @@
1
+ import type { ModelOperation, Plan } from './diff'
2
+
3
+ /**
4
+ * The plan, as a person reads it.
5
+ *
6
+ * One line per artifact, marker first, so a long plan scans vertically. `--json`
7
+ * emits the `Plan` itself and never this text — the two must not drift, which is why
8
+ * the renderer takes the plan rather than being fed by the executor.
9
+ */
10
+
11
+ const MARKER: Record<ModelOperation['operation'], string> = {
12
+ create: '+',
13
+ update: '~',
14
+ delete: '-',
15
+ }
16
+
17
+ function pad(value: string, width: number): string {
18
+ return value.length >= width ? value : value + ' '.repeat(width - value.length)
19
+ }
20
+
21
+ export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: false }): string {
22
+ const rows: Array<{ marker: string; kind: string; apiName: string; detail: string }> = []
23
+
24
+ for (const operation of plan.operations) {
25
+ rows.push({
26
+ marker: MARKER[operation.operation],
27
+ kind: operation.kind,
28
+ apiName: operation.apiName,
29
+ detail: operation.detail ?? (operation.operation === 'create' ? 'create' : ''),
30
+ })
31
+ }
32
+ for (const operation of plan.prunes) {
33
+ rows.push({
34
+ marker: MARKER.delete,
35
+ kind: operation.kind,
36
+ apiName: operation.apiName,
37
+ // Naming the flag on the line rather than in a footnote: the reader is
38
+ // looking at the artifact they care about, not at the bottom of the output.
39
+ detail: options.prune ? 'delete' : 'delete — needs --prune, skipped',
40
+ })
41
+ }
42
+
43
+ if (rows.length === 0) {
44
+ return plan.unchanged === 0
45
+ ? 'No artifacts, and nothing to do.'
46
+ : `No changes. ${plan.unchanged} artifact${plan.unchanged === 1 ? ' matches' : 's match'} the draft.`
47
+ }
48
+
49
+ const kindWidth = Math.max(...rows.map((row) => row.kind.length))
50
+ const nameWidth = Math.max(...rows.map((row) => row.apiName.length))
51
+ const lines = rows.map((row) =>
52
+ ` ${row.marker} ${pad(row.kind, kindWidth)} ${pad(row.apiName, nameWidth)} ${row.detail}`.trimEnd(),
53
+ )
54
+
55
+ if (plan.unchanged > 0) {
56
+ lines.push(` = ${plan.unchanged} unchanged`)
57
+ }
58
+ return lines.join('\n')
59
+ }
60
+
61
+ /** The `!` lines: a bound type whose dataset has moved to a different contract. */
62
+ export function renderDrift(
63
+ drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>,
64
+ ): string {
65
+ if (drift.length === 0) return ''
66
+ return drift
67
+ .map((entry) =>
68
+ ` ! binding ${entry.objectApiName} dataset \`${entry.datasetName}\` publishes a different `
69
+ + `column contract (${entry.currentDigest.slice(0, 14)}…)\n`
70
+ + ` re-review it: frontera blueprint bind ${entry.objectApiName} --dataset ${entry.datasetName} --plan <file>`,
71
+ )
72
+ .join('\n')
73
+ }
@@ -0,0 +1,79 @@
1
+ import type { ArtifactKind } from './model'
2
+
3
+ /**
4
+ * A starting file per kind, complete enough to apply as written once the placeholders
5
+ * are filled in.
6
+ *
7
+ * "Validates as written" is the bar, and it is not decoration: a scaffold missing a
8
+ * required field teaches the reader that the format is approximate, and the next
9
+ * thing they write omits three more. Every field the create route requires is here,
10
+ * including the ones with dull answers.
11
+ */
12
+ export function scaffold(kind: ArtifactKind, apiName: string): Record<string, unknown> {
13
+ if (kind === 'object-type') {
14
+ return {
15
+ apiName,
16
+ displayName: apiName,
17
+ pluralDisplayName: `${apiName}s`,
18
+ // `primaryKey` and `title` name properties by apiName. The service resolves
19
+ // them to ids; nothing in this tree ever carries one.
20
+ primaryKey: 'id',
21
+ title: 'id',
22
+ governance: {
23
+ ownership: { kind: 'team', ownerId: 'blueprint-administration' },
24
+ sensitivity: { classification: 'internal', categories: [] },
25
+ },
26
+ properties: [{
27
+ apiName: 'id',
28
+ displayName: 'ID',
29
+ valueType: 'string',
30
+ required: true,
31
+ propertyType: 'attribute',
32
+ }],
33
+ validations: [],
34
+ // The dataset NAME, not a revision id: the name means the same thing in every
35
+ // deployment, and the revision it points at does not. One column per property,
36
+ // and no name-match fallback — the service refuses a mapping it was not given.
37
+ backing: {
38
+ dataset: 'CHANGE_ME',
39
+ mapping: { id: 'CHANGE_ME' },
40
+ },
41
+ }
42
+ }
43
+
44
+ if (kind === 'shared-field') {
45
+ // Identity and semantics, and nothing about where the data lives: no column,
46
+ // no `required`, no `unique`. `propertyType` is required — a shared field
47
+ // states how every implementing property is read — so it is written here
48
+ // with the commonest answer rather than left for the create route to refuse.
49
+ return {
50
+ apiName,
51
+ displayName: apiName,
52
+ description: 'CHANGE_ME',
53
+ valueType: 'string',
54
+ propertyType: 'attribute',
55
+ }
56
+ }
57
+
58
+ if (kind === 'link-type') {
59
+ return {
60
+ apiName,
61
+ cardinality: 'one_to_many',
62
+ from: { objectType: 'CHANGE_ME', property: 'id', displayName: 'CHANGE_ME', phrase: 'has' },
63
+ to: { objectType: 'CHANGE_ME', property: 'id', displayName: 'CHANGE_ME', phrase: 'belongs to' },
64
+ }
65
+ }
66
+
67
+ // A metric names the type it measures and the measures it computes. Both are
68
+ // required by the create route, so a scaffold without them would contradict this
69
+ // command's own promise that what it writes validates as written.
70
+ return {
71
+ apiName,
72
+ displayName: apiName,
73
+ objectType: 'CHANGE_ME',
74
+ definition: {
75
+ measures: [{ agg: 'count', label: 'Count' }],
76
+ dimensions: [],
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, extname, join } from 'node:path'
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import { CliError } from '../errors'
5
+ import {
6
+ ARTIFACT_KINDS,
7
+ DIRECTORY_KIND,
8
+ KIND_DIRECTORY,
9
+ type ArtifactKind,
10
+ type AuthoredFile,
11
+ } from './model'
12
+
13
+ /**
14
+ * Reading and writing the `blueprint/` tree. No network, no resolution.
15
+ *
16
+ * YAML and JSON are both accepted, chosen by extension, because a tree generated by
17
+ * another tool should not have to be converted before it can be applied. `pull` and
18
+ * `new` emit YAML: it diffs better in a pull request, which is the whole reason the
19
+ * Blueprint is in files.
20
+ */
21
+
22
+ const READABLE = new Set(['.yaml', '.yml', '.json'])
23
+
24
+ export function parseDocument(path: string, text: string): Record<string, unknown> {
25
+ const parsed = extname(path) === '.json' ? JSON.parse(text) : parseYaml(text)
26
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
27
+ throw new CliError(`${path} does not contain a mapping.`, {
28
+ code: 'USAGE',
29
+ hint: 'Each file holds exactly one artifact, written as a mapping of fields.',
30
+ })
31
+ }
32
+ return parsed as Record<string, unknown>
33
+ }
34
+
35
+ /**
36
+ * Read the whole tree.
37
+ *
38
+ * A file whose `apiName` disagrees with its filename is an error rather than a
39
+ * rename: the two are the same identity written twice, and guessing which one the
40
+ * author meant is the difference between renaming an artifact and replacing it.
41
+ */
42
+ export function readTree(root: string): AuthoredFile[] {
43
+ const files: AuthoredFile[] = []
44
+ for (const kind of ARTIFACT_KINDS) {
45
+ const directory = join(root, KIND_DIRECTORY[kind])
46
+ if (!existsSync(directory)) continue
47
+ for (const entry of readdirSync(directory).sort()) {
48
+ const extension = extname(entry)
49
+ if (!READABLE.has(extension)) continue
50
+ const relative = `${KIND_DIRECTORY[kind]}/${entry}`
51
+ const document = parseDocument(relative, readFileSync(join(directory, entry), 'utf8'))
52
+ const stem = entry.slice(0, -extension.length)
53
+ const apiName = document.apiName
54
+ if (typeof apiName !== 'string' || !apiName) {
55
+ throw new CliError(`${relative} declares no apiName.`, {
56
+ code: 'USAGE',
57
+ hint: `Add \`apiName: ${stem}\`, or delete the file.`,
58
+ })
59
+ }
60
+ if (apiName !== stem) {
61
+ throw new CliError(
62
+ `${relative} declares apiName "${apiName}", which is not its filename.`,
63
+ {
64
+ code: 'USAGE',
65
+ hint: `Renaming is an explicit act: \`frontera blueprint rename ${kind} ${stem} ${apiName}\`.`,
66
+ },
67
+ )
68
+ }
69
+ files.push({ kind, apiName, path: relative, document })
70
+ }
71
+ }
72
+ return files
73
+ }
74
+
75
+ /**
76
+ * Delete a file the tree should no longer contain.
77
+ *
78
+ * `writeTree` only ever wrote, which left `pull --force` keeping artifacts that had
79
+ * since left the draft — the next `plan` then reported a phantom create for something
80
+ * nobody had authored. "Two pulled trees are byte-identical" was true only of fresh
81
+ * directories.
82
+ */
83
+ export function removeFromTree(root: string, paths: string[]): string[] {
84
+ const removed: string[] = []
85
+ for (const relative of paths) {
86
+ const absolute = join(root, relative)
87
+ if (!existsSync(absolute)) continue
88
+ rmSync(absolute)
89
+ removed.push(relative)
90
+ }
91
+ return removed
92
+ }
93
+
94
+ export function writeTree(root: string, files: AuthoredFile[]): string[] {
95
+ const written: string[] = []
96
+ for (const file of files) {
97
+ const absolute = join(root, file.path)
98
+ mkdirSync(dirname(absolute), { recursive: true })
99
+ writeFileSync(absolute, serialize(file.path, file.document))
100
+ written.push(file.path)
101
+ }
102
+ return written
103
+ }
104
+
105
+ export function serialize(path: string, document: Record<string, unknown>): string {
106
+ return extname(path) === '.json'
107
+ ? `${JSON.stringify(document, null, 2)}\n`
108
+ // `lineWidth: 0` disables wrapping. A wrapped description reflows the moment
109
+ // someone edits a word before it, which turns a one-word change into a
110
+ // multi-line diff and hides what actually changed.
111
+ : stringifyYaml(document, { lineWidth: 0 })
112
+ }
113
+
114
+ /** Which files a tree already holds, so `new` can refuse to overwrite one. */
115
+ export function pathFor(kind: ArtifactKind, apiName: string): string {
116
+ return `${KIND_DIRECTORY[kind]}/${apiName}.yaml`
117
+ }
118
+
119
+ export function kindOfDirectory(directory: string): ArtifactKind | undefined {
120
+ return DIRECTORY_KIND[directory]
121
+ }
@@ -86,6 +86,83 @@ async function readDocument(ctx: CommandContext): Promise<Record<string, unknown
86
86
  }
87
87
  }
88
88
 
89
+ /** `agent_configs.agentId` — a slug, lowercase, the value every verb takes. */
90
+ const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
91
+
92
+ const create: Command = {
93
+ meta: {
94
+ noun: 'agent',
95
+ verb: 'create',
96
+ args: [
97
+ {
98
+ name: 'slug',
99
+ required: true,
100
+ description: 'lowercase slug every other agent command takes, e.g. claims-triage',
101
+ },
102
+ ],
103
+ flags: { name: 'string', description: 'string', kind: 'string' },
104
+ summary: 'Create an agent, ready for `agent apply` and `agent publish`',
105
+ examples: [
106
+ 'frontera agent create claims-triage --name "Claims Triage"',
107
+ 'frontera agent create nightly-extract --kind work',
108
+ ],
109
+ },
110
+ async run(ctx) {
111
+ const slug = ctx.positional[0]
112
+ if (!slug) {
113
+ throw new UsageError('missing <slug>', 'frontera agent create <slug> [--name <text>]')
114
+ }
115
+ if (!SLUG_PATTERN.test(slug)) {
116
+ throw new UsageError(
117
+ `"${slug}" is not a valid slug`,
118
+ 'lowercase letters, digits and hyphens, starting with a letter — e.g. claims-triage',
119
+ )
120
+ }
121
+
122
+ const kind = flagString(ctx, 'kind')
123
+ if (kind && kind !== 'conversation' && kind !== 'work') {
124
+ throw new UsageError(`unknown --kind "${kind}"`, 'conversation (default) or work')
125
+ }
126
+
127
+ const client = api(ctx)
128
+
129
+ // Checked before the write: the service answers a duplicate slug from deep
130
+ // inside the create transaction, and the message names a constraint rather
131
+ // than the agent that already holds the name.
132
+ const existing = await client.agent(slug).catch((err: unknown) => {
133
+ if ((err as { code?: string }).code === 'NOT_FOUND') return null
134
+ throw err
135
+ })
136
+ if (existing) {
137
+ throw new CliError(`an agent with the slug "${slug}" already exists`, {
138
+ code: 'CONFLICT',
139
+ hint: `stage a change onto it with \`frontera agent apply ${slug} -f <file>\``,
140
+ })
141
+ }
142
+
143
+ const name = flagString(ctx, 'name')
144
+ const description = flagString(ctx, 'description')
145
+ const row = (await client.createAgent({
146
+ agentId: slug,
147
+ ...(name ? { name } : {}),
148
+ ...(description ? { description } : {}),
149
+ ...(kind ? { kind: kind as 'conversation' | 'work' } : {}),
150
+ })) as { data?: AgentRow } | AgentRow
151
+ const agent = ((row as { data?: AgentRow }).data ?? row) as AgentRow
152
+
153
+ return {
154
+ data: agent,
155
+ text: [
156
+ `created ${slug}${agent.id ? ` ${agent.id}` : ''}`,
157
+ // Said plainly because the next thing a caller does is run `agent list`,
158
+ // see nothing, and conclude the create failed.
159
+ ` it is in \`configuring\` and will NOT appear in \`frontera agent list\` until published`,
160
+ ` next: frontera agent apply ${slug} -f <file> then frontera agent publish ${slug}`,
161
+ ].join('\n'),
162
+ }
163
+ },
164
+ }
165
+
89
166
  const list: Command = {
90
167
  meta: {
91
168
  noun: 'agent',
@@ -345,4 +422,13 @@ const versions: Command = {
345
422
  },
346
423
  }
347
424
 
348
- export const agentCommands: Command[] = [list, get, apply, publish, diff, discard, versions]
425
+ export const agentCommands: Command[] = [
426
+ list,
427
+ get,
428
+ create,
429
+ apply,
430
+ publish,
431
+ diff,
432
+ discard,
433
+ versions,
434
+ ]