@frontera-sdk/cli 1.44.1 → 1.45.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 (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +419 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +31 -0
  58. package/src/vendor/sdk-sources.json +1 -1
@@ -111,6 +111,168 @@ const sources: Command = {
111
111
  },
112
112
  }
113
113
 
114
+ const get: Command = {
115
+ meta: {
116
+ noun: 'knowledge',
117
+ verb: 'get',
118
+ args: [{ name: 'base', required: true, description: 'knowledge base name or id, from `frontera knowledge list`' }],
119
+ flags: {},
120
+ summary: 'Show one knowledge base — its settings and ingestion counts',
121
+ examples: ['frontera knowledge get product-docs', 'frontera knowledge get product-docs --json'],
122
+ },
123
+ async run(ctx) {
124
+ const ref = ctx.positional[0]
125
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
126
+
127
+ const client = new PlatformApi(ctx.apiUrl, ctx.token)
128
+ const base = await client.knowledgeBase(await resolveKnowledgeRef(client, ref))
129
+
130
+ return {
131
+ data: base,
132
+ // Whatever the route returns, flattened. Naming the fields here would
133
+ // print "?" for every one the service renames, and the embedding model
134
+ // and chunker are exactly the fields a caller comes to this verb to read.
135
+ text: table(
136
+ ['field', 'value'],
137
+ Object.entries(base).map(([k, v]) => [
138
+ k,
139
+ v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
140
+ ]),
141
+ [undefined, 70],
142
+ ),
143
+ }
144
+ },
145
+ }
146
+
147
+ /**
148
+ * Retrieval, which is the only thing that proves an upload landed.
149
+ *
150
+ * `knowledge upload` reports what it sent; `knowledge sources` reports the
151
+ * ingestion status per file. Neither answers the question a caller actually
152
+ * has — can the agent find this — because a source can reach `ready` with
153
+ * chunks that embed nothing useful. Searching is the check.
154
+ */
155
+ const search: Command = {
156
+ meta: {
157
+ noun: 'knowledge',
158
+ verb: 'search',
159
+ args: [
160
+ { name: 'base', required: true, description: 'knowledge base name or id, from `frontera knowledge list`' },
161
+ { name: 'query', required: true, description: 'text to search for, as an agent would ask it' },
162
+ ],
163
+ flags: { 'top-k': 'string', threshold: 'string' },
164
+ summary: 'Search a knowledge base and show the chunks that come back',
165
+ examples: [
166
+ 'frontera knowledge search product-docs "how do refunds work"',
167
+ 'frontera knowledge search product-docs "refund policy" --top-k 3 --json',
168
+ ],
169
+ },
170
+ async run(ctx) {
171
+ const [ref, ...queryParts] = ctx.positional
172
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
173
+ // Joined rather than requiring quotes: an unquoted multi-word query is the
174
+ // normal typo, and refusing it teaches nothing a join cannot fix.
175
+ const query = queryParts.join(' ').trim()
176
+ if (!query) {
177
+ throw new UsageError('missing <query>', `frontera knowledge search ${ref} "what a user would ask"`)
178
+ }
179
+
180
+ const topKRaw = flagString(ctx, 'top-k')
181
+ const topK = topKRaw === undefined ? undefined : Number(topKRaw)
182
+ if (topK !== undefined && (!Number.isInteger(topK) || topK < 1)) {
183
+ throw new UsageError(`--top-k must be a positive whole number, not "${topKRaw}"`, 'try --top-k 5')
184
+ }
185
+ const thresholdRaw = flagString(ctx, 'threshold')
186
+ const threshold = thresholdRaw === undefined ? undefined : Number(thresholdRaw)
187
+ if (threshold !== undefined && Number.isNaN(threshold)) {
188
+ throw new UsageError(`--threshold must be a number, not "${thresholdRaw}"`, 'try --threshold 0.7')
189
+ }
190
+
191
+ const client = new PlatformApi(ctx.apiUrl, ctx.token)
192
+ const baseId = await resolveKnowledgeRef(client, ref)
193
+ const result = await client.knowledgeSearch(baseId, {
194
+ query,
195
+ ...(topK === undefined ? {} : { topK }),
196
+ ...(threshold === undefined ? {} : { threshold }),
197
+ })
198
+
199
+ const chunks = result.results
200
+ if (chunks.length === 0) {
201
+ // The overwhelmingly common cause, and the one a caller cannot see from
202
+ // here: ingestion is asynchronous, so a base uploaded a minute ago is
203
+ // empty rather than broken.
204
+ return {
205
+ data: result,
206
+ text:
207
+ 'No chunks matched.\n'
208
+ + ` If the upload was recent, check \`frontera knowledge sources ${ref}\` — a source\n`
209
+ + ' still `processing` is not searchable yet.\n'
210
+ // Stated the right way round: --threshold is a distance cutoff, so a
211
+ // LOW value is the restrictive one and 0 can never match.
212
+ + (threshold !== undefined
213
+ ? ` --threshold ${threshold} is a maximum cosine distance — raise it to widen the search.\n`
214
+ : ' The default cutoff is 0.9; pass a higher --threshold to widen the search.\n')
215
+ + ' Otherwise the corpus may simply not cover this.',
216
+ }
217
+ }
218
+
219
+ return {
220
+ data: result,
221
+ text: table(
222
+ ['score', 'source', 'chunk'],
223
+ chunks.map((c) => [
224
+ typeof c.score === 'number' ? c.score.toFixed(3) : String(c.score ?? ''),
225
+ String(c.fileName ?? c.sourceId ?? ''),
226
+ String(c.content ?? c.text ?? '').replace(/\s+/g, ' ').trim(),
227
+ ]),
228
+ [8, 30, 90],
229
+ ),
230
+ }
231
+ },
232
+ }
233
+
234
+ /**
235
+ * Re-run ingestion for a source that failed.
236
+ *
237
+ * `upload` exits 0 on a partial batch by design — one unreadable file does not
238
+ * end the run — so a failed source is normal and recovery has to be normal
239
+ * too. Until this verb the only route was the Console, which meant an
240
+ * unattended `knowledge upload` could not repair itself.
241
+ */
242
+ const retry: Command = {
243
+ meta: {
244
+ noun: 'knowledge',
245
+ verb: 'retry',
246
+ args: [
247
+ { name: 'base', required: true, description: 'knowledge base name or id' },
248
+ { name: 'source', required: true, description: 'source id, from `frontera knowledge sources`' },
249
+ ],
250
+ flags: {},
251
+ summary: 'Re-run ingestion for one source that failed',
252
+ examples: ['frontera knowledge retry product-docs <sourceId>'],
253
+ },
254
+ async run(ctx) {
255
+ const [ref, sourceId] = ctx.positional
256
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
257
+ if (!sourceId) {
258
+ throw new UsageError('missing <source>', `frontera knowledge sources ${ref} — the id column`)
259
+ }
260
+
261
+ const client = new PlatformApi(ctx.apiUrl, ctx.token)
262
+ const baseId = await resolveKnowledgeRef(client, ref)
263
+ const result = await client.retryKnowledgeSource(baseId, sourceId)
264
+
265
+ return {
266
+ data: result,
267
+ // Same asynchrony as upload: this queues the job, it does not finish it.
268
+ text:
269
+ `Re-queued ${sourceId}.\n`
270
+ + ` Ingestion is asynchronous — \`frontera knowledge sources ${ref}\` shows the outcome.\n`
271
+ + ' Retrying is additive and safe to repeat.',
272
+ }
273
+ },
274
+ }
275
+
114
276
  const create: Command = {
115
277
  meta: {
116
278
  noun: 'knowledge',
@@ -381,7 +543,10 @@ const reserved: Command[] = (
381
543
 
382
544
  export const knowledgeCommands: Command[] = [
383
545
  list,
546
+ get,
384
547
  sources,
548
+ search,
549
+ retry,
385
550
  create,
386
551
  upload,
387
552
  attach,
@@ -1,41 +1,31 @@
1
- import { PlatformApi } from '../api/platform-api'
2
- import { writeStoredToken } from '../config'
1
+ import { verifyKey } from '../auth-verify'
2
+ import { ensureMigrated } from '../config'
3
+ import { credentialStore } from '../credential-store'
3
4
  import { CliError, UsageError } from '../errors'
4
- import { readSecretValue } from '../secrets'
5
5
  import { canPrompt, promptSecret } from '../prompt'
6
- import type { Command } from './types'
6
+ import { assertApiUrl, fingerprint, getProfile, putProfile } from '../profiles'
7
+ import { readSecretValue } from '../secrets'
8
+ import { flagBool, type Command } from './types'
7
9
 
8
10
  /**
9
- * Store a workspace key for an API origin.
11
+ * The old entry point, kept working.
10
12
  *
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.
13
+ * `login` predates profiles and stored one token per API origin, which is the
14
+ * limitation profiles exist to remove. It survives for two minor releases as an
15
+ * alias that creates or replaces the `default` profile, because a machine that
16
+ * upgrades mid-task should not discover its setup command is gone.
15
17
  *
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.
18
+ * Replacement is still explicit: an existing `default` needs `--force`, exactly
19
+ * as `auth add` does. Silently rebinding `default` was the old behaviour and it
20
+ * is the behaviour that made two customers impossible to hold at once.
31
21
  */
22
+ const DEPRECATION = 'frontera login is deprecated — use `frontera auth add <profile> --api-url <origin>`'
23
+
32
24
  async function readToken(flags: Record<string, string | boolean>): Promise<string> {
33
25
  const noInput = flags['no-input'] === true
34
26
  const explicitStdin = flags['token-stdin'] === true
35
27
 
36
28
  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
29
  if (explicitStdin || !process.stdin.isTTY) {
40
30
  const piped = (await readSecretValue('-')).trim()
41
31
  if (piped) return piped
@@ -43,7 +33,11 @@ async function readToken(flags: Record<string, string | boolean>): Promise<strin
43
33
  return (process.env.FRONTERA_TOKEN ?? '').trim()
44
34
  }
45
35
 
46
- return promptSecret('Workspace key (sk-ws-…): ')
36
+ // Names BOTH kinds, because the prompt is the only place most callers ever
37
+ // learn which are accepted. It read `Workspace key (sk-ws-…)` while the
38
+ // verification below has always taken an organization key too — so a holder
39
+ // of one had no way to discover that except by pasting it and being right.
40
+ return promptSecret('Frontera key (sk-ws-… or sk-org-…): ')
47
41
  }
48
42
 
49
43
  export const loginCommand: Command = {
@@ -51,91 +45,77 @@ export const loginCommand: Command = {
51
45
  noun: 'login',
52
46
  verb: '',
53
47
  args: [],
54
- flags: { 'token-stdin': 'boolean', 'no-input': 'boolean' },
55
- summary: 'Verify a workspace key and store it for this API origin',
48
+ flags: { 'token-stdin': 'boolean', 'no-input': 'boolean', force: 'boolean' },
49
+ // Keeps the upstream correction that this accepts an organization key too,
50
+ // inside the deprecation framing profiles introduce.
51
+ summary: 'Deprecated — store a workspace or organization key as `default`',
56
52
  examples: [
57
- 'frontera login --api-url http://localhost:4000',
58
53
  'echo $KEY | frontera login --api-url http://localhost:4000',
59
- 'frontera login --api-url https://api.example.com --token-stdin < key.txt',
54
+ 'frontera login --api-url http://localhost:4000 --force',
60
55
  ],
61
- // It SUPPLIES a credential, so it must not require one to start.
62
56
  offline: true,
63
57
  },
64
58
 
65
59
  async run(ctx) {
60
+ ctx.output.note(DEPRECATION)
61
+
66
62
  if (!ctx.apiUrl) {
67
63
  throw new UsageError(
68
64
  'no API origin configured',
69
65
  'set FRONTERA_API_URL, or pass --api-url <origin>',
70
66
  )
71
67
  }
68
+ assertApiUrl(ctx.apiUrl)
72
69
 
73
- const token = await readToken(ctx.flags)
70
+ await ensureMigrated(process.env)
74
71
 
72
+ const existing = getProfile('default')
73
+ if (existing && !flagBool(ctx, 'force')) {
74
+ throw new CliError('the `default` profile already exists', {
75
+ code: 'USAGE',
76
+ hint: 're-run with --force to replace it, or name a new one with `frontera auth add <profile>`',
77
+ })
78
+ }
79
+
80
+ const token = await readToken(ctx.flags)
75
81
  if (!token) {
76
82
  throw new UsageError(
77
83
  'no token supplied',
78
84
  'run `frontera login` in a terminal to be asked for one, or pipe it in: `echo $KEY | frontera login`',
79
85
  )
80
86
  }
81
- // Two credential kinds reach this CLI, and they are not interchangeable:
82
- //
83
- // sk-ws- a WORKSPACE key — one workspace's slice of the platform.
84
- // sk-org- an ORGANIZATION key — the organization-level Blueprint draft,
85
- // which no workspace credential can reach, because that draft is
86
- // shared by every workspace in the organization.
87
- //
88
- // Both are stored the same way; only the verification below differs, because an
89
- // organization key belongs to no workspace and `whoami` has none to report.
90
- const isWorkspaceKey = token.startsWith('sk-ws-')
91
- const isOrgKey = token.startsWith('sk-org-')
92
- if (!isWorkspaceKey && !isOrgKey) {
93
- throw new UsageError(
94
- 'that does not look like a Frontera key',
95
- 'workspace keys start with sk-ws- (Workspace settings → API Keys); ' +
96
- 'organization keys start with sk-org- (Settings → API keys)',
97
- )
98
- }
99
87
 
100
- // Shaped as `whoami` actually answers — both fields are nullable there, and
101
- // declaring them merely optional made the assignment below unassignable.
102
- let me: { workspaceId: string | null; orgId: string | null } | null = null
103
- if (isWorkspaceKey) {
104
- me = await new PlatformApi(ctx.apiUrl, token).whoami().catch(() => null)
105
- if (!me?.workspaceId) {
106
- throw new CliError('the token was rejected by this API origin', {
107
- code: 'UNAUTHORIZED',
108
- hint: `check the key is enabled, and that --api-url is right (currently ${ctx.apiUrl})`,
109
- })
110
- }
111
- } else {
112
- // Verified against an organization-scoped read instead. A revoked or expired
113
- // key answers 401 here, which is exactly what login should catch.
114
- const lifecycle = await fetch(`${ctx.apiUrl}/v1/blueprint/lifecycle`, {
115
- headers: { authorization: `Bearer ${token}` },
116
- }).catch(() => null)
117
- if (!lifecycle || lifecycle.status === 401 || lifecycle.status === 403) {
118
- throw new CliError('the organization key was rejected by this API origin', {
119
- code: 'UNAUTHORIZED',
120
- hint: `check the key is active and carries organization:update, and that --api-url is right (currently ${ctx.apiUrl})`,
121
- })
122
- }
123
- }
88
+ const verified = await verifyKey(ctx.apiUrl, token)
89
+
90
+ const store = credentialStore()
91
+ await store.set('default', token)
124
92
 
125
- writeStoredToken(ctx.apiUrl, token)
93
+ const now = new Date().toISOString()
94
+ putProfile('default', {
95
+ apiUrl: ctx.apiUrl,
96
+ credentialKind: verified.kind,
97
+ workspaceId: verified.workspaceId,
98
+ orgId: verified.orgId,
99
+ fingerprint: fingerprint(token),
100
+ createdAt: existing?.createdAt ?? now,
101
+ lastVerifiedAt: now,
102
+ })
126
103
 
127
104
  return {
128
105
  data: {
106
+ profile: 'default',
129
107
  apiUrl: ctx.apiUrl,
130
- kind: isOrgKey ? 'organization' : 'workspace',
131
- workspaceId: me?.workspaceId ?? null,
132
- orgId: me?.orgId ?? null,
108
+ kind: verified.kind,
109
+ workspaceId: verified.workspaceId,
110
+ orgId: verified.orgId,
111
+ deprecated: true,
133
112
  },
134
- // An organization key belongs to no workspace, so saying "workspace <id>" for
135
- // one would be a comforting lie about what the credential reaches.
136
- text: isOrgKey
137
- ? `Stored an organization key for ${ctx.apiUrl}\n it authors this organization's Blueprint`
138
- : `Stored a key for ${ctx.apiUrl}\n workspace ${me?.workspaceId}`,
113
+ text: [
114
+ verified.kind === 'organization'
115
+ ? `Stored an organization key as profile default for ${ctx.apiUrl}\n it authors this organization's Blueprint`
116
+ : `Stored a key as profile default for ${ctx.apiUrl}\n workspace ${verified.workspaceId}`,
117
+ ' select it for this directory with `frontera auth use default`',
118
+ ].join('\n'),
139
119
  }
140
120
  },
141
121
  }