@frontera-sdk/cli 0.1.0 → 1.43.6

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,195 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { readSecretValue } from '../../secrets'
4
+ import { table } from '../../table'
5
+ import { flagString, type Command } from '../types'
6
+
7
+ /**
8
+ * Workspace secrets.
9
+ *
10
+ * The reason this noun exists: an automation names a secret rather than
11
+ * carrying its value — `ctx.http`'s `auth: { secret }` — and until now the CLI
12
+ * could deploy that automation while nothing could create the secret it named.
13
+ * The value therefore had to be typed into the Console by hand, which is the
14
+ * one step an FDE cannot script.
15
+ *
16
+ * A value NEVER arrives as a flag. `--value hunter2` lands in shell history and
17
+ * in the process list on a shared machine, and a CLI that accepts it will have
18
+ * it used, so the inline form is refused rather than discouraged.
19
+ */
20
+
21
+ /** Same rule the service enforces, checked here to save a round trip. */
22
+ const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/
23
+
24
+ interface SecretRow {
25
+ name?: string
26
+ description?: string | null
27
+ type?: string | null
28
+ provider?: string | null
29
+ updatedAt?: string | null
30
+ dependents?: Array<{ kind?: string; displayName?: string; name?: string }>
31
+ }
32
+
33
+ async function requireWorkspaceId(api: PlatformApi): Promise<string> {
34
+ const me = await api.whoami()
35
+ if (!me.workspaceId) {
36
+ throw new CliError('this credential is not scoped to a workspace', {
37
+ code: 'FORBIDDEN',
38
+ hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
39
+ })
40
+ }
41
+ return me.workspaceId
42
+ }
43
+
44
+ const list: Command = {
45
+ meta: {
46
+ noun: 'secret',
47
+ verb: 'list',
48
+ args: [],
49
+ flags: {},
50
+ summary: 'List secret names in this workspace — never their values',
51
+ examples: ['frontera secret list', 'frontera secret list --json'],
52
+ },
53
+ async run(ctx) {
54
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
55
+ const rows = (await api.workspaceSecrets(await requireWorkspaceId(api))) as SecretRow[]
56
+
57
+ return {
58
+ data: rows,
59
+ text:
60
+ rows.length === 0
61
+ ? 'No secrets in this workspace.'
62
+ : table(
63
+ // `used by` is what makes a delete decision possible without
64
+ // opening the Console — the service computes it from every path a
65
+ // credential is actually reached through.
66
+ ['name', 'used by', 'description'],
67
+ rows.map((s) => [
68
+ s.name ?? '?',
69
+ (s.dependents ?? [])
70
+ .map((d) => d.displayName ?? d.name ?? d.kind ?? '')
71
+ .filter(Boolean)
72
+ .join(', '),
73
+ s.description ?? '',
74
+ ]),
75
+ [undefined, 40, 40],
76
+ ),
77
+ }
78
+ },
79
+ }
80
+
81
+ const set: Command = {
82
+ meta: {
83
+ noun: 'secret',
84
+ verb: 'set',
85
+ args: [
86
+ {
87
+ name: 'name',
88
+ required: true,
89
+ description: 'UPPER_SNAKE_CASE name an automation or install refers to',
90
+ },
91
+ ],
92
+ flags: { from: 'string', description: 'string' },
93
+ summary: 'Create or replace a secret, reading the value from stdin or a file',
94
+ examples: [
95
+ 'op read op://vault/stripe/key | frontera secret set STRIPE_API_KEY --from -',
96
+ 'frontera secret set STRIPE_API_KEY --from ./key.txt',
97
+ ],
98
+ },
99
+ async run(ctx) {
100
+ const name = ctx.positional[0]
101
+ if (!name) {
102
+ throw new UsageError('missing <name>', 'frontera secret set <NAME> --from - (or --from ./file)')
103
+ }
104
+ if (!NAME_PATTERN.test(name)) {
105
+ throw new UsageError(
106
+ `"${name}" is not a valid secret name`,
107
+ 'uppercase letters, digits and underscores, starting with a letter — e.g. STRIPE_API_KEY',
108
+ )
109
+ }
110
+
111
+ const from = flagString(ctx, 'from')
112
+ if (!from) {
113
+ throw new UsageError(
114
+ 'missing --from',
115
+ // Named rather than implied: a caller that has to guess will guess an
116
+ // inline flag, which is the thing this refuses.
117
+ 'pass `--from -` to read the value from stdin, or `--from ./file` to read it from a file',
118
+ )
119
+ }
120
+
121
+ // Trailing newline stripped: `echo secret | …` and `op read …` both add one,
122
+ // and a credential that differs from the intended value by an invisible byte
123
+ // fails at the far end of an integration with no clue why.
124
+ const value = (await readSecretValue(from)).replace(/\r?\n$/, '')
125
+ if (value.length === 0) {
126
+ throw new CliError('the value read was empty', {
127
+ code: 'USAGE',
128
+ hint: from === '-' ? 'nothing arrived on stdin — is the pipe connected?' : `${from} is empty`,
129
+ })
130
+ }
131
+
132
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
133
+ const workspaceId = await requireWorkspaceId(api)
134
+ const description = flagString(ctx, 'description')
135
+
136
+ // List, then create or replace. The service has no upsert, and choosing by
137
+ // NAME rather than by the shape of a failure is what makes this reliable:
138
+ // the first version of this command decided from the error and silently
139
+ // stopped replacing anything the day a duplicate stopped being a CONFLICT.
140
+ const existing = (await api.workspaceSecrets(workspaceId)) as SecretRow[]
141
+ const existed = existing.some((s) => s.name === name)
142
+
143
+ if (existed) {
144
+ await api.updateWorkspaceSecret(workspaceId, name, { value, description })
145
+ } else {
146
+ // A concurrent create — two people seeding the same workspace — is the one
147
+ // case the list cannot rule out, so a CONFLICT here becomes the replace it
148
+ // was always going to be rather than an error the caller must interpret.
149
+ await api.createWorkspaceSecret(workspaceId, { name, value, description }).catch(
150
+ async (err: unknown) => {
151
+ if ((err as { code?: string }).code !== 'CONFLICT') throw err
152
+ await api.updateWorkspaceSecret(workspaceId, name, { value, description })
153
+ },
154
+ )
155
+ }
156
+
157
+ return {
158
+ data: { name, action: existed ? 'replaced' : 'created' },
159
+ // The value is never echoed, not even truncated — a prefix of a key is
160
+ // still a prefix of a key, and this text lands in CI logs.
161
+ text: [
162
+ `${existed ? 'replaced' : 'created'} ${name}`,
163
+ ` reference it from an automation as auth: { secret: '${name}' }, and grant secret:${name}`,
164
+ ].join('\n'),
165
+ }
166
+ },
167
+ }
168
+
169
+ const remove: Command = {
170
+ meta: {
171
+ noun: 'secret',
172
+ verb: 'delete',
173
+ args: [{ name: 'name', required: true, description: 'secret name, from `frontera secret list`' }],
174
+ flags: {},
175
+ summary: 'Delete a secret, unless something still depends on it',
176
+ examples: ['frontera secret delete STRIPE_API_KEY'],
177
+ },
178
+ async run(ctx) {
179
+ const name = ctx.positional[0]
180
+ if (!name) throw new UsageError('missing <name>', 'frontera secret list — then pass a name')
181
+
182
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
183
+ await api.deleteWorkspaceSecret(await requireWorkspaceId(api), name)
184
+
185
+ // No `--force`. The service refuses while anything still resolves the
186
+ // secret and says what, and an override here would only move the outage
187
+ // from this command to whatever was depending on it.
188
+ return {
189
+ data: { name, deleted: true },
190
+ text: `deleted ${name}`,
191
+ }
192
+ },
193
+ }
194
+
195
+ export const secretCommands: Command[] = [list, set, remove]
@@ -0,0 +1,327 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join, relative, sep } from 'node:path'
3
+
4
+ import matter from 'gray-matter'
5
+
6
+ import { PlatformApi } from '../../api/platform-api'
7
+ import { CliError, UsageError } from '../../errors'
8
+ import { flagBool, flagString, type Command } from '../types'
9
+ import { resolveSkillRef } from './resolve'
10
+
11
+ /**
12
+ * Folder-based skill authoring: `pull` hydrates a bundle directory from the
13
+ * platform, `push` upserts one from disk. The layout is the same one the web
14
+ * ZIP round-trip uses (docs/workspace-skill-bundles.md):
15
+ *
16
+ * my-skill/
17
+ * ├── SKILL.md frontmatter + body
18
+ * ├── refs/ markdown references, inlined
19
+ * ├── scripts/ text scripts, inlined as fenced code (never executed)
20
+ * └── anything else — an asset, path kept verbatim
21
+ *
22
+ * `visible` assets are marked in SKILL.md frontmatter as `visibleAssets:` (a
23
+ * list of asset paths) so the flag survives the round-trip without inventing a
24
+ * sidecar metadata file.
25
+ */
26
+
27
+ interface SkillDoc {
28
+ id?: string
29
+ name?: string
30
+ displayName?: string
31
+ trigger?: string
32
+ description?: string
33
+ keywords?: string[] | null
34
+ body?: string
35
+ references?: Array<{ filename: string; content: string }> | null
36
+ scripts?: Array<{ path: string; content: string }> | null
37
+ assets?: Array<{
38
+ path: string
39
+ storageKey: string
40
+ mime: string
41
+ bytes: number
42
+ sha256: string
43
+ visible?: boolean
44
+ }> | null
45
+ enabled?: boolean
46
+ sortOrder?: number
47
+ }
48
+
49
+ const REF_DIRS = ['refs', 'references'] as const
50
+ const SCRIPT_DIR = 'scripts'
51
+
52
+ const MIME_BY_EXT: Record<string, string> = {
53
+ png: 'image/png',
54
+ jpg: 'image/jpeg',
55
+ jpeg: 'image/jpeg',
56
+ webp: 'image/webp',
57
+ gif: 'image/gif',
58
+ woff2: 'font/woff2',
59
+ woff: 'font/woff',
60
+ ttf: 'font/ttf',
61
+ css: 'text/css',
62
+ csv: 'text/csv',
63
+ json: 'application/json',
64
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
65
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
66
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
67
+ }
68
+
69
+ function mimeFromPath(path: string): string {
70
+ return MIME_BY_EXT[path.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream'
71
+ }
72
+
73
+ /** `workspace/{ws}/skill-assets/{sha}.{ext}` → `{sha}.{ext}`, or null. */
74
+ function assetFilename(storageKey: string): string | null {
75
+ const m = /^workspace\/[^/]+\/skill-assets\/([0-9a-f]{6,64}\.[A-Za-z0-9]+)$/.exec(storageKey)
76
+ return m ? m[1]! : null
77
+ }
78
+
79
+ function toSkillMd(doc: SkillDoc): string {
80
+ const data: Record<string, unknown> = { name: doc.name }
81
+ if (doc.displayName != null) data.displayName = doc.displayName
82
+ if (doc.trigger != null) data.trigger = doc.trigger
83
+ if (doc.description != null) data.description = doc.description
84
+ if (doc.keywords?.length) data.keywords = doc.keywords
85
+ if (doc.enabled != null) data.enabled = doc.enabled
86
+ if (typeof doc.sortOrder === 'number') data.sortOrder = doc.sortOrder
87
+ const visible = (doc.assets ?? []).filter((a) => a.visible).map((a) => a.path)
88
+ if (visible.length) data.visibleAssets = visible
89
+ return matter.stringify(doc.body ?? '', data)
90
+ }
91
+
92
+ /**
93
+ * Write one bundle file under `dir`. `rel` comes from the server (asset/script
94
+ * paths); the upsert validates it, but a compromised or older server must not
95
+ * be able to write outside the bundle directory — reject rather than trust.
96
+ */
97
+ function writeBundleFile(dir: string, rel: string, content: string | Uint8Array): void {
98
+ if (!rel || rel.startsWith('/') || rel.split('/').some((seg) => seg === '..' || seg === '')) {
99
+ throw new CliError(`refusing to write "${rel}" — path escapes the bundle directory`, {
100
+ code: 'VALIDATION',
101
+ hint: 'fix the asset/script path on the server and pull again',
102
+ })
103
+ }
104
+ const path = join(dir, ...rel.split('/'))
105
+ mkdirSync(dirname(path), { recursive: true })
106
+ if (typeof content === 'string') writeFileSync(path, content, 'utf8')
107
+ else writeFileSync(path, content)
108
+ }
109
+
110
+ const pull: Command = {
111
+ meta: {
112
+ noun: 'skill',
113
+ verb: 'pull',
114
+ args: [{ name: 'skill', required: true, description: 'skill name or id, from `frontera skill list`' }],
115
+ flags: { dir: 'string', force: 'boolean' },
116
+ summary: 'Hydrate a skill bundle folder (SKILL.md, refs/, scripts/, assets) from the platform',
117
+ examples: [
118
+ 'frontera skill pull brand-guidelines',
119
+ 'frontera skill pull brand-guidelines --dir ~/skills/brand-guidelines --force',
120
+ ],
121
+ optionalProject: true,
122
+ },
123
+
124
+ async run(ctx) {
125
+ const ref = ctx.positional[0]
126
+ if (!ref) throw new UsageError('missing <skill>', 'frontera skill pull <skill> [--dir <path>]')
127
+
128
+ const client = new PlatformApi(ctx.apiUrl, ctx.token)
129
+ const id = await resolveSkillRef(client, ref)
130
+ const doc = (await client.workspaceSkill(id)) as SkillDoc
131
+ if (!doc.name) throw new CliError('skill has no name', { code: 'INTERNAL_ERROR', hint: 'run `frontera skill list` and pull by id' })
132
+
133
+ const dir = flagString(ctx, 'dir') ?? join(ctx.cwd, doc.name)
134
+ const skillMdPath = join(dir, 'SKILL.md')
135
+ if (existsSync(skillMdPath) && !flagBool(ctx, 'force')) {
136
+ throw new CliError(`refusing to overwrite ${skillMdPath}`, {
137
+ code: 'CONFLICT',
138
+ hint: 're-run with --force to replace the bundle in place',
139
+ })
140
+ }
141
+
142
+ mkdirSync(dirname(skillMdPath), { recursive: true })
143
+ writeFileSync(skillMdPath, toSkillMd(doc), 'utf8')
144
+ const written: string[] = ['SKILL.md']
145
+
146
+ for (const ref of doc.references ?? []) {
147
+ writeBundleFile(dir, `refs/${ref.filename}`, ref.content)
148
+ written.push(`refs/${ref.filename}`)
149
+ }
150
+ for (const script of doc.scripts ?? []) {
151
+ writeBundleFile(dir, script.path, script.content)
152
+ written.push(script.path)
153
+ }
154
+
155
+ // Assets need the workspace segment for the download route; a workspace
156
+ // key resolves its own scope, so whoami is authoritative.
157
+ const assets = doc.assets ?? []
158
+ if (assets.length > 0) {
159
+ const who = await client.whoami()
160
+ if (!who.workspaceId) {
161
+ throw new CliError('cannot download assets without a workspace scope', {
162
+ code: 'FORBIDDEN',
163
+ hint: 'use a workspace-scoped API key',
164
+ })
165
+ }
166
+ for (const asset of assets) {
167
+ const filename = assetFilename(asset.storageKey)
168
+ if (!filename) continue
169
+ const bytes = await client.downloadSkillAsset(who.workspaceId, filename)
170
+ writeBundleFile(dir, asset.path, bytes)
171
+ written.push(`${asset.path}${asset.visible ? ' *' : ''}`)
172
+ }
173
+ }
174
+
175
+ return {
176
+ data: { name: doc.name, dir, files: written },
177
+ text: [`pulled ${doc.name} → ${dir}`, ...written.map((f) => ` ${f}`)].join('\n'),
178
+ }
179
+ },
180
+ }
181
+
182
+ /** Walk a bundle dir, classify every file the way the web importer does. */
183
+ export function readBundle(dir: string): {
184
+ skillMd: string
185
+ references: Array<{ filename: string; content: string }>
186
+ scripts: Array<{ path: string; content: string }>
187
+ assetFiles: Array<{ path: string; bytes: Uint8Array }>
188
+ } {
189
+ const skillMdPath = join(dir, 'SKILL.md')
190
+ if (!existsSync(skillMdPath)) {
191
+ throw new CliError(`no SKILL.md in ${dir}`, {
192
+ code: 'NOT_FOUND',
193
+ hint: 'point at a bundle folder — see docs/workspace-skill-bundles.md',
194
+ })
195
+ }
196
+
197
+ const references: Array<{ filename: string; content: string }> = []
198
+ const scripts: Array<{ path: string; content: string }> = []
199
+ const assetFiles: Array<{ path: string; bytes: Uint8Array }> = []
200
+ const fatalUtf8 = new TextDecoder('utf-8', { fatal: true })
201
+
202
+ const walk = (current: string): void => {
203
+ for (const entry of readdirSync(current)) {
204
+ const full = join(current, entry)
205
+ const rel = relative(dir, full).split(sep).join('/')
206
+ const base = entry
207
+ if (base === '.DS_Store' || base.startsWith('._') || base === 'Thumbs.db' || base === '.git') continue
208
+ if (statSync(full).isDirectory()) {
209
+ walk(full)
210
+ continue
211
+ }
212
+ if (rel === 'SKILL.md') continue
213
+ const [top] = rel.split('/')
214
+ if ((REF_DIRS as readonly string[]).includes(top!)) {
215
+ references.push({ filename: rel.split('/').slice(1).join('/'), content: readFileSync(full, 'utf8') })
216
+ continue
217
+ }
218
+ if (top === SCRIPT_DIR) {
219
+ try {
220
+ scripts.push({ path: rel, content: fatalUtf8.decode(readFileSync(full)) })
221
+ } catch {
222
+ throw new CliError(`script "${rel}" is not valid UTF-8 text`, {
223
+ code: 'VALIDATION',
224
+ hint: 'scripts/ may only contain text files — put binaries under assets/',
225
+ })
226
+ }
227
+ continue
228
+ }
229
+ // Loose markdown is neither reference nor asset — same rule as the web
230
+ // importer, which ignores it rather than guessing.
231
+ if (rel.endsWith('.md')) continue
232
+ assetFiles.push({ path: rel, bytes: new Uint8Array(readFileSync(full)) })
233
+ }
234
+ }
235
+ walk(dir)
236
+
237
+ references.sort((a, b) => a.filename.localeCompare(b.filename))
238
+ scripts.sort((a, b) => a.path.localeCompare(b.path))
239
+ assetFiles.sort((a, b) => a.path.localeCompare(b.path))
240
+ return { skillMd: readFileSync(skillMdPath, 'utf8'), references, scripts, assetFiles }
241
+ }
242
+
243
+ const push: Command = {
244
+ meta: {
245
+ noun: 'skill',
246
+ verb: 'push',
247
+ args: [{ name: 'dir', required: false, description: 'bundle folder (default: current directory)' }],
248
+ flags: { dir: 'string' },
249
+ summary: 'Create or update a workspace skill from a bundle folder, uploading its assets',
250
+ examples: ['frontera skill push ./brand-guidelines', 'frontera skill push'],
251
+ optionalProject: true,
252
+ },
253
+
254
+ async run(ctx) {
255
+ const dir = ctx.positional[0] ?? flagString(ctx, 'dir') ?? ctx.cwd
256
+ const bundle = readBundle(dir)
257
+
258
+ const parsed = matter(bundle.skillMd)
259
+ const fm = parsed.data as Record<string, unknown>
260
+ const name = typeof fm.name === 'string' ? fm.name.trim() : ''
261
+ if (!name) {
262
+ throw new CliError('SKILL.md frontmatter is missing "name"', {
263
+ code: 'VALIDATION',
264
+ hint: 'the frontmatter name is the canonical identity — folder names are cosmetic',
265
+ })
266
+ }
267
+ const visibleAssets = Array.isArray(fm.visibleAssets)
268
+ ? fm.visibleAssets.filter((v): v is string => typeof v === 'string')
269
+ : []
270
+
271
+ const api = client(ctx)
272
+ const assets: NonNullable<SkillDoc['assets']> = []
273
+ for (const file of bundle.assetFiles) {
274
+ const mime = mimeFromPath(file.path)
275
+ const uploaded = await api.uploadSkillAsset(file.bytes, file.path.split('/').pop()!, mime)
276
+ const sha = assetFilename(uploaded.storagePath)?.split('.')[0] ?? ''
277
+ assets.push({
278
+ path: file.path,
279
+ storageKey: uploaded.storagePath,
280
+ mime: uploaded.mediaType || mime,
281
+ bytes: file.bytes.length,
282
+ sha256: sha,
283
+ ...(visibleAssets.includes(file.path) ? { visible: true } : {}),
284
+ })
285
+ }
286
+
287
+ const skill = {
288
+ name,
289
+ displayName: typeof fm.displayName === 'string' ? fm.displayName : undefined,
290
+ trigger: typeof fm.trigger === 'string' && fm.trigger.trim() ? fm.trigger.trim() : undefined,
291
+ description: typeof fm.description === 'string' ? fm.description : undefined,
292
+ keywords: Array.isArray(fm.keywords)
293
+ ? fm.keywords.filter((k): k is string => typeof k === 'string')
294
+ : undefined,
295
+ body: parsed.content.replace(/^\n+/, ''),
296
+ references: bundle.references.length ? bundle.references : undefined,
297
+ scripts: bundle.scripts.length ? bundle.scripts : undefined,
298
+ assets: assets.length ? assets : undefined,
299
+ enabled: typeof fm.enabled === 'boolean' ? fm.enabled : undefined,
300
+ sortOrder: typeof fm.sortOrder === 'number' ? fm.sortOrder : undefined,
301
+ }
302
+
303
+ const summary = await api.importWorkspaceSkills([skill])
304
+ if (summary.errors?.length) {
305
+ throw new CliError(`push failed: ${summary.errors[0]!.message}`, {
306
+ code: 'VALIDATION',
307
+ hint: 'fix the bundle and re-run `frontera skill push`',
308
+ })
309
+ }
310
+
311
+ const verb = summary.created > 0 ? 'created' : 'updated'
312
+ return {
313
+ data: { name, ...summary, assets: assets.length, scripts: bundle.scripts.length, references: bundle.references.length },
314
+ text:
315
+ `${verb} ${name} — ${bundle.references.length} reference(s), ` +
316
+ `${bundle.scripts.length} script(s), ${assets.length} asset(s)` +
317
+ (assets.some((a) => a.visible) ? ` (${assets.filter((a) => a.visible).length} visible)` : ''),
318
+ }
319
+ },
320
+ }
321
+
322
+ // One PlatformApi per run is enough; a tiny helper keeps push readable.
323
+ function client(ctx: { apiUrl: string; token: string }): PlatformApi {
324
+ return new PlatformApi(ctx.apiUrl, ctx.token)
325
+ }
326
+
327
+ export const skillBundleCommands: Command[] = [pull, push]
@@ -1,7 +1,9 @@
1
1
  import { PlatformApi } from '../../api/platform-api'
2
- import { CliError, UsageError } from '../../errors'
2
+ import { UsageError } from '../../errors'
3
3
  import { table } from '../../table'
4
4
  import type { Command } from '../types'
5
+ import { resolveSkillRef } from './resolve'
6
+ import { skillBundleCommands } from './bundle-commands'
5
7
 
6
8
  interface SkillRow {
7
9
  id?: string
@@ -16,30 +18,6 @@ interface SkillRow {
16
18
  * harness and is written by `frontera init`, and it is not `frontera app add`,
17
19
  * which copies registry source into a project. One word, one meaning.
18
20
  */
19
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
20
-
21
- async function resolveSkillRef(client: PlatformApi, ref: string): Promise<string> {
22
- if (UUID.test(ref)) return ref
23
-
24
- const rows = (await client.workspaceSkills()) as SkillRow[]
25
- const match =
26
- rows.find((s) => (s.name ?? '').toLowerCase() === ref.toLowerCase()) ??
27
- rows.find((s) => (s.displayName ?? '').toLowerCase() === ref.toLowerCase())
28
- if (match?.id) return match.id
29
-
30
- const near = rows
31
- .filter((s) => `${s.name ?? ''} ${s.displayName ?? ''}`.toLowerCase().includes(ref.toLowerCase()))
32
- .map((s) => s.name)
33
- .filter(Boolean)
34
-
35
- throw new CliError(`no skill named "${ref}"`, {
36
- code: 'NOT_FOUND',
37
- hint:
38
- near.length > 0
39
- ? `did you mean ${near.slice(0, 3).join(', ')}?`
40
- : 'run `frontera skill list` to see names and ids',
41
- })
42
- }
43
21
 
44
22
  const list: Command = {
45
23
  meta: {
@@ -91,6 +69,8 @@ const get: Command = {
91
69
  trigger?: string
92
70
  keywords?: string[] | null
93
71
  references?: Array<{ filename?: string }> | null
72
+ scripts?: Array<{ path?: string }> | null
73
+ assets?: Array<{ path?: string; visible?: boolean }> | null
94
74
  }
95
75
 
96
76
  // The body is markdown a person reads, and JSON-encoding it turns every
@@ -102,6 +82,16 @@ const get: Command = {
102
82
  ['trigger', doc.trigger],
103
83
  ['keywords', doc.keywords?.join(', ')],
104
84
  ['references', doc.references?.map((r) => r.filename).filter(Boolean).join(', ')],
85
+ ['scripts', doc.scripts?.map((s) => s.path).filter(Boolean).join(', ')],
86
+ // Bundled binaries. `*` marks the ones loaded into model context on every
87
+ // skill load, since that is the line item with an ongoing token cost.
88
+ [
89
+ 'assets',
90
+ doc.assets
91
+ ?.map((a) => `${a.path}${a.visible ? ' *' : ''}`)
92
+ .filter(Boolean)
93
+ .join(', '),
94
+ ],
105
95
  ].filter(([, v]) => v) as Array<[string, string]>
106
96
 
107
97
  return {
@@ -115,26 +105,30 @@ const get: Command = {
115
105
  },
116
106
  }
117
107
 
118
- const RESERVED = 'skill writes are not available in this release; list and get are read-only'
119
-
120
- const reserved: Command[] = (
121
- [
122
- ['apply', 'Create or update a workspace skill from a document'],
123
- ['delete', 'Delete a workspace skill'],
124
- ] as const
125
- ).map(([verb, summary]) => ({
108
+ const remove: Command = {
126
109
  meta: {
127
110
  noun: 'skill',
128
- verb,
129
- args: [],
111
+ verb: 'delete',
112
+ args: [{ name: 'skill', required: true, description: 'skill name or id, from `frontera skill list`' }],
130
113
  flags: {},
131
- summary,
132
- examples: [`frontera skill ${verb} <id>`],
133
- reserved: RESERVED,
114
+ summary: 'Delete a workspace skill and its bindings to agents',
115
+ examples: ['frontera skill delete brand-guidelines'],
134
116
  },
135
- async run(): Promise<never> {
136
- throw new Error(RESERVED)
117
+ async run(ctx) {
118
+ const ref = ctx.positional[0]
119
+ if (!ref) throw new UsageError('missing <skill>', 'frontera skill list — then pass a name or id')
120
+
121
+ const client = new PlatformApi(ctx.apiUrl, ctx.token)
122
+ const id = await resolveSkillRef(client, ref)
123
+ await client.deleteWorkspaceSkill(id)
124
+
125
+ return {
126
+ data: { id, name: ref, deleted: true },
127
+ // Bindings going too is the part worth stating: an agent that loaded this
128
+ // skill loses it at its next run, with no version to fall back to.
129
+ text: `deleted ${ref} — any agent bound to it no longer loads it`,
130
+ }
137
131
  },
138
- }))
132
+ }
139
133
 
140
- export const skillCommands: Command[] = [list, get, ...reserved]
134
+ export const skillCommands: Command[] = [list, get, ...skillBundleCommands, remove]
@@ -0,0 +1,34 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { CliError } from '../../errors'
3
+
4
+ interface SkillRow {
5
+ id?: string
6
+ name?: string
7
+ displayName?: string
8
+ description?: string
9
+ }
10
+
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
12
+
13
+ export async function resolveSkillRef(client: PlatformApi, ref: string): Promise<string> {
14
+ if (UUID.test(ref)) return ref
15
+
16
+ const rows = (await client.workspaceSkills()) as SkillRow[]
17
+ const match =
18
+ rows.find((s) => (s.name ?? '').toLowerCase() === ref.toLowerCase()) ??
19
+ rows.find((s) => (s.displayName ?? '').toLowerCase() === ref.toLowerCase())
20
+ if (match?.id) return match.id
21
+
22
+ const near = rows
23
+ .filter((s) => `${s.name ?? ''} ${s.displayName ?? ''}`.toLowerCase().includes(ref.toLowerCase()))
24
+ .map((s) => s.name)
25
+ .filter(Boolean)
26
+
27
+ throw new CliError(`no skill named "${ref}"`, {
28
+ code: 'NOT_FOUND',
29
+ hint:
30
+ near.length > 0
31
+ ? `did you mean ${near.slice(0, 3).join(', ')}?`
32
+ : 'run `frontera skill list` to see names and ids',
33
+ })
34
+ }