@frontera-sdk/cli 1.53.22 → 1.53.24

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 (52) hide show
  1. package/package.json +4 -4
  2. package/src/api/knowledge-base-api.ts +230 -0
  3. package/src/commands/knowledge/index-commands.ts +6 -6
  4. package/src/commands/knowledge-base/index-commands.ts +328 -0
  5. package/src/commands/local/check.ts +12 -0
  6. package/src/commands/local/dev.ts +64 -2
  7. package/src/commands/local/index-commands.ts +34 -10
  8. package/src/commands/local/init.ts +11 -2
  9. package/src/commands/local/schema.ts +1 -1
  10. package/src/commands/local/sync-request.ts +135 -29
  11. package/src/commands/local/sync.ts +40 -25
  12. package/src/commands/registry.ts +4 -1
  13. package/src/commands/types.ts +6 -0
  14. package/src/flag-help.ts +10 -4
  15. package/src/forge/touches.ts +29 -0
  16. package/src/local/local-applied.ts +28 -0
  17. package/src/local/local-build-summary.ts +1 -0
  18. package/src/local/local-component-health.ts +7 -2
  19. package/src/local/local-dev-references.ts +6 -2
  20. package/src/local/local-dev-sync.ts +43 -4
  21. package/src/local/local-docker.ts +16 -1
  22. package/src/local/local-env.ts +19 -0
  23. package/src/local/local-file-checks.ts +56 -8
  24. package/src/local/local-knowledge-base-apply.ts +714 -0
  25. package/src/local/local-knowledge-base-files.ts +195 -0
  26. package/src/local/local-knowledge-base-pull.ts +458 -0
  27. package/src/local/local-knowledge-base-sources-off.ts +67 -0
  28. package/src/local/local-person-steps.ts +2 -1
  29. package/src/local/local-personas.ts +64 -7
  30. package/src/local/local-portability.ts +27 -0
  31. package/src/local/local-ports.ts +4 -2
  32. package/src/local/local-project-secrets.ts +30 -5
  33. package/src/local/local-scaffold.ts +2 -0
  34. package/src/local/local-schema-check.ts +3 -1
  35. package/src/local/local-schemas.ts +11 -1
  36. package/src/local/local-search-index.ts +92 -0
  37. package/src/local/local-secrets.ts +7 -2
  38. package/src/local/local-seed.ts +17 -0
  39. package/src/local/local-state.ts +7 -5
  40. package/src/local/local-studio-server.ts +3 -3
  41. package/src/local/local-supervisor.ts +45 -8
  42. package/src/local/local-sync-link.ts +16 -0
  43. package/src/local/local-sync-names.ts +10 -1
  44. package/src/local/local-sync-plan.ts +44 -5
  45. package/src/local/local-sync-push.ts +165 -34
  46. package/src/local/local-sync-refs.ts +3 -1
  47. package/src/local/local-sync-remote.ts +44 -12
  48. package/src/local/local-sync-snapshot.ts +156 -12
  49. package/src/main.ts +13 -0
  50. package/src/mcp/mcp-server.ts +3 -0
  51. package/src/mcp/tools/docs-tools.ts +1 -1
  52. package/src/mcp/tools/knowledge-base-tools.ts +85 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.53.22",
3
+ "version": "1.53.24",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
42
  "@faker-js/faker": "10.6.0",
43
- "@frontera-sdk/core": "1.53.22",
44
- "@frontera-sdk/functions": "1.53.22",
43
+ "@frontera-sdk/core": "1.53.24",
44
+ "@frontera-sdk/functions": "1.53.24",
45
45
  "ai": "^6.0.319",
46
46
  "axe-core": "4.12.1",
47
47
  "gray-matter": "^4.0.3",
@@ -49,7 +49,7 @@
49
49
  "yaml": "^2.9.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@frontera-sdk/forge-contracts": "1.53.22",
52
+ "@frontera-sdk/forge-contracts": "1.53.24",
53
53
  "@types/bun": "^1.3.14",
54
54
  "typescript": "^5.9.3"
55
55
  }
