@frontera-sdk/cli 0.1.0

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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. package/src/vendor/sdk-sources.json +25 -0
@@ -0,0 +1,193 @@
1
+ import { FronteraClient } from '@frontera-sdk/core/client'
2
+
3
+ export interface AgentDraft {
4
+ agentId?: string
5
+ revision: number
6
+ updatedBy?: string
7
+ updatedAt?: string
8
+ snapshot: Record<string, unknown>
9
+ }
10
+
11
+ /**
12
+ * Client for the platform's resource routes.
13
+ *
14
+ * One class rather than one per noun: they share a base URL, a credential and
15
+ * an envelope, and splitting them would multiply construction without
16
+ * separating anything real. The nouns are separated where it matters — in the
17
+ * commands.
18
+ */
19
+ export class PlatformApi {
20
+ private readonly client: FronteraClient
21
+
22
+ constructor(apiBaseUrl: string, token: string) {
23
+ this.client = new FronteraClient({
24
+ apiBaseUrl,
25
+ credential: { kind: 'apiKey', key: token },
26
+ })
27
+ }
28
+
29
+ private get<T>(path: string): Promise<T> {
30
+ return this.client.request<T>(path)
31
+ }
32
+
33
+ /**
34
+ * Normalise a list response.
35
+ *
36
+ * Some routers return `ok(result)` where `result` is itself `{ data: [...] }`
37
+ * — `/v1/apps` and `/v1/workspace-knowledge` both do — so after the SDK
38
+ * unwraps the outer envelope a second one is left. Rather than encode which
39
+ * endpoints double-wrap (a list that would rot), accept either shape and
40
+ * treat anything else as empty.
41
+ */
42
+ private async getList<T>(path: string): Promise<T[]> {
43
+ const body = await this.get<T[] | { data?: T[] }>(path)
44
+ if (Array.isArray(body)) return body
45
+ const inner = (body as { data?: T[] })?.data
46
+ return Array.isArray(inner) ? inner : []
47
+ }
48
+
49
+ // ── Identity ──────────────────────────────────────────────────────────────
50
+
51
+ whoami() {
52
+ return this.get<{ orgId: string | null; workspaceId: string | null; authKind: string; principal: string | null }>(
53
+ '/v1/whoami',
54
+ )
55
+ }
56
+
57
+ // ── Blueprint (read) ──────────────────────────────────────────────────────
58
+
59
+ blueprintObjectTypes() {
60
+ return this.getList<unknown>('/v1/blueprint/object-types')
61
+ }
62
+
63
+ blueprintObjectType(apiName: string) {
64
+ return this.get<unknown>(`/v1/blueprint/object-types/${encodeURIComponent(apiName)}`)
65
+ }
66
+
67
+ blueprintMetrics() {
68
+ return this.getList<unknown>('/v1/blueprint/metrics')
69
+ }
70
+
71
+ /** How object types relate. Without these the catalog reads as a pile of
72
+ * disconnected tables, and nothing can be joined. */
73
+ blueprintLinkTypes() {
74
+ return this.getList<unknown>('/v1/blueprint/link-types')
75
+ }
76
+
77
+ // ── Agents ────────────────────────────────────────────────────────────────
78
+
79
+ agents() {
80
+ return this.getList<unknown>('/v1/config/agents')
81
+ }
82
+
83
+ agent(id: string) {
84
+ return this.get<unknown>(`/v1/config/agents/${encodeURIComponent(id)}`)
85
+ }
86
+
87
+ /**
88
+ * The agent's full composition: prompts, skills, models, knowledge, app
89
+ * installs, MCP servers, channels, automations.
90
+ *
91
+ * `GET /config/agents/:id` returns the config ROW — flat scalars with every
92
+ * relation null — so it answers "what are its settings" and not "what is
93
+ * this agent made of". Nobody can review or rebuild an agent from that.
94
+ */
95
+ async agentComposition(id: string): Promise<Record<string, unknown>> {
96
+ const body = await this.get<{ data?: Record<string, unknown> } | Record<string, unknown>>(
97
+ `/v1/config/agents/${encodeURIComponent(id)}/snapshot/live`,
98
+ )
99
+ return ((body as { data?: Record<string, unknown> })?.data ?? body ?? {}) as Record<string, unknown>
100
+ }
101
+
102
+ /** Frontera Apps in this workspace (not integrations — see `plugin`). */
103
+ platformApps() {
104
+ return this.getList<unknown>('/v1/platform-apps/')
105
+ }
106
+
107
+ /** `{ agentId, revision, snapshot }`, or null when nothing is staged. */
108
+ async agentDraft(id: string): Promise<AgentDraft | null> {
109
+ // Wrapped twice: `ok({ data: … })` around the SDK's own envelope.
110
+ const body = await this.get<{ data?: AgentDraft | null } | AgentDraft | null>(
111
+ `/v1/config/agents/${encodeURIComponent(id)}/draft-v2`,
112
+ )
113
+ if (!body) return null
114
+ const inner = (body as { data?: AgentDraft | null }).data
115
+ return inner === undefined ? (body as AgentDraft) : inner
116
+ }
117
+
118
+ /**
119
+ * Stage a partial snapshot onto the draft.
120
+ *
121
+ * `expectedRevision` is the whole concurrency story: undefined is
122
+ * collaborative, a number means "the draft I read", and a stale one comes
123
+ * back as DRAFT_CONFLICT rather than overwriting whoever wrote first.
124
+ */
125
+ async patchAgentDraft(
126
+ id: string,
127
+ patch: Record<string, unknown>,
128
+ expectedRevision?: number,
129
+ ): Promise<AgentDraft> {
130
+ const body = await this.client.request<{ data?: AgentDraft } | AgentDraft>(
131
+ `/v1/config/agents/${encodeURIComponent(id)}/draft-v2`,
132
+ {
133
+ method: 'PATCH',
134
+ body: expectedRevision === undefined ? { patch } : { patch, expectedRevision },
135
+ },
136
+ )
137
+ return ((body as { data?: AgentDraft }).data ?? body) as AgentDraft
138
+ }
139
+
140
+ publishAgent(id: string, args: { notes?: string; expectedRevision?: number } = {}) {
141
+ return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/publish`, {
142
+ method: 'POST',
143
+ body: args,
144
+ })
145
+ }
146
+
147
+ discardAgentDraft(id: string) {
148
+ return this.client.request<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/draft-v2`, {
149
+ method: 'DELETE',
150
+ })
151
+ }
152
+
153
+ agentVersions(id: string) {
154
+ return this.getList<unknown>(`/v1/config/agents/${encodeURIComponent(id)}/versions`)
155
+ }
156
+
157
+ // ── Skills ────────────────────────────────────────────────────────────────
158
+
159
+ workspaceSkills() {
160
+ return this.getList<unknown>('/v1/config/workspace-skills')
161
+ }
162
+
163
+ workspaceSkill(id: string) {
164
+ return this.get<unknown>(`/v1/config/workspace-skills/${encodeURIComponent(id)}`)
165
+ }
166
+
167
+ // ── Plugins (integrations and MCP servers) ────────────────────────────────
168
+
169
+ pluginInstalls() {
170
+ return this.getList<unknown>('/v1/apps/')
171
+ }
172
+
173
+ pluginCatalog() {
174
+ return this.getList<unknown>('/v1/apps/catalog')
175
+ }
176
+
177
+ // ── Knowledge ─────────────────────────────────────────────────────────────
178
+
179
+ knowledgeBases(workspaceId: string) {
180
+ return this.getList<unknown>(`/v1/workspace-knowledge?workspaceId=${encodeURIComponent(workspaceId)}`)
181
+ }
182
+
183
+ /**
184
+ * Sources in a WORKSPACE knowledge base, keyed by its id.
185
+ *
186
+ * Not `/v1/knowledge/:name/sources` — that is the per-AGENT path, and it
187
+ * defaults `agentName` to "default", so a workspace base's name reached a
188
+ * uuid column and came back as a raw postgres 22P02 rendered as a 500.
189
+ */
190
+ knowledgeSources(id: string) {
191
+ return this.getList<unknown>(`/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`)
192
+ }
193
+ }
@@ -0,0 +1,43 @@
1
+ export interface RegistryFile {
2
+ path: string
3
+ content: string
4
+ type: 'ui' | 'lib' | 'style' | 'skill'
5
+ }
6
+
7
+ export interface RegistryItem {
8
+ name: string
9
+ type: 'ui' | 'lib' | 'style' | 'skill'
10
+ version: string
11
+ description: string
12
+ dependencies: string[]
13
+ registryDependencies: string[]
14
+ files: RegistryFile[]
15
+ }
16
+
17
+ /**
18
+ * Client for the component registry.
19
+ *
20
+ * Unauthenticated on purpose (see the service router): this serves the same
21
+ * first-party component source that ships in the platform's own bundle, and
22
+ * requiring a credential would stop `frontera app init` scaffolding before a
23
+ * key exists.
24
+ */
25
+ export class RegistryApi {
26
+ constructor(private readonly apiBaseUrl: string) {}
27
+
28
+ async list(): Promise<Array<Omit<RegistryItem, 'files'>>> {
29
+ const res = await fetch(`${this.apiBaseUrl}/v1/apps/registry`)
30
+ if (!res.ok) throw new Error(`registry unavailable (${res.status})`)
31
+ const body = (await res.json()) as { data: Array<Omit<RegistryItem, 'files'>> }
32
+ return body.data
33
+ }
34
+
35
+ /** The item plus its transitive dependencies, dependencies first. */
36
+ async resolve(name: string): Promise<RegistryItem[]> {
37
+ const res = await fetch(`${this.apiBaseUrl}/v1/apps/registry/${encodeURIComponent(name)}`)
38
+ if (res.status === 404) throw new Error(`unknown registry item: ${name}`)
39
+ if (!res.ok) throw new Error(`registry unavailable (${res.status})`)
40
+ const body = (await res.json()) as { data: RegistryItem[] }
41
+ return body.data
42
+ }
43
+ }
package/src/args.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { UsageError } from './errors'
2
+
3
+ export type FlagKind = 'string' | 'boolean'
4
+ export type FlagSpec = Readonly<Record<string, FlagKind>>
5
+
6
+ export interface ParsedArgs {
7
+ positional: string[]
8
+ flags: Record<string, string | boolean>
9
+ }
10
+
11
+ /**
12
+ * Argument parser.
13
+ *
14
+ * Strict on purpose. The parser it replaces returned `args[i + 1]` for any
15
+ * `--flag`, so `--dir --force` set the directory to "--force" and the command
16
+ * ran against a path that does not exist while reporting success. It also
17
+ * ignored unknown flags outright, so a typo silently changed nothing. Both
18
+ * failures are worse for a model caller than for a person: the command appears
19
+ * to work, so nothing prompts a second look.
20
+ *
21
+ * Every rejection is a `UsageError`, which the shell maps to exit 2 — the code
22
+ * that tells a caller to fix the command rather than retry it.
23
+ */
24
+ export function parseArgs(
25
+ argv: string[],
26
+ spec: FlagSpec,
27
+ aliases: Readonly<Record<string, string>> = {},
28
+ ): ParsedArgs {
29
+ const positional: string[] = []
30
+ const flags: Record<string, string | boolean> = {}
31
+
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ let token = argv[i]!
34
+
35
+ // Short flags (`-f value`, `-f=value`). Conventional for the common ones,
36
+ // and their absence silently turned `-f` into a positional argument — so a
37
+ // documented example read the file it was given as an agent name instead.
38
+ if (/^-[A-Za-z]/.test(token)) {
39
+ const eqAt = token.indexOf('=')
40
+ const short = (eqAt === -1 ? token.slice(1) : token.slice(1, eqAt))
41
+ const long = aliases[short]
42
+ if (!long) {
43
+ throw new UsageError(
44
+ `unknown flag: -${short}`,
45
+ 'run the command with --help to see the flags it accepts',
46
+ )
47
+ }
48
+ token = eqAt === -1 ? `--${long}` : `--${long}${token.slice(eqAt)}`
49
+ }
50
+
51
+ // `--` ends flag parsing. Everything after is positional, even if it
52
+ // starts with dashes — the escape hatch for a literal value.
53
+ if (token === '--') {
54
+ positional.push(...argv.slice(i + 1))
55
+ break
56
+ }
57
+
58
+ // A lone `-` is the stdin convention, and `-1` is a value. Neither is a
59
+ // flag, so only `--` prefixes are parsed as one.
60
+ if (!token.startsWith('--')) {
61
+ positional.push(token)
62
+ continue
63
+ }
64
+
65
+ const eq = token.indexOf('=')
66
+ const name = eq === -1 ? token.slice(2) : token.slice(2, eq)
67
+ const kind = spec[name]
68
+
69
+ if (!kind) {
70
+ throw new UsageError(
71
+ `unknown flag: --${name}`,
72
+ 'run the command with --help to see the flags it accepts',
73
+ )
74
+ }
75
+
76
+ if (kind === 'boolean') {
77
+ if (eq !== -1) {
78
+ throw new UsageError(
79
+ `--${name} is a boolean flag and takes no value`,
80
+ `pass --${name} on its own`,
81
+ )
82
+ }
83
+ flags[name] = true
84
+ continue
85
+ }
86
+
87
+ if (eq !== -1) {
88
+ // `--dir=` is a deliberate empty string, not a missing value.
89
+ flags[name] = token.slice(eq + 1)
90
+ continue
91
+ }
92
+
93
+ const value = argv[i + 1]
94
+ // The defect this replaces: a following flag is not a value. `--` is not
95
+ // one either — it terminates parsing, so consuming it would swallow the
96
+ // escape hatch.
97
+ if (value === undefined || value === '--' || (value.startsWith('--') && value.length > 2)) {
98
+ throw new UsageError(
99
+ `--${name} needs a value`,
100
+ `pass it as --${name} <value> or --${name}=<value>`,
101
+ )
102
+ }
103
+ flags[name] = value
104
+ i += 1
105
+ }
106
+
107
+ return { positional, flags }
108
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Render an agent's composition the way someone configuring it needs to see it.
3
+ *
4
+ * Two things this exists to avoid. The default read used to be the config row:
5
+ * flat scalars, every relation null — it told you the temperature and nothing
6
+ * about what the agent is made of. And the snapshot's relations carry only
7
+ * uuids, so printing them raw ("Knowledge: a7452079-…") is technically
8
+ * complete and practically useless. Ids are resolved to names here.
9
+ */
10
+
11
+ type Row = Record<string, unknown>
12
+
13
+ export interface Lookups {
14
+ /** installId → readable plugin name. */
15
+ plugins: Map<string, string>
16
+ /** knowledge config id → readable name. */
17
+ knowledge: Map<string, string>
18
+ /** workspace skill id → readable name. */
19
+ skills: Map<string, string>
20
+ /** Identity the snapshot's config does not carry. */
21
+ identity?: { id?: string; slug?: string; kind?: string; version?: number }
22
+ }
23
+
24
+ function arr(value: unknown): Row[] {
25
+ return Array.isArray(value) ? (value as Row[]) : []
26
+ }
27
+
28
+ function str(row: Row, ...keys: string[]): string {
29
+ for (const k of keys) {
30
+ const v = row[k]
31
+ if (typeof v === 'string' && v.length > 0) return v
32
+ }
33
+ return ''
34
+ }
35
+
36
+ function section(title: string, items: string[]): string[] {
37
+ if (items.length === 0) return [`${title}: —`]
38
+ return [`${title} (${items.length})`, ...items.map((i) => ` ${i}`)]
39
+ }
40
+
41
+ /** Name if we have one, id if we do not — never a bare uuid with no context. */
42
+ function label(id: string, lookup: Map<string, string>): string {
43
+ const name = lookup.get(id)
44
+ return name ? name : `${id} (unresolved)`
45
+ }
46
+
47
+ export function renderComposition(snapshot: Row, lookups: Lookups): string {
48
+ const config = (snapshot.config ?? {}) as Row
49
+ const lines: string[] = []
50
+
51
+ lines.push(str(config, 'name') || str(config, 'agentId'))
52
+ if (str(config, 'description')) lines.push(str(config, 'description'))
53
+ lines.push('')
54
+
55
+ for (const [k, v] of [
56
+ ['slug', str(config, 'agentId') || (lookups.identity?.slug ?? '')],
57
+ ['id', str(config, 'id') || (lookups.identity?.id ?? '')],
58
+ ['kind', str(config, 'kind') || (lookups.identity?.kind ?? '')],
59
+ ['status', str(config, 'lifecycleStatus')],
60
+ ['default mode', str(config, 'defaultMode')],
61
+ ['live version', lookups.identity?.version ? String(lookups.identity.version) : ''],
62
+ ] as Array<[string, string]>) {
63
+ if (v) lines.push(`${k.padEnd(14)}${v}`)
64
+ }
65
+ lines.push('')
66
+
67
+ lines.push(
68
+ ...section(
69
+ 'Models',
70
+ arr(snapshot.modelConfigs).map((m) =>
71
+ `${str(m, 'modeKey').padEnd(22)}${str(m, 'modelSlug')}${
72
+ str(m, 'displayName') ? ` (${str(m, 'displayName')})` : ''
73
+ }`,
74
+ ),
75
+ ),
76
+ '',
77
+ ...section(
78
+ 'Prompts',
79
+ arr(snapshot.prompts).map((p) => {
80
+ const body = str(p, 'content').replace(/\s+/g, ' ')
81
+ return `${(str(p, 'kind', 'filename') || 'prompt').padEnd(22)}${body.slice(0, 60)}${
82
+ body.length > 60 ? '…' : ''
83
+ }`
84
+ }),
85
+ ),
86
+ '',
87
+ // `skills` is an OBJECT ({ workspaceBindings: [...] }), not an array —
88
+ // treating it as a list silently reported "no skills" for every agent
89
+ // that had them.
90
+ ...section(
91
+ 'Skills',
92
+ arr((snapshot.skills as Row | undefined)?.workspaceBindings).map((s) => {
93
+ const name = label(str(s, 'workspaceSkillId'), lookups.skills)
94
+ return s.enabled === false ? `${name} (disabled)` : name
95
+ }),
96
+ ),
97
+ '',
98
+ ...section(
99
+ 'Knowledge',
100
+ [
101
+ ...arr(snapshot.knowledgeAttachments).map((k) =>
102
+ label(str(k, 'knowledgeConfigId'), lookups.knowledge),
103
+ ),
104
+ ...arr(snapshot.knowledgeConfigs).map((k) => str(k, 'name') || label(str(k, 'id'), lookups.knowledge)),
105
+ ],
106
+ ),
107
+ '',
108
+ ...section('Plugins', [
109
+ ...arr(snapshot.appInstalls).map((a) => {
110
+ const name = label(str(a, 'installId'), lookups.plugins)
111
+ const off = a.enabled === false ? ' (disabled)' : ''
112
+ return `${name}${off}`
113
+ }),
114
+ ...arr(snapshot.mcpServers).map((m) => `${str(m, 'name', 'serverName', 'mcpServerId')} (mcp)`),
115
+ ]),
116
+ '',
117
+ ...section(
118
+ 'Channels',
119
+ // Channel names need `/v1/channels`, which is not on the programmatic
120
+ // auth surface — widening that for a label is not worth it, so the id is
121
+ // shown plainly rather than dressed up as a name.
122
+ arr(snapshot.channelBindings).map((c) => `channel ${str(c, 'channelId')}`),
123
+ ),
124
+ '',
125
+ ...section(
126
+ 'Automations',
127
+ arr(snapshot.automations).map((a) => str(a, 'name', 'schedule', 'id')),
128
+ ),
129
+ '',
130
+ ...section(
131
+ 'Packs',
132
+ arr(snapshot.appliedPacks).map((p) => str(p, 'name', 'packId', 'id')),
133
+ ),
134
+ )
135
+
136
+ // Capabilities are per-app grants and there are often dozens; a count with
137
+ // the approval posture is what a reviewer reads, and --json has the rest.
138
+ const caps = arr(snapshot.appCapabilities)
139
+ const needsApproval = caps.filter((c) => c.requiresApprovalOverride === true).length
140
+ lines.push(
141
+ '',
142
+ caps.length === 0
143
+ ? 'Capabilities: —'
144
+ : `Capabilities (${caps.length})` +
145
+ (needsApproval > 0 ? ` — ${needsApproval} require approval` : ''),
146
+ )
147
+
148
+ const caseDefinition = snapshot.caseDefinition as Row | null | undefined
149
+ if (caseDefinition && typeof caseDefinition === 'object') {
150
+ lines.push('', ...section('Case columns', arr(caseDefinition.columns).map((c) => str(c, 'name', 'key'))))
151
+ }
152
+
153
+ lines.push('', 'Full document: add --json')
154
+ return lines.join('\n')
155
+ }