@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.
- package/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
interface KnowledgeRow {
|
|
7
|
+
id?: string
|
|
8
|
+
name?: string
|
|
9
|
+
displayName?: string
|
|
10
|
+
description?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Accept a base by name or id — `knowledge list` shows both. */
|
|
14
|
+
async function resolveKnowledgeRef(client: PlatformApi, ref: string): Promise<string> {
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
16
|
+
if (UUID.test(ref)) return ref
|
|
17
|
+
|
|
18
|
+
const me = await client.whoami()
|
|
19
|
+
if (!me.workspaceId) {
|
|
20
|
+
throw new CliError('this credential is not scoped to a workspace', {
|
|
21
|
+
code: 'FORBIDDEN',
|
|
22
|
+
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
const rows = (await client.knowledgeBases(me.workspaceId)) as KnowledgeRow[]
|
|
26
|
+
const match = rows.find((k) => (k.name ?? '').toLowerCase() === ref.toLowerCase())
|
|
27
|
+
if (match?.id) return match.id
|
|
28
|
+
|
|
29
|
+
throw new CliError(`no knowledge base named "${ref}"`, {
|
|
30
|
+
code: 'NOT_FOUND',
|
|
31
|
+
hint: `known bases: ${rows.map((k) => k.name).filter(Boolean).join(', ') || '(none)'}`,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const list: Command = {
|
|
36
|
+
meta: {
|
|
37
|
+
noun: 'knowledge',
|
|
38
|
+
verb: 'list',
|
|
39
|
+
args: [],
|
|
40
|
+
flags: {},
|
|
41
|
+
summary: 'List knowledge bases in this workspace',
|
|
42
|
+
examples: ['frontera knowledge list', 'frontera knowledge list --json'],
|
|
43
|
+
},
|
|
44
|
+
async run(ctx) {
|
|
45
|
+
const api = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
46
|
+
// The route takes the workspace explicitly, and a key holder cannot see
|
|
47
|
+
// its own workspace id from anywhere else — hence /v1/whoami.
|
|
48
|
+
const me = await api.whoami()
|
|
49
|
+
if (!me.workspaceId) {
|
|
50
|
+
throw new CliError('this credential is not scoped to a workspace', {
|
|
51
|
+
code: 'FORBIDDEN',
|
|
52
|
+
hint: 'use a workspace key (sk-ws-…) created for the workspace you mean',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
const rows = (await api.knowledgeBases(me.workspaceId)) as KnowledgeRow[]
|
|
56
|
+
return {
|
|
57
|
+
data: rows,
|
|
58
|
+
text:
|
|
59
|
+
rows.length === 0
|
|
60
|
+
? 'No knowledge bases in this workspace.'
|
|
61
|
+
: // NAME first: it is the value `knowledge sources` takes, not the id.
|
|
62
|
+
// No `displayName` on these rows, so a column for it was always
|
|
63
|
+
// blank and shifted the description out of alignment.
|
|
64
|
+
table(
|
|
65
|
+
['name', 'description', 'id'],
|
|
66
|
+
rows.map((k) => [k.name ?? '?', k.description ?? '', k.id ?? '']),
|
|
67
|
+
[undefined, 60, undefined],
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sources: Command = {
|
|
74
|
+
meta: {
|
|
75
|
+
noun: 'knowledge',
|
|
76
|
+
verb: 'sources',
|
|
77
|
+
args: [{ name: 'base', required: true, description: 'knowledge base name or id, from `frontera knowledge list`' }],
|
|
78
|
+
flags: {},
|
|
79
|
+
summary: 'List the sources in one knowledge base',
|
|
80
|
+
examples: ['frontera knowledge sources product-docs'],
|
|
81
|
+
},
|
|
82
|
+
async run(ctx) {
|
|
83
|
+
const ref = ctx.positional[0]
|
|
84
|
+
if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
|
|
85
|
+
|
|
86
|
+
const client = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
87
|
+
const id = await resolveKnowledgeRef(client, ref)
|
|
88
|
+
const rows = (await client.knowledgeSources(id)) as Array<Record<string, unknown>>
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
data: rows,
|
|
92
|
+
text:
|
|
93
|
+
rows.length === 0
|
|
94
|
+
? 'No sources in this knowledge base.'
|
|
95
|
+
: table(
|
|
96
|
+
// `fileName`, not `name` — a source is an uploaded file, and
|
|
97
|
+
// guessing the field printed "?" for every row.
|
|
98
|
+
['file', 'status', 'chunks', 'id'],
|
|
99
|
+
rows.map((r) => [
|
|
100
|
+
String(r.fileName ?? '?'),
|
|
101
|
+
String(r.error ? `error: ${r.error}` : (r.status ?? '')),
|
|
102
|
+
String(r.chunkCount ?? ''),
|
|
103
|
+
String(r.id ?? ''),
|
|
104
|
+
]),
|
|
105
|
+
[50, 40, undefined, undefined],
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `upload` is the strongest case for this noun existing at all — nobody wants
|
|
113
|
+
* to drag two hundred PDFs into a browser — and is reserved rather than
|
|
114
|
+
* half-built: it needs per-file outcome reporting so one unreadable file does
|
|
115
|
+
* not fail a batch, which is the whole reason to prefer it over the UI.
|
|
116
|
+
*/
|
|
117
|
+
const RESERVED = 'knowledge writes are not available in this release; list and sources are read-only'
|
|
118
|
+
|
|
119
|
+
const reserved: Command[] = (
|
|
120
|
+
[
|
|
121
|
+
['create', 'Create a knowledge base'],
|
|
122
|
+
['upload', 'Upload files into a knowledge base'],
|
|
123
|
+
['delete', 'Delete a knowledge base'],
|
|
124
|
+
] as const
|
|
125
|
+
).map(([verb, summary]) => ({
|
|
126
|
+
meta: {
|
|
127
|
+
noun: 'knowledge',
|
|
128
|
+
verb,
|
|
129
|
+
args: [],
|
|
130
|
+
flags: {},
|
|
131
|
+
summary,
|
|
132
|
+
examples: [`frontera knowledge ${verb} <name>`],
|
|
133
|
+
reserved: RESERVED,
|
|
134
|
+
},
|
|
135
|
+
async run(): Promise<never> {
|
|
136
|
+
throw new Error(RESERVED)
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
export const knowledgeCommands: Command[] = [list, sources, ...reserved]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { PlatformApi } from '../api/platform-api'
|
|
2
|
+
import { writeStoredToken } from '../config'
|
|
3
|
+
import { CliError, UsageError } from '../errors'
|
|
4
|
+
import { readSecretValue } from '../secrets'
|
|
5
|
+
import { canPrompt, promptSecret } from '../prompt'
|
|
6
|
+
import type { Command } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Store a workspace key for an API origin.
|
|
10
|
+
*
|
|
11
|
+
* Non-interactive by construction: the token arrives on stdin or from the
|
|
12
|
+
* environment, never as a flag and never through a prompt. A prompt is where
|
|
13
|
+
* an agent hangs, and a flag lands the secret in process listings and shell
|
|
14
|
+
* history.
|
|
15
|
+
*
|
|
16
|
+
* The token is VERIFIED before it is written. Storing an unusable credential
|
|
17
|
+
* just moves the failure to the next command, where the cause is no longer
|
|
18
|
+
* obvious — and the check costs one request that also tells the caller which
|
|
19
|
+
* workspace it just bound to.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Where the token comes from, in the order a caller would expect.
|
|
23
|
+
*
|
|
24
|
+
* Piped input wins over a prompt, so `echo $KEY | frontera login` works
|
|
25
|
+
* without a flag — the clig.dev convention, and the reason `--token-stdin`
|
|
26
|
+
* is now an explicit opt-in rather than a requirement.
|
|
27
|
+
*
|
|
28
|
+
* The prompt is reached only on an interactive terminal. An agent's stdin is
|
|
29
|
+
* a pipe or /dev/null, so it lands in the stdin branch or the error, never on
|
|
30
|
+
* a prompt that would hang. `--no-input` forces that even on a TTY.
|
|
31
|
+
*/
|
|
32
|
+
async function readToken(flags: Record<string, string | boolean>): Promise<string> {
|
|
33
|
+
const noInput = flags['no-input'] === true
|
|
34
|
+
const explicitStdin = flags['token-stdin'] === true
|
|
35
|
+
|
|
36
|
+
if (explicitStdin || !canPrompt() || noInput) {
|
|
37
|
+
// Not a terminal: read whatever was piped in. Falls back to the
|
|
38
|
+
// environment so an already-exported key can simply be stored.
|
|
39
|
+
if (explicitStdin || !process.stdin.isTTY) {
|
|
40
|
+
const piped = (await readSecretValue('-')).trim()
|
|
41
|
+
if (piped) return piped
|
|
42
|
+
}
|
|
43
|
+
return (process.env.FRONTERA_TOKEN ?? '').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return promptSecret('Workspace key (sk-ws-…): ')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const loginCommand: Command = {
|
|
50
|
+
meta: {
|
|
51
|
+
noun: 'login',
|
|
52
|
+
verb: '',
|
|
53
|
+
args: [],
|
|
54
|
+
flags: { 'token-stdin': 'boolean', 'no-input': 'boolean' },
|
|
55
|
+
summary: 'Verify a workspace key and store it for this API origin',
|
|
56
|
+
examples: [
|
|
57
|
+
'frontera login --api-url http://localhost:4000',
|
|
58
|
+
'echo $KEY | frontera login --api-url http://localhost:4000',
|
|
59
|
+
'frontera login --api-url https://api.example.com --token-stdin < key.txt',
|
|
60
|
+
],
|
|
61
|
+
// It SUPPLIES a credential, so it must not require one to start.
|
|
62
|
+
offline: true,
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async run(ctx) {
|
|
66
|
+
if (!ctx.apiUrl) {
|
|
67
|
+
throw new UsageError(
|
|
68
|
+
'no API origin configured',
|
|
69
|
+
'set FRONTERA_API_URL, or pass --api-url <origin>',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const token = await readToken(ctx.flags)
|
|
74
|
+
|
|
75
|
+
if (!token) {
|
|
76
|
+
throw new UsageError(
|
|
77
|
+
'no token supplied',
|
|
78
|
+
'run `frontera login` in a terminal to be asked for one, or pipe it in: `echo $KEY | frontera login`',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
if (!token.startsWith('sk-ws-')) {
|
|
82
|
+
throw new UsageError(
|
|
83
|
+
'that does not look like a workspace key',
|
|
84
|
+
'workspace keys start with sk-ws- and are created in Workspace settings → API Keys',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const me = await new PlatformApi(ctx.apiUrl, token).whoami().catch(() => null)
|
|
89
|
+
if (!me?.workspaceId) {
|
|
90
|
+
throw new CliError('the token was rejected by this API origin', {
|
|
91
|
+
code: 'UNAUTHORIZED',
|
|
92
|
+
hint: `check the key is enabled, and that --api-url is right (currently ${ctx.apiUrl})`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
writeStoredToken(ctx.apiUrl, token)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
data: { apiUrl: ctx.apiUrl, workspaceId: me.workspaceId, orgId: me.orgId },
|
|
100
|
+
text: `Stored a key for ${ctx.apiUrl}\n workspace ${me.workspaceId}`,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `/v1/apps` returns `{ install, catalog, … }` per row, not a flattened
|
|
7
|
+
* install — so reading `id` and `status` off the top level yielded undefined
|
|
8
|
+
* and every row printed "?" with a blank status.
|
|
9
|
+
*/
|
|
10
|
+
interface InstallRow {
|
|
11
|
+
install?: { id?: string; installName?: string; status?: string; enabled?: boolean }
|
|
12
|
+
catalog?: { kind?: string; displayName?: string; authType?: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `plugin` covers an OAuth integration and a workspace-hosted MCP server
|
|
17
|
+
* alike. The platform already models them as one family — an App may use
|
|
18
|
+
* OAuth, API-key, native or MCP-backed connectivity, and a custom MCP server
|
|
19
|
+
* is one App subtype — they simply never had a shared name that was not "app".
|
|
20
|
+
*
|
|
21
|
+
* The Console still says "Apps". That divergence is known and tracked, not
|
|
22
|
+
* accidental: `frontera app` is Frontera Apps, which are a different thing.
|
|
23
|
+
*/
|
|
24
|
+
const list: Command = {
|
|
25
|
+
meta: {
|
|
26
|
+
noun: 'plugin',
|
|
27
|
+
verb: 'list',
|
|
28
|
+
args: [],
|
|
29
|
+
flags: {},
|
|
30
|
+
summary: 'List installed plugins — integrations and MCP servers',
|
|
31
|
+
examples: ['frontera plugin list', 'frontera plugin list --json'],
|
|
32
|
+
},
|
|
33
|
+
async run(ctx) {
|
|
34
|
+
const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).pluginInstalls()) as InstallRow[]
|
|
35
|
+
return {
|
|
36
|
+
data: rows,
|
|
37
|
+
text:
|
|
38
|
+
rows.length === 0
|
|
39
|
+
? 'No plugins installed in this workspace.'
|
|
40
|
+
: table(
|
|
41
|
+
['id', 'kind', 'status', 'name'],
|
|
42
|
+
rows.map((i) => [
|
|
43
|
+
i.install?.id ?? '?',
|
|
44
|
+
i.catalog?.kind ?? '',
|
|
45
|
+
i.install?.enabled === false ? 'disabled' : (i.install?.status ?? ''),
|
|
46
|
+
i.install?.installName ?? i.catalog?.displayName ?? '',
|
|
47
|
+
]),
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const catalog: Command = {
|
|
54
|
+
meta: {
|
|
55
|
+
noun: 'plugin',
|
|
56
|
+
verb: 'catalog',
|
|
57
|
+
args: [],
|
|
58
|
+
flags: {},
|
|
59
|
+
summary: 'List plugins available to install',
|
|
60
|
+
examples: ['frontera plugin catalog'],
|
|
61
|
+
},
|
|
62
|
+
async run(ctx) {
|
|
63
|
+
const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).pluginCatalog()) as Array<{
|
|
64
|
+
kind?: string
|
|
65
|
+
displayName?: string
|
|
66
|
+
authType?: string
|
|
67
|
+
}>
|
|
68
|
+
return {
|
|
69
|
+
data: rows,
|
|
70
|
+
text:
|
|
71
|
+
rows.length === 0
|
|
72
|
+
? 'Nothing in the plugin catalog.'
|
|
73
|
+
: table(
|
|
74
|
+
['kind', 'auth', 'name'],
|
|
75
|
+
rows.map((c) => [c.kind ?? '?', c.authType ?? '', c.displayName ?? '']),
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `connect` is reserved rather than half-built. It needs the headless consent
|
|
83
|
+
* flow — print the authorization URL, poll, `--no-wait` to hand off — because
|
|
84
|
+
* the Computer has no browser, and shipping it without that would produce a
|
|
85
|
+
* command that hangs in exactly the environment it exists for.
|
|
86
|
+
*/
|
|
87
|
+
const RESERVED = 'plugin writes are not available in this release; list and catalog are read-only'
|
|
88
|
+
|
|
89
|
+
const reserved: Command[] = (
|
|
90
|
+
[
|
|
91
|
+
['add', 'Install a plugin from the catalog'],
|
|
92
|
+
['configure', 'Set a plugin’s configuration or credentials'],
|
|
93
|
+
['connect', 'Complete an OAuth connection'],
|
|
94
|
+
['verify', 'Check a plugin’s connection'],
|
|
95
|
+
['remove', 'Uninstall a plugin'],
|
|
96
|
+
] as const
|
|
97
|
+
).map(([verb, summary]) => ({
|
|
98
|
+
meta: {
|
|
99
|
+
noun: 'plugin',
|
|
100
|
+
verb,
|
|
101
|
+
args: [],
|
|
102
|
+
flags: {},
|
|
103
|
+
summary,
|
|
104
|
+
examples: [`frontera plugin ${verb} <id>`],
|
|
105
|
+
reserved: RESERVED,
|
|
106
|
+
},
|
|
107
|
+
async run(): Promise<never> {
|
|
108
|
+
throw new Error(RESERVED)
|
|
109
|
+
},
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
export const pluginCommands: Command[] = [list, catalog, ...reserved]
|