@@ -0,0 +1,230 @@
1
+ import { CliError } from '../errors'
2
+ import { organizationHeaders } from '../organization'
3
+ import { credentialFailure } from './credential-failure'
4
+ import { formatValidationDetails } from './validation-detail'
5
+
6
+ /**
7
+ * The Knowledge app's knowledge bases (`/v1/knowledge-app`), for `frontera
8
+ * knowledge-base` and the `knowledge_base_*` MCP tools. Not Agent knowledge,
9
+ * which `PlatformApi.knowledgeBases` reads.
10
+ *
11
+ * Every call acts as a person: the person's own session on a platform, the
12
+ * runtime's owner (or a local persona, with `--as`) on the local runtime. The
13
+ * app refuses organization keys on the whole prefix, and a workspace key needs
14
+ * the Knowledge purpose, which a project's key does not have.
15
+ */
16
+
17
+ export interface KnowledgeBase {
18
+ id: string
19
+ name: string
20
+ description: string | null
21
+ ownerWorkspaceId: string | null
22
+ canManage: boolean
23
+ sharing: { allWorkspaces: boolean; workspaceIds: string[] } | null
24
+ }
25
+
26
+ export interface KnowledgeDocument {
27
+ id: string
28
+ title: string
29
+ fileName: string | null
30
+ status: 'scanning' | 'pending' | 'processing' | 'ready' | 'failed' | 'blocked'
31
+ searchable: boolean
32
+ error: string | null
33
+ source: { id: string; name: string; url: string | null } | null
34
+ updatedAt: string
35
+ }
36
+
37
+ export interface KnowledgeSearchResult {
38
+ documentId: string
39
+ title: string
40
+ knowledgeBase: { id: string; name: string } | null
41
+ page: number | null
42
+ snippet: string
43
+ score: number
44
+ openUrl: string
45
+ fileName: string | null
46
+ }
47
+
48
+ export interface KnowledgeSource {
49
+ id: string
50
+ knowledgeBaseId: string
51
+ kind: string
52
+ name: string
53
+ host: string
54
+ state: string
55
+ documents: number
56
+ canManage: boolean
57
+ sync: { nextAt?: string | null; failures?: number; lastFailure?: { message?: string } | null } & Record<string, unknown>
58
+ /** Present only for whoever manages the base. */
59
+ schedule?: unknown
60
+ keepRemovedDays?: unknown
61
+ config?: unknown
62
+ auth?: { kind: string } & Record<string, unknown>
63
+ }
64
+
65
+ export interface KnowledgeSteward {
66
+ userId: string
67
+ email: string | null
68
+ displayName: string | null
69
+ role: string
70
+ }
71
+
72
+ /** A base's documents by where they are: `reading` is scanning, waiting or being read; `failed` includes blocked files. */
73
+ export interface BaseStatus {
74
+ base: KnowledgeBase
75
+ scope: 'workspace' | 'organization'
76
+ documents: number
77
+ ready: number
78
+ reading: number
79
+ failed: number
80
+ }
81
+
82
+ /** What a refusal says next, by where the command runs. */
83
+ export const KNOWLEDGE_OFF_NEXT = {
84
+ local: 'start the local runtime with its search index: `frontera local stop`, then `frontera local start --with search-index` (a project with a knowledge-base/ folder starts it by itself)',
85
+ platform: 'Ask your administrator to turn on the Knowledge app.',
86
+ } as const
87
+
88
+ export class KnowledgeBaseApi {
89
+ constructor(
90
+ private readonly apiUrl: string,
91
+ private readonly token: string,
92
+ /** Sent as `x-workspace-id`: where the person stands, which decides the bases they read. */
93
+ private readonly workspaceId: string | undefined,
94
+ /** Whether `apiUrl` is the project's local runtime: it decides what a refusal tells the reader to do. */
95
+ private readonly local: boolean,
96
+ /** Sent as `x-org-id` when given; else the organization the command selected. */
97
+ private readonly orgId?: string,
98
+ ) {}
99
+
100
+ private orgHeaders(): Record<string, string> {
101
+ return this.orgId ? { 'x-org-id': this.orgId } : organizationHeaders()
102
+ }
103
+
104
+ private async call<T>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
105
+ const response = await fetch(`${this.apiUrl.replace(/\/+$/, '')}/v1/knowledge-app${path}`, {
106
+ method: options.method ?? 'GET',
107
+ headers: {
108
+ Authorization: `Bearer ${this.token}`,
109
+ ...this.orgHeaders(),
110
+ ...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
111
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
112
+ },
113
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
114
+ })
115
+ const text = await response.text()
116
+ let payload: { data?: unknown; message?: string; code?: string; details?: { reason?: string } & Record<string, unknown> } | null
117
+ try {
118
+ payload = text ? JSON.parse(text) : null
119
+ } catch {
120
+ payload = null
121
+ }
122
+ if (response.ok) return (payload?.data ?? payload) as T
123
+
124
+ // `knowledge_off`, or no reason at all: a release that does not mount the app.
125
+ const reason = payload?.details?.reason
126
+ if (response.status === 404 && (reason === 'knowledge_off' || reason === undefined)) {
127
+ throw new CliError(this.local ? 'this local runtime does not run the Knowledge app' : 'this platform does not run the Knowledge app', {
128
+ code: 'KNOWLEDGE_OFF',
129
+ hint: this.local ? KNOWLEDGE_OFF_NEXT.local : KNOWLEDGE_OFF_NEXT.platform,
130
+ })
131
+ }
132
+ const credential = credentialFailure(response.status, payload?.message, { token: this.token, code: payload?.code, details: payload?.details })
133
+ if (credential) throw credential
134
+ const detail = formatValidationDetails(payload?.details)
135
+ throw new CliError(payload?.message ?? `Request failed (${response.status}).`, {
136
+ code: payload?.code ?? 'FAILURE',
137
+ ...(detail ? { hint: detail } : {}),
138
+ })
139
+ }
140
+
141
+ async bases(): Promise<KnowledgeBase[]> {
142
+ return (await this.call<{ bases: KnowledgeBase[] }>('/bases')).bases
143
+ }
144
+
145
+ /** One page of a base's documents, newest first, and how many match in all. */
146
+ documents(baseId: string, query: { status?: KnowledgeDocument['status']; limit?: number; cursor?: string } = {}): Promise<{ documents: KnowledgeDocument[]; nextCursor: string | null; total: number }> {
147
+ const params = new URLSearchParams()
148
+ for (const [k, v] of Object.entries(query)) if (v !== undefined) params.set(k, String(v))
149
+ const qs = params.toString()
150
+ return this.call(`/bases/${encodeURIComponent(baseId)}/documents${qs ? `?${qs}` : ''}`)
151
+ }
152
+
153
+ /** Every document of a base, page after page. */
154
+ async allDocuments(baseId: string): Promise<KnowledgeDocument[]> {
155
+ const all: KnowledgeDocument[] = []
156
+ let cursor: string | undefined
157
+ do {
158
+ const page = await this.documents(baseId, { limit: 200, ...(cursor ? { cursor } : {}) })
159
+ all.push(...page.documents)
160
+ cursor = page.nextCursor ?? undefined
161
+ } while (cursor)
162
+ return all
163
+ }
164
+
165
+ /** The ready, reading and failed counts of each base: four counts a base, asked at once. */
166
+ async statuses(bases: KnowledgeBase[]): Promise<BaseStatus[]> {
167
+ const count = async (id: string, status?: KnowledgeDocument['status']) => (await this.documents(id, { limit: 1, ...(status ? { status } : {}) })).total
168
+ return Promise.all(bases.map(async (base) => {
169
+ const [documents, ready, failed, blocked] = await Promise.all([count(base.id), count(base.id, 'ready'), count(base.id, 'failed'), count(base.id, 'blocked')])
170
+ return { base, scope: scopeOf(base), documents, ready, reading: Math.max(0, documents - ready - failed - blocked), failed: failed + blocked }
171
+ }))
172
+ }
173
+
174
+ async search(body: { query: string; knowledgeBaseIds?: string[]; limit?: number }): Promise<{ results: KnowledgeSearchResult[]; total: number; mode: string; correctedQuery?: string }> {
175
+ return this.call('/search', { method: 'POST', body })
176
+ }
177
+
178
+ async sources(baseId: string): Promise<KnowledgeSource[]> {
179
+ return (await this.call<{ sources: KnowledgeSource[] }>(`/bases/${encodeURIComponent(baseId)}/sources`)).sources
180
+ }
181
+
182
+ /** Test connection: the first page of a source as it would be added. Stores nothing. */
183
+ async testSource(baseId: string, spec: Record<string, unknown>): Promise<unknown> {
184
+ return (await this.call<{ test: unknown }>(`/bases/${encodeURIComponent(baseId)}/sources/test`, { method: 'POST', body: spec })).test
185
+ }
186
+
187
+ async syncSource(sourceId: string): Promise<KnowledgeSource> {
188
+ return (await this.call<{ source: KnowledgeSource }>(`/sources/${encodeURIComponent(sourceId)}/sync`, { method: 'POST' })).source
189
+ }
190
+
191
+ async stewards(): Promise<KnowledgeSteward[]> {
192
+ return (await this.call<{ stewards: KnowledgeSteward[] }>('/stewards')).stewards
193
+ }
194
+
195
+ async addSteward(userId: string): Promise<void> {
196
+ await this.call('/stewards', { method: 'POST', body: { userId } })
197
+ }
198
+
199
+ async removeSteward(userId: string): Promise<void> {
200
+ await this.call(`/stewards/${encodeURIComponent(userId)}`, { method: 'DELETE' })
201
+ }
202
+
203
+ /** The organization member with this email, from the members list any member reads. */
204
+ async memberByEmail(email: string): Promise<{ userId: string; email: string } | null> {
205
+ const response = await fetch(`${this.apiUrl.replace(/\/+$/, '')}/v1/users/members?limit=100&search=${encodeURIComponent(email)}`, {
206
+ headers: { Authorization: `Bearer ${this.token}`, ...this.orgHeaders() },
207
+ })
208
+ const payload = (await response.json().catch(() => null)) as { data?: { data?: Array<{ userId: string; user?: { email?: string | null } }> }; message?: string } | null
209
+ if (!response.ok) {
210
+ throw credentialFailure(response.status, payload?.message, { token: this.token }) ?? new CliError(payload?.message ?? `Request failed (${response.status}).`, { code: 'FAILURE', hint: 'pass the person’s user id instead of the email' })
211
+ }
212
+ const match = (payload?.data?.data ?? []).find((m) => (m.user?.email ?? '').toLowerCase() === email.toLowerCase())
213
+ return match ? { userId: match.userId, email: match.user!.email! } : null
214
+ }
215
+ }
216
+
217
+ export function scopeOf(base: Pick<KnowledgeBase, 'ownerWorkspaceId'>): 'workspace' | 'organization' {
218
+ return base.ownerWorkspaceId ? 'workspace' : 'organization'
219
+ }
220
+
221
+ /** A result as a reader cites it: `title (base, page 3)`. */
222
+ export function citation(result: Pick<KnowledgeSearchResult, 'title' | 'knowledgeBase' | 'page'>): string {
223
+ const where = [result.knowledgeBase?.name, result.page ? `page ${result.page}` : null].filter(Boolean).join(', ')
224
+ return where ? `${result.title} (${where})` : result.title
225
+ }
226
+
227
+ /** The snippet without the search's `<mark>` around the query's words. */
228
+ export function plainSnippet(snippet: string): string {
229
+ return snippet.replace(/<\/?mark>/g, '').replace(/\s+/g, ' ').trim()
230
+ }
@@ -28,15 +28,15 @@ interface KnowledgeRow {
28
28
 
29
29
  /**
30
30
  * These commands manage the knowledge bases Agents attach. The Knowledge app's
31
- * bases (organization and workspace) are another thing with no command or
32
- * project file, and no local runtime runs the app. Said only where the
33
- * platform runs the app: elsewhere there is no such base to mistake for one.
31
+ * bases (organization and workspace) are another kind, with their own command
32
+ * and project folder. Said only where the platform runs the app: elsewhere
33
+ * there is no such base to mistake for one.
34
34
  */
35
35
  async function knowledgeAppNote(api: PlatformApi): Promise<string> {
36
36
  return (await api.knowledgeAppRuns())
37
- ? ' A knowledge base from the Knowledge app is not one of these: it runs on the platform only, with its search and AI answers, not on a local runtime. '
38
- + 'Choose it for an Agent on the platform, under the Agent\'s Knowledge page, and test that Agent there. '
39
- + 'Its sources, the other systems it copies documents from, are added on the platform too, on the knowledge base\'s page.'
37
+ ? ' A knowledge base from the Knowledge app is not one of these: use `frontera knowledge-base` for it. '
38
+ + 'A project holds it in knowledge-base/<name>/, brought with `frontera local sync pull --kinds knowledge-bases`, '
39
+ + 'and an Agent searches it once its agent.json lists `knowledge-base/<name>` in knowledgeBases.'
40
40
  : ''
41
41
  }
42
42
 
@@ -0,0 +1,328 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import {
5
+ citation,
6
+ KnowledgeBaseApi,
7
+ plainSnippet,
8
+ scopeOf,
9
+ type KnowledgeBase,
10
+ type KnowledgeSource,
11
+ } from '../../api/knowledge-base-api'
12
+ import { CliError, UsageError } from '../../errors'
13
+ import { KNOWLEDGE_BASE_DIR, KNOWLEDGE_BASE_FILE } from '../../local/local-knowledge-base-files'
14
+ import { platformRefName } from '../../local/local-knowledge-base-pull'
15
+ import { KNOWLEDGE_PERSONAS } from '../../local/local-personas'
16
+ import { localProject } from '../../local/local-project'
17
+ import { localRuntimeAt } from '../../local/local-runtime-target'
18
+ import { readSecretValue } from '../../secrets'
19
+ import { table } from '../../table'
20
+ import { flagString, flagStrings, type Command, type CommandContext, type CommandMeta } from '../types'
21
+
22
+ /**
23
+ * `frontera knowledge-base`: the Knowledge app's knowledge bases (folder
24
+ * `knowledge-base/<name>/` in a project), on the local runtime or a platform.
25
+ * Not `frontera knowledge`, which is Agent knowledge.
26
+ *
27
+ * Every verb acts as a person, because the Knowledge app takes no
28
+ * organization key and a project's workspace key lacks its purpose: the
29
+ * runtime's owner on the local runtime (`localOwner`), a local persona with
30
+ * `--as`, and the person signed in (`frontera auth login --browser`) on a
31
+ * platform.
32
+ */
33
+
34
+ const SESSION: Pick<CommandMeta, 'authLane' | 'localOwner' | 'sessionAlternative'> = {
35
+ authLane: 'session',
36
+ localOwner: true,
37
+ sessionAlternative: 'the Knowledge app on the platform',
38
+ }
39
+
40
+ const PERSONA_KEYS = KNOWLEDGE_PERSONAS.map((p) => p.key).join(', ')
41
+
42
+ function api(ctx: CommandContext): KnowledgeBaseApi {
43
+ return new KnowledgeBaseApi(ctx.apiUrl, ctx.token, ctx.workspaceId, !!localRuntimeAt(ctx.cwd, ctx.apiUrl))
44
+ }
45
+
46
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
47
+
48
+ /** The name a project folder's base has: `name` in its knowledge-base.json, else the folder's. */
49
+ function folderBaseName(cwd: string, folder: string): string | null {
50
+ let root: string
51
+ try {
52
+ root = localProject(cwd).root
53
+ } catch {
54
+ return null
55
+ }
56
+ const file = join(root, KNOWLEDGE_BASE_DIR, folder, KNOWLEDGE_BASE_FILE)
57
+ if (!existsSync(file)) return null
58
+ try {
59
+ const name = (JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }).name
60
+ return typeof name === 'string' && name.trim() ? name.trim() : folder
61
+ } catch {
62
+ return folder
63
+ }
64
+ }
65
+
66
+ /** A base the person reads, by id, by name, or by its project folder (`knowledge-base/<folder>` or `<folder>`). */
67
+ export async function resolveBase(client: KnowledgeBaseApi, cwd: string, ref: string): Promise<KnowledgeBase> {
68
+ const bases = await client.bases()
69
+ if (UUID.test(ref)) {
70
+ const byId = bases.find((b) => b.id.toLowerCase() === ref.toLowerCase())
71
+ if (byId) return byId
72
+ }
73
+ const folder = ref.replace(new RegExp(`^${KNOWLEDGE_BASE_DIR}/`), '').replace(/\/+$/, '')
74
+ const wanted = [ref, folderBaseName(cwd, folder)].filter((n): n is string => !!n).map((n) => n.toLowerCase())
75
+ const named = bases.filter((b) => wanted.includes(b.name.toLowerCase()))
76
+ if (named.length === 1) return named[0]!
77
+ if (named.length > 1) {
78
+ throw new CliError(`${named.length} knowledge bases are named "${named[0]!.name}"`, {
79
+ code: 'AMBIGUOUS',
80
+ hint: `pass one by id: ${named.map((b) => `${b.id} (${scopeOf(b)})`).join(', ')}`,
81
+ })
82
+ }
83
+ throw new CliError(`no knowledge base "${ref}" that you can read here`, {
84
+ code: 'NOT_FOUND',
85
+ hint: bases.length
86
+ ? `the bases you read here: ${bases.map((b) => b.name).join(', ')}`
87
+ : 'you read no knowledge base here: `frontera knowledge-base list` shows what you can read',
88
+ })
89
+ }
90
+
91
+ function sharingWords(base: KnowledgeBase): string {
92
+ if (!base.sharing) return ''
93
+ if (base.sharing.allWorkspaces) return 'all workspaces'
94
+ const n = base.sharing.workspaceIds.length
95
+ return n === 0 ? 'no workspace' : `${n} workspace${n === 1 ? '' : 's'}`
96
+ }
97
+
98
+ const list: Command = {
99
+ meta: {
100
+ noun: 'knowledge-base',
101
+ verb: 'list',
102
+ args: [],
103
+ flags: { as: 'string' },
104
+ summary: 'List the knowledge bases you can read here, the workspace’s and the organization’s shared with it',
105
+ details: [`On the local runtime the command acts as its owner; \`--as\` acts as a local persona (${PERSONA_KEYS}).`],
106
+ examples: ['frontera knowledge-base list', 'frontera knowledge-base list --as outsider --json'],
107
+ ...SESSION,
108
+ },
109
+ async run(ctx) {
110
+ const bases = await api(ctx).bases()
111
+ return {
112
+ data: bases.map((b) => ({ ...b, scope: scopeOf(b) })),
113
+ text: bases.length === 0
114
+ ? 'No knowledge bases you can read here.'
115
+ : table(['name', 'scope', 'shared with', 'id'], bases.map((b) => [b.name, scopeOf(b), sharingWords(b), b.id]), [40, undefined, undefined, undefined]),
116
+ }
117
+ },
118
+ }
119
+
120
+ const status: Command = {
121
+ meta: {
122
+ noun: 'knowledge-base',
123
+ verb: 'status',
124
+ args: [{ name: 'base', required: false, description: 'one knowledge base, by name, id or project folder; all you read when absent' }],
125
+ flags: { as: 'string' },
126
+ summary: 'Show each knowledge base’s documents: ready, still reading, and failed',
127
+ details: ['Reading counts files being checked, waiting or being read. Failed counts files that could not be read and files the check blocked; `frontera knowledge-base documents <base>` names them.'],
128
+ examples: ['frontera knowledge-base status', 'frontera knowledge-base status policies --json'],
129
+ ...SESSION,
130
+ },
131
+ async run(ctx) {
132
+ const client = api(ctx)
133
+ const ref = ctx.positional[0]
134
+ const bases = ref ? [await resolveBase(client, ctx.cwd, ref)] : await client.bases()
135
+ const rows = await client.statuses(bases)
136
+ return {
137
+ data: rows.map(({ base, ...counts }) => ({ id: base.id, name: base.name, ...counts })),
138
+ text: rows.length === 0
139
+ ? 'No knowledge bases you can read here.'
140
+ : table(['name', 'scope', 'ready', 'reading', 'failed'], rows.map((r) => [r.base.name, r.scope, String(r.ready), String(r.reading), String(r.failed)]), [40, undefined, undefined, undefined, undefined]),
141
+ }
142
+ },
143
+ }
144
+
145
+ const search: Command = {
146
+ meta: {
147
+ noun: 'knowledge-base',
148
+ verb: 'search',
149
+ args: [{ name: 'query', required: true, description: 'what to search for, as a person would ask it' }],
150
+ flags: { as: 'string', base: 'string', limit: 'string' },
151
+ summary: 'Search the knowledge bases you can read, and cite where each result comes from',
152
+ details: [
153
+ `It searches as the person it acts as, so it finds only what that person may read. On the local runtime, \`--as\` tests that: \`--as outsider\` finds nothing in the workspace’s bases. The personas are ${PERSONA_KEYS}.`,
154
+ ],
155
+ flagHelp: { base: 'search only this knowledge base, by name, id or project folder; repeat for more', limit: 'how many results to show (1 to 50, default 10)' },
156
+ examples: [
157
+ 'frontera knowledge-base search "late repayment fee"',
158
+ 'frontera knowledge-base search "late repayment fee" --base policies --limit 5',
159
+ 'frontera knowledge-base search "late repayment fee" --as outsider',
160
+ ],
161
+ ...SESSION,
162
+ },
163
+ async run(ctx) {
164
+ const query = ctx.positional.join(' ').trim()
165
+ if (!query) throw new UsageError('missing <query>', 'frontera knowledge-base search "what a person would ask"')
166
+ const limit = flagString(ctx, 'limit')
167
+ const n = limit === undefined ? 10 : Number(limit)
168
+ if (!Number.isInteger(n) || n < 1 || n > 50) throw new UsageError('--limit takes a whole number from 1 to 50', `frontera knowledge-base search "${query}" --limit 10`)
169
+ const client = api(ctx)
170
+ const only = await Promise.all(flagStrings(ctx, 'base').map((ref) => resolveBase(client, ctx.cwd, ref)))
171
+ const answer = await client.search({ query, limit: n, ...(only.length ? { knowledgeBaseIds: only.map((b) => b.id) } : {}) })
172
+ const results = answer.results.map((r, i) => ({ n: i + 1, citation: citation(r), ...r, snippet: plainSnippet(r.snippet) }))
173
+ const corrected = answer.correctedQuery ? `Searched for “${answer.correctedQuery}”.\n\n` : ''
174
+ return {
175
+ data: { query, results, total: answer.total, mode: answer.mode, ...(answer.correctedQuery ? { correctedQuery: answer.correctedQuery } : {}) },
176
+ text: results.length === 0
177
+ ? `No results for “${query}” in the knowledge bases you can read here.`
178
+ : corrected + results.map((r) => `[${r.n}] ${r.citation}\n ${r.snippet}`).join('\n\n'),
179
+ }
180
+ },
181
+ }
182
+
183
+ const documents: Command = {
184
+ meta: {
185
+ noun: 'knowledge-base',
186
+ verb: 'documents',
187
+ args: [{ name: 'base', required: true, description: 'knowledge base by name, id or project folder' }],
188
+ flags: { as: 'string' },
189
+ summary: 'List one knowledge base’s documents and where each one stands',
190
+ examples: ['frontera knowledge-base documents policies', 'frontera knowledge-base documents policies --json'],
191
+ ...SESSION,
192
+ },
193
+ async run(ctx) {
194
+ const ref = ctx.positional[0]
195
+ if (!ref) throw new UsageError('missing <base>', 'frontera knowledge-base list — then pass a name')
196
+ const client = api(ctx)
197
+ const base = await resolveBase(client, ctx.cwd, ref)
198
+ const docs = await client.allDocuments(base.id)
199
+ return {
200
+ data: docs,
201
+ text: docs.length === 0
202
+ ? `No documents in ${base.name}.`
203
+ : table(
204
+ ['title', 'status', 'from', 'id'],
205
+ docs.map((d) => [d.title, d.error ? `${d.status}: ${d.error}` : d.status, d.source?.name ?? 'uploaded', d.id]),
206
+ [50, 40, 24, undefined],
207
+ ),
208
+ }
209
+ },
210
+ }
211
+
212
+ /** A source by its id, its name, or its file name in the project (`sources/<name>.json`, as a pull names it). */
213
+ function findSource(sources: KnowledgeSource[], ref: string, base: KnowledgeBase): KnowledgeSource {
214
+ const stem = ref.replace(/\.json$/, '')
215
+ const match = sources.find((s) => s.id === ref) ?? sources.find((s) => s.name.toLowerCase() === stem.toLowerCase() || platformRefName(s.name) === stem)
216
+ if (match) return match
217
+ throw new CliError(`no source "${ref}" in ${base.name}`, {
218
+ code: 'NOT_FOUND',
219
+ hint: sources.length ? `its sources: ${sources.map((s) => s.name).join(', ')}` : `${base.name} has no sources here`,
220
+ })
221
+ }
222
+
223
+ const source: Command = {
224
+ meta: {
225
+ noun: 'knowledge-base',
226
+ verb: 'source',
227
+ args: [
228
+ { name: 'action', required: true, description: 'test | sync' },
229
+ { name: 'base', required: true, description: 'knowledge base by name, id or project folder' },
230
+ { name: 'source', required: true, description: 'the source by name or id' },
231
+ ],
232
+ flags: { 'credential-from': 'string' },
233
+ flagHelp: { 'credential-from': 'test only: where to read the credential of a source that signs in: `-` for stdin, or a file path. Never an inline value' },
234
+ summary: 'Test a source’s connection, or sync it now',
235
+ details: [
236
+ 'test reads the first page of the source as it is set up on the target, and stores nothing. The stored credential is never sent back, so a source that signs in is tested with its credential from --credential-from.',
237
+ 'sync makes the source due now. It is refused while the source syncs, is paused, or waits for its credential.',
238
+ ],
239
+ examples: [
240
+ 'frontera knowledge-base source test policies help-center',
241
+ 'frontera knowledge-base source test policies tickets --credential-from -',
242
+ 'frontera knowledge-base source sync policies help-center',
243
+ ],
244
+ ...SESSION,
245
+ },
246
+ async run(ctx) {
247
+ const [action, baseRef, sourceRef] = ctx.positional
248
+ if (action !== 'test' && action !== 'sync') throw new UsageError(`unknown action "${action ?? ''}"`, 'frontera knowledge-base source test|sync <base> <source>')
249
+ if (!baseRef || !sourceRef) throw new UsageError('missing <base> or <source>', `frontera knowledge-base source ${action} <base> <source>`)
250
+ const client = api(ctx)
251
+ const base = await resolveBase(client, ctx.cwd, baseRef)
252
+ const found = findSource(await client.sources(base.id), sourceRef, base)
253
+
254
+ if (action === 'sync') {
255
+ const after = await client.syncSource(found.id)
256
+ return { data: after, text: `${found.name} syncs now (${after.state}). \`frontera knowledge-base status ${base.name}\` shows its documents as they are read.` }
257
+ }
258
+
259
+ if (!found.config || !found.auth) {
260
+ throw new CliError(`only whoever manages ${base.name} can test its sources`, { code: 'FORBIDDEN', hint: 'a Knowledge steward manages organization bases; a workspace member with Knowledge update manages the workspace’s' })
261
+ }
262
+ const from = flagString(ctx, 'credential-from')
263
+ const signsIn = found.auth.kind !== 'none'
264
+ if (signsIn && !from) {
265
+ throw new UsageError(`${found.name} signs in, and the stored credential is never sent back`, `pass its credential for this test: \`frontera knowledge-base source test ${baseRef} ${sourceRef} --credential-from -\``)
266
+ }
267
+ const credential = signsIn ? (await readSecretValue(from!)).replace(/\r?\n$/, '') : undefined
268
+ const spec = { kind: found.kind, name: found.name, schedule: found.schedule, keepRemovedDays: found.keepRemovedDays, config: found.config, auth: found.auth, ...(credential ? { credential } : {}) }
269
+ const test = await client.testSource(base.id, spec) as { connected: boolean; page?: { items: number; more: boolean }; problem?: { message: string }; unmapped?: { items: number } }
270
+ return {
271
+ data: test,
272
+ text: test.connected
273
+ ? `${found.name} answers: ${test.page?.items ?? 0} items on its first page${test.page?.more ? ', and more pages' : ''}${test.unmapped?.items ? `; ${test.unmapped.items} items have fields that did not map` : ''}.`
274
+ : `${found.name} did not connect: ${test.problem?.message ?? 'no reason given'}`,
275
+ }
276
+ },
277
+ }
278
+
279
+ const steward: Command = {
280
+ meta: {
281
+ noun: 'knowledge-base',
282
+ verb: 'steward',
283
+ args: [
284
+ { name: 'action', required: true, description: 'list | add | remove' },
285
+ { name: 'person', required: false, description: 'add and remove: the person’s email in the organization, or their user id' },
286
+ ],
287
+ flags: {},
288
+ scope: 'organization',
289
+ summary: 'List, add or remove the organization’s Knowledge stewards, who manage its organization knowledge bases',
290
+ details: [
291
+ 'Stewards are people, so they live on the platform and never in project files. Adding or removing one needs an organization owner or admin. On the local runtime the owner is a steward already.',
292
+ ],
293
+ examples: [
294
+ 'frontera knowledge-base steward list',
295
+ 'frontera knowledge-base steward add rina@example.com',
296
+ 'frontera knowledge-base steward remove rina@example.com',
297
+ ],
298
+ ...SESSION,
299
+ },
300
+ async run(ctx) {
301
+ const [action, person] = ctx.positional
302
+ if (action !== 'list' && action !== 'add' && action !== 'remove') throw new UsageError(`unknown action "${action ?? ''}"`, 'frontera knowledge-base steward list|add|remove [person]')
303
+ const client = api(ctx)
304
+ if (action === 'list') {
305
+ const stewards = await client.stewards()
306
+ return {
307
+ data: stewards,
308
+ text: stewards.length === 0
309
+ ? 'No Knowledge stewards. An owner or admin adds one: `frontera knowledge-base steward add <email>`.'
310
+ : table(['email', 'name', 'role'], stewards.map((s) => [s.email ?? s.userId, s.displayName ?? '', s.role])),
311
+ }
312
+ }
313
+ if (!person) throw new UsageError('missing <person>', `frontera knowledge-base steward ${action} <email>`)
314
+ const isEmail = person.includes('@')
315
+ if (action === 'add') {
316
+ const member = isEmail ? await client.memberByEmail(person) : { userId: person, email: person }
317
+ if (!member) throw new CliError(`no member of this organization has the email ${person}`, { code: 'NOT_FOUND', hint: 'a steward is a member of the organization first: an owner or admin adds them in Organization settings' })
318
+ await client.addSteward(member.userId)
319
+ return { data: { added: member }, text: `${member.email} is a Knowledge steward.` }
320
+ }
321
+ const current = (await client.stewards()).find((s) => (isEmail ? (s.email ?? '').toLowerCase() === person.toLowerCase() : s.userId === person))
322
+ if (!current) return { data: { removed: null }, text: `${person} is not a Knowledge steward.` }
323
+ await client.removeSteward(current.userId)
324
+ return { data: { removed: current }, text: `${current.email ?? current.userId} is no longer a Knowledge steward.` }
325
+ },
326
+ }
327
+
328
+ export const knowledgeBaseCommands: readonly Command[] = [list, status, search, documents, source, steward]
@@ -14,6 +14,7 @@ import { repullRefFor } from '../../local/local-app-code'
14
14
  import { customAppFolders } from '../../local/local-apps'
