@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
@@ -0,0 +1,105 @@
1
+ import { PlatformApi } from './api/platform-api'
2
+ import { CliError, UsageError } from './errors'
3
+ import type { CredentialKind } from './profiles'
4
+
5
+ export interface VerifiedKey {
6
+ kind: CredentialKind
7
+ workspaceId: string | null
8
+ orgId: string | null
9
+ }
10
+
11
+ /**
12
+ * Two credential kinds reach this CLI, and they are not interchangeable:
13
+ *
14
+ * sk-ws- a WORKSPACE key — one workspace's slice of the platform.
15
+ * sk-org- an ORGANIZATION key — the organization-level Blueprint draft,
16
+ * which no workspace credential can reach, because that draft is
17
+ * shared by every workspace in the organization.
18
+ *
19
+ * Both are stored the same way; only the verification differs, because an
20
+ * organization key belongs to no workspace and `whoami` has none to report.
21
+ */
22
+ export function classifyKey(token: string): CredentialKind {
23
+ if (token.startsWith('sk-ws-')) return 'workspace'
24
+ if (token.startsWith('sk-org-')) return 'organization'
25
+ throw new UsageError(
26
+ 'that does not look like a Frontera key',
27
+ 'workspace keys start with sk-ws- (Workspace settings → API Keys); '
28
+ + 'organization keys start with sk-org- (Settings → API keys)',
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Prove the key works BEFORE it is stored.
34
+ *
35
+ * Storing an unusable credential only moves the failure to the next command,
36
+ * where the cause is no longer obvious — and the check costs one request that
37
+ * also reports which workspace the key just bound to.
38
+ */
39
+ export async function verifyKey(apiUrl: string, token: string): Promise<VerifiedKey> {
40
+ const kind = classifyKey(token)
41
+
42
+ if (kind === 'workspace') {
43
+ const me = await new PlatformApi(apiUrl, token).whoami().catch(() => null)
44
+ if (!me?.workspaceId) {
45
+ throw new CliError('the token was rejected by this API origin', {
46
+ code: 'UNAUTHORIZED',
47
+ hint: `check the key is enabled, and that the origin is right (currently ${apiUrl})`,
48
+ })
49
+ }
50
+ return { kind, workspaceId: me.workspaceId, orgId: me.orgId ?? null }
51
+ }
52
+
53
+ /**
54
+ * Organization keys verify against an organization-scoped read, because
55
+ * `/v1/whoami` refuses them: it performs no permission check, and the org-key
56
+ * lane default-denies any route that neither checks a permission nor declares
57
+ * itself a read.
58
+ *
59
+ * The subtlety is that 200 is NOT the only success. An organization that has
60
+ * never run `blueprint adopt` has no lifecycle row, so this route answers 404
61
+ * for a perfectly valid key — requiring 2xx would reject the very keys someone
62
+ * adds in order to run `adopt`.
63
+ *
64
+ * So the question asked is "did a FRONTERA API answer this", not "did it
65
+ * answer 200". A Frontera error carries the service's envelope
66
+ * (`{ error: true, code, message }`); a typo'd host's 404 page or a 500 from
67
+ * something else does not. Without that distinction any non-401 reply counted
68
+ * as proof, so `auth add --api-url <typo>` sent the key to that host and then
69
+ * stored it as verified.
70
+ */
71
+ const lifecycle = await fetch(`${apiUrl}/v1/blueprint/lifecycle`, {
72
+ headers: { authorization: `Bearer ${token}` },
73
+ }).catch(() => null)
74
+
75
+ if (!lifecycle) {
76
+ throw new CliError(`could not reach ${apiUrl}`, {
77
+ code: 'SERVICE_UNAVAILABLE',
78
+ hint: 'check the origin and your network, then try again',
79
+ })
80
+ }
81
+ if (lifecycle.status === 401 || lifecycle.status === 403) {
82
+ throw new CliError('the organization key was rejected by this API origin', {
83
+ code: 'UNAUTHORIZED',
84
+ hint: `check the key is active and carries organization:update, and that the origin is right (currently ${apiUrl})`,
85
+ })
86
+ }
87
+ if (!lifecycle.ok && !(await looksLikeFronteraError(lifecycle))) {
88
+ throw new CliError(`${apiUrl} did not answer like a Frontera API`, {
89
+ code: 'BAD_REQUEST',
90
+ hint: `check --api-url for a typo — the key was NOT stored (this origin answered ${lifecycle.status})`,
91
+ })
92
+ }
93
+
94
+ return { kind, workspaceId: null, orgId: null }
95
+ }
96
+
97
+ /** The service's error envelope — `{ error: true, code, message }`. */
98
+ async function looksLikeFronteraError(response: Response): Promise<boolean> {
99
+ try {
100
+ const body = (await response.json()) as { error?: unknown; code?: unknown }
101
+ return body?.error === true && typeof body.code === 'string'
102
+ } catch {
103
+ return false
104
+ }
105
+ }
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { writeFileAtomic } from './fs-atomic'
5
+ import { configDir, type Env } from './paths'
6
+
7
+ /**
8
+ * Which directories THIS machine bound to a profile, and to which one.
9
+ *
10
+ * `.frontera/context.json` is discovered by walking upward, and the walk cannot
11
+ * tell who wrote the file it finds. A repository that COMMITS one selects a real
12
+ * profile — `default` is the name every migrated machine ends up with — and
13
+ * therefore a real key and a real origin, for every `frontera` command run
14
+ * inside that checkout. Nothing is printed and nothing is asked.
15
+ *
16
+ * The file holds a name rather than a token, which bounds the damage to
17
+ * deployments the user already has keys for. It does not close the case that
18
+ * matters: `frontera app pull` in a cloned repository writes a customer's source
19
+ * into that repository's tree, and the write paths write to the customer's
20
+ * deployment.
21
+ *
22
+ * So selection is answered by the context file and PROVENANCE is answered here.
23
+ * A binding this machine did not make is not an answer; it is a question, and
24
+ * `frontera auth use` is how the person answers it.
25
+ *
26
+ * Recording a path, not a secret — and only paths the user explicitly bound.
27
+ */
28
+
29
+ export interface Binding {
30
+ profile: string
31
+ boundAt: string
32
+ }
33
+
34
+ interface BindingFile {
35
+ schemaVersion: 1
36
+ roots: Record<string, Binding>
37
+ }
38
+
39
+ function bindingsPath(env: Env): string {
40
+ return join(configDir(env), 'bindings.json')
41
+ }
42
+
43
+ /** Resolved, so `/tmp` and `/private/tmp` are not two different bindings. */
44
+ function key(root: string): string {
45
+ return resolve(root)
46
+ }
47
+
48
+ function read(env: Env): BindingFile {
49
+ const path = bindingsPath(env)
50
+ if (!existsSync(path)) return { schemaVersion: 1, roots: {} }
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as BindingFile
53
+ return { schemaVersion: 1, roots: parsed.roots ?? {} }
54
+ } catch {
55
+ // Unlike the credential file, losing this costs no secret and no
56
+ // configuration — the worst case is that `auth use` must be re-run, which
57
+ // is the safe direction to fail in.
58
+ return { schemaVersion: 1, roots: {} }
59
+ }
60
+ }
61
+
62
+ export function recordBinding(root: string, profile: string, env: Env = process.env): void {
63
+ const file = read(env)
64
+ file.roots[key(root)] = { profile, boundAt: new Date().toISOString() }
65
+ writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
66
+ }
67
+
68
+ export function bindingFor(root: string, env: Env = process.env): Binding | null {
69
+ return read(env).roots[key(root)] ?? null
70
+ }
71
+
72
+ export function forgetBinding(root: string, env: Env = process.env): void {
73
+ const file = read(env)
74
+ if (!(key(root) in file.roots)) return
75
+ delete file.roots[key(root)]
76
+ writeFileAtomic(bindingsPath(env), `${JSON.stringify(file, null, 2)}\n`, 0o600)
77
+ }
78
+
79
+ /**
80
+ * Did this machine bind this root to this profile?
81
+ *
82
+ * The profile must match too. A committed context that edits the NAME inside a
83
+ * directory the user did bind is the same attack with an extra step.
84
+ */
85
+ export function isTrustedBinding(root: string, profile: string, env: Env = process.env): boolean {
86
+ return bindingFor(root, env)?.profile === profile
87
+ }
@@ -23,6 +23,7 @@ export const actionDeploy: Command = {
23
23
  meta: {
24
24
  noun: 'action',
25
25
  verb: 'deploy',
26
+ authLane: 'session',
26
27
  args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
27
28
  flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
28
29
  summary: 'Build and validate the write path for a published Action',
@@ -20,6 +20,7 @@ export const actionGrant: Command = {
20
20
  meta: {
21
21
  noun: 'action',
22
22
  verb: 'grant',
23
+ authLane: 'session',
23
24
  args: [
24
25
  { name: 'capability', required: true, description: 'The Action’s invoke capability, as resource:action' },
25
26
  ],
@@ -2,6 +2,7 @@ import { actionDeploy } from './deploy'
2
2
  import { actionGrant } from './grant'
3
3
  import { actionList } from './list'
4
4
  import { actionPrepare } from './prepare'
5
+ import { actionRequests } from './requests'
5
6
  import { actionReview } from './review'
6
7
  import type { Command } from '../types'
7
8
 
@@ -19,9 +20,16 @@ import type { Command } from '../types'
19
20
  * non-member principal — `wskey:…` and `orgkey:…` — and the invoke check joins
20
21
  * organization membership, so a `submit` verb here would refuse every call it
21
22
  * ever made. It belongs to a credential that represents a person.
23
+ *
24
+ * READING those invocations is not the same boundary, and `requests` is here:
25
+ * the list gates on `actionRequest: ['read']` rather than on membership. The
26
+ * distinction matters because an armed path that fails every request looks
27
+ * exactly like one nothing has tried, and until now nothing in the CLI could
28
+ * tell the two apart.
22
29
  */
23
30
  export const actionCommands: readonly Command[] = [
24
31
  actionList,
32
+ actionRequests,
25
33
  actionPrepare,
26
34
  actionDeploy,
27
35
  actionReview,
@@ -17,6 +17,7 @@ export const actionPrepare: Command = {
17
17
  meta: {
18
18
  noun: 'action',
19
19
  verb: 'prepare',
20
+ authLane: 'session',
20
21
  args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
21
22
  flags: { reason: 'string' },
22
23
  summary: 'Record that a drafted Action ships disabled, so it can be published',
@@ -0,0 +1,111 @@
1
+ import { GovernedActionApi } from '../../api/governed-action-api'
2
+ import { UsageError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import { flagString, type Command } from '../types'
5
+
6
+ /**
7
+ * What has actually run through the write path.
8
+ *
9
+ * `action list` reports that an Action is published and deployed; `action
10
+ * review` reports that its Binding is active. Both describe the ARMING, and an
11
+ * armed path that every caller's request fails against looks identical to one
12
+ * nothing has tried yet. This is the only verb that can tell them apart.
13
+ *
14
+ * Read-only, and that is a boundary rather than an omission: invoking joins
15
+ * organization membership, which no CLI credential satisfies. Listing gates on
16
+ * `actionRequest: ['read']`, which a key can hold.
17
+ */
18
+ export const actionRequests: Command = {
19
+ meta: {
20
+ noun: 'action',
21
+ verb: 'requests',
22
+ args: [
23
+ {
24
+ name: 'requestId',
25
+ required: false,
26
+ description: 'one request to open in full; omit to list recent ones',
27
+ },
28
+ ],
29
+ flags: { lifecycle: 'string', limit: 'string', cursor: 'string' },
30
+ summary: 'List Action requests and how they resolved — or open one',
31
+ examples: [
32
+ 'frontera action requests',
33
+ 'frontera action requests --lifecycle failed',
34
+ 'frontera action requests <requestId> --json',
35
+ ],
36
+ },
37
+
38
+ async run(ctx) {
39
+ const client = new GovernedActionApi(ctx.apiUrl, ctx.token)
40
+ const requestId = ctx.positional[0]
41
+
42
+ if (requestId) {
43
+ const request = await client.getRequest(requestId)
44
+ return {
45
+ data: request,
46
+ // Flattened rather than curated: the surface records provenance,
47
+ // attempts and failure detail under names that differ by adapter, and
48
+ // naming a subset here would hide exactly the field that explains the
49
+ // failure being investigated.
50
+ text: table(
51
+ ['field', 'value'],
52
+ Object.entries(request).map(([k, v]) => [
53
+ k,
54
+ v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
55
+ ]),
56
+ [undefined, 80],
57
+ ),
58
+ }
59
+ }
60
+
61
+ const limitRaw = flagString(ctx, 'limit')
62
+ const limit = limitRaw === undefined ? undefined : Number(limitRaw)
63
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) {
64
+ throw new UsageError(
65
+ `--limit must be a whole number between 1 and 200, not "${limitRaw}"`,
66
+ 'try --limit 20',
67
+ )
68
+ }
69
+
70
+ const page = await client.listRequests({
71
+ ...(flagString(ctx, 'lifecycle') ? { lifecycle: flagString(ctx, 'lifecycle')! } : {}),
72
+ ...(limit === undefined ? {} : { limit }),
73
+ ...(flagString(ctx, 'cursor') ? { cursor: flagString(ctx, 'cursor')! } : {}),
74
+ })
75
+
76
+ if (page.requests.length === 0) {
77
+ const filtered = flagString(ctx, 'lifecycle')
78
+ return {
79
+ data: page,
80
+ text: filtered
81
+ ? `No requests with lifecycle "${filtered}".`
82
+ : 'No Action requests recorded.\n'
83
+ + ' Nothing has invoked a deployed Action yet — invoking needs a credential\n'
84
+ + ' that represents a person, so it happens from an agent or the Console.',
85
+ }
86
+ }
87
+
88
+ return {
89
+ data: page,
90
+ text:
91
+ table(
92
+ // The request id is here because the detail verb takes it — without
93
+ // it the listing could not drive `action requests <requestId>`, which
94
+ // is the only place the failure detail lives. `effectCertainty` is
95
+ // the column to read on a failure: it separates "the effect landed
96
+ // and the request still failed" from "nothing happened", which is
97
+ // the difference between retrying and not.
98
+ ['created', 'lifecycle', 'effect', 'action', 'request id'],
99
+ page.requests.map((r) => [
100
+ r.createdAt ?? '',
101
+ r.lifecycle ?? '',
102
+ r.effectCertainty ?? (r.hasEverDispatched ? 'dispatched' : ''),
103
+ r.actionDefinitionId ?? '?',
104
+ r.id ?? '',
105
+ ]),
106
+ [24, 12, 20, 38, undefined],
107
+ )
108
+ + (page.nextCursor ? `\n\nMore requests. Continue with --cursor ${page.nextCursor}` : ''),
109
+ }
110
+ },
111
+ }
@@ -26,6 +26,7 @@ export const actionReview: Command = {
26
26
  meta: {
27
27
  noun: 'action',
28
28
  verb: 'review',
29
+ authLane: 'session',
29
30
  args: [{ name: 'bindingRevision', required: true, description: 'The Binding revision to review' }],
30
31
  flags: { report: 'string', reason: 'string', 'no-activate': 'boolean' },
31
32
  summary: 'Review a validated write path and activate it',
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
3
3
  import { PlatformApi } from '../../api/platform-api'
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { table } from '../../table'
6
- import { flagString, type Command, type CommandContext } from '../types'
6
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
7
7
  import { renderComposition, type Lookups } from './compose'
8
8
  import { resolveAgentRef, type AgentRow } from './resolve'
9
9
 
@@ -86,6 +86,26 @@ async function readDocument(ctx: CommandContext): Promise<Record<string, unknown
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * JSON with object keys in a fixed order, for comparison.
91
+ *
92
+ * `JSON.stringify` preserves insertion order, and the draft snapshot and the
93
+ * live snapshot are built by different code paths — so the same
94
+ * `caseDefinition` serialises as `{metrics, objectSets, objectTypes}` on one
95
+ * side and `{objectTypes, metrics, objectSets}` on the other. Comparing the
96
+ * strings reported both as changed on a draft that had touched neither, which
97
+ * is the noise `agent diff` exists to avoid.
98
+ *
99
+ * Arrays keep their order: element order is meaningful in `stages` and
100
+ * `prompts`, where a reordering IS the change.
101
+ */
102
+ export function stableStringify(value: unknown): string {
103
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
104
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
105
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
106
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
107
+ }
108
+
89
109
  /** `agent_configs.agentId` — a slug, lowercase, the value every verb takes. */
90
110
  const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
91
111
 
@@ -362,10 +382,39 @@ const diff: Command = {
362
382
  'id', 'orgId', 'workspaceId', 'createdAt', 'updatedAt', 'createdBy',
363
383
  'currentVersion', 'currentVersionId', 'draft', 'lifecycleStatus',
364
384
  ])
365
- const changed = Object.keys(draftConfig)
366
- .filter((k) => !MANAGED.has(k))
367
- .filter((k) => JSON.stringify(draftConfig[k]) !== JSON.stringify(liveConfig[k]))
368
- .sort()
385
+
386
+ /**
387
+ * A snapshot is `config` PLUS fifteen sibling sections, and this compared
388
+ * only `config`.
389
+ *
390
+ * So a draft that staged a capability, a skill, a prompt or a knowledge
391
+ * attachment reported "matches the live version" — the answer a caller
392
+ * gets when nothing is pending, given immediately after they staged
393
+ * something. `capability grant` made this reachable in one step, but
394
+ * `agent apply` has always been able to stage these sections too.
395
+ *
396
+ * The live side comes from the live SNAPSHOT rather than the config row:
397
+ * `GET /config/agents/:id` returns flat scalars with every relation null,
398
+ * so every section would read as "changed" against it.
399
+ */
400
+ const liveSnapshot = await t.api.agentComposition(t.id).catch(() => null)
401
+ const draftSections = Object.fromEntries(
402
+ Object.entries(draft.snapshot).filter(([k]) => k !== 'config' && !MANAGED.has(k)),
403
+ )
404
+
405
+ const changed = [
406
+ ...Object.keys(draftConfig)
407
+ .filter((k) => !MANAGED.has(k))
408
+ .filter((k) => stableStringify(draftConfig[k]) !== stableStringify(liveConfig[k])),
409
+ // Only when the live snapshot could be read. Reporting every section as
410
+ // changed because one request failed would be worse than the silence
411
+ // this replaces.
412
+ ...(liveSnapshot
413
+ ? Object.keys(draftSections).filter(
414
+ (k) => stableStringify(draftSections[k]) !== stableStringify(liveSnapshot[k]),
415
+ )
416
+ : []),
417
+ ].sort()
369
418
 
370
419
  return {
371
420
  data: { hasDraft: true, revision: draft.revision, changed },
@@ -410,18 +459,148 @@ const versions: Command = {
410
459
  text:
411
460
  rows.length === 0
412
461
  ? 'No versions published yet.'
462
+ // The id is here because `agent revert` takes it, and nothing else
463
+ // prints it — the listing could name a version to go back to and
464
+ // not the value needed to go there, so the only route was --json.
465
+ // `publishedByName` before `publishedBy`: the raw column holds a
466
+ // principal string like `wskey:<uuid>:<uuid>`, which identifies the
467
+ // credential rather than the person.
413
468
  : table(
414
- ['version', 'published', 'by'],
469
+ ['version', 'published', 'by', 'notes', 'id'],
415
470
  rows.map((v) => [
416
471
  String(v.versionNumber ?? v.version ?? '?'),
417
472
  String(v.publishedAt ?? ''),
418
- String(v.publishedBy ?? ''),
473
+ String(v.publishedByName ?? v.publishedBy ?? ''),
474
+ String(v.notes ?? ''),
475
+ String(v.id ?? ''),
419
476
  ]),
477
+ [7, 26, 22, 24, undefined],
420
478
  ),
421
479
  }
422
480
  },
423
481
  }
424
482
 
483
+ /**
484
+ * Bind a workspace skill to an agent.
485
+ *
486
+ * The missing step in the skill workflow. `skill push` uploaded a bundle and
487
+ * nothing loaded it — the only route from a folder to a running agent went
488
+ * through `pack apply`, which carries a whole pack, or through the Console.
489
+ *
490
+ * Unlike `capability grant`, this writes LIVE. There is no draft to publish
491
+ * and `agent diff` stays empty, because the route binds the skill directly
492
+ * rather than staging a snapshot patch. Measured, not assumed: attaching one
493
+ * changes `agent get` immediately and leaves no draft behind. The two verbs
494
+ * sit next to each other and behave oppositely, so both say which they are.
495
+ */
496
+ const skillAttach: Command = {
497
+ meta: {
498
+ noun: 'agent',
499
+ verb: 'skill-attach',
500
+ args: [
501
+ { name: 'agent', required: true, description: 'agent id or slug' },
502
+ { name: 'skill', required: true, description: 'workspace skill id, from `frontera skill list`' },
503
+ ],
504
+ flags: {},
505
+ summary: 'Give an agent a workspace skill to load at runtime — takes effect immediately',
506
+ examples: ['frontera agent skill-attach support-bot <skillId>'],
507
+ },
508
+ async run(ctx) {
509
+ const [ref, skillId] = ctx.positional
510
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
511
+ if (!skillId) throw new UsageError('missing <skill>', 'frontera skill list — then pass an id')
512
+
513
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
514
+ const result = await api.attachAgentSkill(await resolveAgentRef(api, ref), skillId)
515
+
516
+ return {
517
+ data: result,
518
+ text: `Attached ${skillId} to ${ref}.\n`
519
+ + ' Live immediately — this is not staged, and there is nothing to publish.',
520
+ }
521
+ },
522
+ }
523
+
524
+ const skillDetach: Command = {
525
+ meta: {
526
+ noun: 'agent',
527
+ verb: 'skill-detach',
528
+ args: [
529
+ { name: 'agent', required: true, description: 'agent id or slug' },
530
+ { name: 'skill', required: true, description: 'workspace skill id' },
531
+ ],
532
+ flags: {},
533
+ summary: 'Take a workspace skill away from an agent — takes effect immediately',
534
+ examples: ['frontera agent skill-detach support-bot <skillId>'],
535
+ },
536
+ async run(ctx) {
537
+ const [ref, skillId] = ctx.positional
538
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
539
+ if (!skillId) throw new UsageError('missing <skill>', `frontera agent get ${ref} — lists what it loads`)
540
+
541
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
542
+ const result = await api.detachAgentSkill(await resolveAgentRef(api, ref), skillId)
543
+
544
+ return { data: result, text: `Detached ${skillId} from ${ref}. The skill itself is untouched.` }
545
+ },
546
+ }
547
+
548
+ /**
549
+ * Go back to a version that worked.
550
+ *
551
+ * `agent versions` listed what could be reverted to and nothing could revert,
552
+ * so recovering from a bad publish meant reconstructing the document by hand.
553
+ *
554
+ * It RE-POINTS rather than appends: the route applies vN's snapshot to the
555
+ * live tables and moves `current_version_id` back to vN in one transaction, so
556
+ * the version list does not grow and the pointer moves backwards. Measured,
557
+ * not assumed — this was first documented as "publishes a new version", and
558
+ * reverting a two-version agent left it with two versions, not three. The act
559
+ * itself is recorded in the agent's event log, which is the append-only trail.
560
+ *
561
+ * There is no draft staging: this is live the moment it returns, which is the
562
+ * opposite of every other write on this noun and the reason it says so in its
563
+ * own output.
564
+ */
565
+ const revert: Command = {
566
+ meta: {
567
+ noun: 'agent',
568
+ verb: 'revert',
569
+ args: [
570
+ { name: 'agent', required: true, description: 'agent id or slug' },
571
+ { name: 'version', required: true, description: 'version id to go back to, from `frontera agent versions`' },
572
+ ],
573
+ flags: { 'accept-orphaned-knowledge': 'boolean' },
574
+ summary: 'Restore an earlier version and point the agent back at it',
575
+ examples: ['frontera agent revert support-bot <versionId>'],
576
+ },
577
+ async run(ctx) {
578
+ const [ref, versionId] = ctx.positional
579
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
580
+ if (!versionId) {
581
+ throw new UsageError('missing <version>', `frontera agent versions ${ref} — then pass a version id`)
582
+ }
583
+
584
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
585
+ const result = await api.revertAgent(await resolveAgentRef(api, ref), versionId, {
586
+ // The old snapshot can name a knowledge base that has since been
587
+ // deleted. Refusing by default is right — silently reverting to an agent
588
+ // that reads a corpus which no longer exists is a working agent with a
589
+ // missing dependency, which is worse than a refusal.
590
+ ...(flagBool(ctx, 'accept-orphaned-knowledge') ? { acceptOrphanedKnowledge: true } : {}),
591
+ })
592
+
593
+ return {
594
+ data: result,
595
+ text:
596
+ `${ref} reverted to ${versionId}.\n`
597
+ + ' Its snapshot is live now and the current-version pointer moved back to it —\n'
598
+ + ' no new version was created, so `agent versions` is unchanged.\n'
599
+ + ' This did not stage a draft; there is nothing to publish.',
600
+ }
601
+ },
602
+ }
603
+
425
604
  export const agentCommands: Command[] = [
426
605
  list,
427
606
  get,
@@ -431,4 +610,7 @@ export const agentCommands: Command[] = [
431
610
  diff,
432
611
  discard,
433
612
  versions,
613
+ revert,
614
+ skillAttach,
615
+ skillDetach,
434
616
  ]
@@ -226,7 +226,7 @@ export const appInit: Command = {
226
226
  //
227
227
  // Best-effort by construction: `offline: true` above means this command
228
228
  // must work before anyone has logged in.
229
- const devEnv = framework === 'react' ? writeDevEnv(target) : { written: false as const, reason: 'not-needed' }
229
+ const devEnv = framework === 'react' ? await writeDevEnv(target) : { written: false as const, reason: 'not-needed' }
230
230
  if (framework === 'react') {
231
231
  ctx.output.note(
232
232
  devEnv.written
@@ -108,7 +108,7 @@ export const appPull: Command = {
108
108
  // carries the publisher's — every working copy has to be given one here.
109
109
  // Never overwrites, so an author who pointed this project somewhere else
110
110
  // keeps their setting.
111
- const devEnv = writeDevEnv(dir)
111
+ const devEnv = await writeDevEnv(dir)
112
112
  if (devEnv.written) ctx.output.note(` ${DEV_ENV_FILE} written — the dev host will read real data`)
113
113
 
114
114
  const pulled = `Pulled ${count} files from ${app.slug}${parentVersion ? `@${parentVersion}` : ' (draft)'}`