@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.
- package/package.json +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- 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 {
|
|
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
|
|
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: [
|
|
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()
|
|
136
|
-
|
|
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, ...
|
|
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
|
+
}
|