15
15
  import { endCheckBaseline, readBaseline, restoreBaseline, restoreCutOffCheck, saveBaseline } from '../../local/local-db-snapshot'
16
16
  import { localIngestApi, settleKnowledge, type KnowledgeSettle } from '../../local/local-knowledge-ingestion'
17
+ import { sourcesOffNeeds } from '../../local/local-knowledge-base-sources-off'
17
18
  import { localProject, type LocalProject } from '../../local/local-project'
18
19
  import { projectSecretLines } from '../../local/local-project-secrets'
19
20
  import { localSecretPresence } from '../../local/local-secrets'
@@ -299,6 +300,11 @@ export function appsWithCheck(root: string): string[] {
299
300
  return customAppFolders(root).filter((dir) => !!readPackage(join(root, dir))?.scripts?.check)
300
301
  }
301
302
 
303
+ /** Each knowledge base whose sources the project's Knowledge settings keep off, as `local check` warns of it. */
304
+ export function sourcesOffWarnings(root: string): string[] {
305
+ return sourcesOffNeeds(root).map((n) => `${n.step}: ${n.message}. Fix: ${n.fix}`)
306
+ }
307
+
302
308
  function parseSkip(raw: string | undefined): Set<CheckStepName> {
303
309
  const names = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean)
304
310
  for (const n of names) {
@@ -384,6 +390,12 @@ async function check(ctx: CommandContext) {
384
390
  record({ step, status: code === 0 ? 'passed' : 'failed', command: shown, ms: Date.now() - started, ...(code === 0 ? {} : { note: `\`${shown}\` exited ${code}` }) })
385
391
  }
386
392
 
393
+ // Said first, as a warning that fails nothing: the local runtime goes without such a source, and the platform's Apply refuses the base.
394
+ for (const line of sourcesOffWarnings(root)) {
395
+ ctx.output.note(`warning: ${line}`)
396
+ knowledgeNotes.push(line)
397
+ }
398
+
387
399
  for (const step of CHECK_STEPS) {
388
400
  const blocked = blockedBy(step, failedSteps, failFast)
389
401
  if (blocked) {