@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
@@ -4,7 +4,7 @@ import { dirname, resolve } from 'node:path'
4
4
  import { DatasetApi, type DatasetColumn } from '../../api/dataset-api'
5
5
  import { CliError } from '../../errors'
6
6
  import { table } from '../../table'
7
- import type { Command } from '../types'
7
+ import { flagString, type Command } from '../types'
8
8
 
9
9
  /**
10
10
  * Datasets — the source contract a Blueprint object type reads.
@@ -127,6 +127,105 @@ const list: Command = {
127
127
  },
128
128
  }
129
129
 
130
+ /**
131
+ * A dataset by API name.
132
+ *
133
+ * Datasets are addressed by `apiName` everywhere a caller meets one — it is
134
+ * what `backing.dataset` holds in a committed tree — while every route takes
135
+ * the uuid, so the lookup is unavoidable and belongs in one place.
136
+ */
137
+ async function resolveDataset(api: DatasetApi, apiName: string) {
138
+ const datasets = await api.list()
139
+ const dataset = datasets.find((entry) => (entry.apiName ?? entry.name) === apiName)
140
+ if (!dataset?.id) {
141
+ throw new CliError(`No dataset named "${apiName}" in this organization.`, {
142
+ code: 'NOT_FOUND',
143
+ // The empty case reads as "missing" and is usually a capability, not a fact.
144
+ hint: datasets.length === 0
145
+ ? 'No datasets are visible at all — the key may have been minted without dataset:read.'
146
+ : `Visible: ${datasets.map((entry) => entry.apiName ?? entry.name).filter(Boolean).join(', ')}`,
147
+ })
148
+ }
149
+ return dataset
150
+ }
151
+
152
+ const rows: Command = {
153
+ meta: {
154
+ noun: 'dataset',
155
+ verb: 'rows',
156
+ args: [{ name: 'apiName', required: true, description: 'The dataset’s API name' }],
157
+ flags: { limit: 'string', cursor: 'string' },
158
+ summary: 'Read rows of the dataset’s current revision',
159
+ examples: ['frontera dataset rows customers', 'frontera dataset rows customers --limit 20 --json'],
160
+ },
161
+ async run(ctx) {
162
+ const apiName = ctx.positional[0]
163
+ if (!apiName) {
164
+ throw new CliError('A dataset apiName is required.', { code: 'USAGE', hint: 'frontera dataset list' })
165
+ }
166
+
167
+ const limitRaw = flagString(ctx, 'limit')
168
+ const limit = limitRaw === undefined ? undefined : Number(limitRaw)
169
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
170
+ throw new CliError(`--limit must be a positive whole number, not "${limitRaw}".`, {
171
+ code: 'USAGE',
172
+ hint: 'The service caps a page at 100 rows; try --limit 20.',
173
+ })
174
+ }
175
+
176
+ const api = new DatasetApi(ctx.apiUrl, ctx.token)
177
+ const dataset = await resolveDataset(api, apiName)
178
+ // The CURRENT revision, not the newest row in the table: a dataset can
179
+ // carry a draft revision that nothing is bound to, and reading that one
180
+ // would answer a question nobody asked.
181
+ const revisionId = dataset.currentRevisionId
182
+ ?? (await api.revisions(dataset.id!)).at(-1)?.id
183
+ if (!revisionId) {
184
+ throw new CliError(`"${apiName}" has no published revision, so it has no rows.`, {
185
+ code: 'NOT_FOUND',
186
+ hint: 'A dataset binds only through a published revision — publish one in the Console first.',
187
+ })
188
+ }
189
+
190
+ const cursor = flagString(ctx, 'cursor')
191
+ const page = await api.revisionRows(revisionId, {
192
+ ...(limit === undefined ? {} : { limit }),
193
+ ...(cursor === undefined ? {} : { cursor }),
194
+ })
195
+
196
+ const columns = (page.columns ?? []).map((c) => c.name ?? '?')
197
+ const body = page.rows ?? []
198
+
199
+ if (body.length === 0) {
200
+ return {
201
+ data: page,
202
+ // The contract exists and the data does not — the state `dataset get`
203
+ // cannot distinguish, and the reason this verb exists.
204
+ text:
205
+ `No rows in the current revision of "${apiName}".\n`
206
+ + ' The column contract is published, so anything bound to it will bind and read nothing.\n'
207
+ + ` \`frontera dataset get ${apiName}\` shows the contract.`,
208
+ }
209
+ }
210
+
211
+ return {
212
+ data: page,
213
+ text:
214
+ table(columns, body.map((row) => row.map(cellText)))
215
+ + `\n\n${body.length} row${body.length === 1 ? '' : 's'} read ${page.readAt ?? 'now'}`
216
+ + (page.consistency ? ` (${page.consistency})` : '')
217
+ + (page.nextCursor ? `\nMore rows. Continue with --cursor ${page.nextCursor}` : ''),
218
+ }
219
+ },
220
+ }
221
+
222
+ /** Cells arrive as arbitrary JSON: nulls print empty, objects collapse. */
223
+ function cellText(value: unknown): string {
224
+ if (value === null || value === undefined) return ''
225
+ if (typeof value === 'object') return JSON.stringify(value)
226
+ return String(value)
227
+ }
228
+
130
229
  const get: Command = {
131
230
  meta: {
132
231
  noun: 'dataset',
@@ -145,18 +244,8 @@ const get: Command = {
145
244
  })
146
245
  }
147
246
  const api = new DatasetApi(ctx.apiUrl, ctx.token)
148
- const datasets = await api.list()
149
- const dataset = datasets.find((entry) => (entry.apiName ?? entry.name) === apiName)
150
- if (!dataset?.id) {
151
- throw new CliError(`No dataset named "${apiName}" in this organization.`, {
152
- code: 'NOT_FOUND',
153
- // The empty case reads as "missing" and is usually a capability, not a fact.
154
- hint: datasets.length === 0
155
- ? 'No datasets are visible at all — the key may have been minted without dataset:read.'
156
- : `Visible: ${datasets.map((entry) => entry.apiName ?? entry.name).filter(Boolean).join(', ')}`,
157
- })
158
- }
159
- const revisions = await api.revisions(dataset.id)
247
+ const dataset = await resolveDataset(api, apiName)
248
+ const revisions = await api.revisions(dataset.id!)
160
249
  const current = revisions.find((entry) => entry.id === dataset.currentRevisionId) ?? revisions.at(-1)
161
250
  return {
162
251
  data: { dataset, revision: current },
@@ -428,4 +517,4 @@ const create: Command = {
428
517
  },
429
518
  }
430
519
 
431
- export const datasetCommands: Command[] = [list, get, sources, preview, testSource, create]
520
+ export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create]
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { checkCompatibility, findDuplicateSources, KIT, readLock } from '../../kit'
5
+ import type { Command } from '../types'
6
+ import { cliVersion, kitRoot } from './shared'
7
+
8
+ /**
9
+ * Diagnose a harness that is present twice, or not at all.
10
+ *
11
+ * The condition worth having a command for: installed plugin AND vendored copy
12
+ * at the same time. Codex does not merge equal skill names across scopes, so
13
+ * both appear, and a model choosing between two identical-looking skills is
14
+ * making a coin flip nobody asked it to make. Claude namespaces plugin skills,
15
+ * but the recommendation is the same for both hosts so that behaviour does not
16
+ * depend on which one the person happens to be in.
17
+ *
18
+ * It reports the paths it actually found rather than asserting a diagnosis, so
19
+ * a wrong one is visible.
20
+ */
21
+ export const kitDoctor: Command = {
22
+ meta: {
23
+ noun: 'kit',
24
+ verb: 'doctor',
25
+ args: [],
26
+ flags: { dir: 'string' },
27
+ summary: 'Diagnose duplicate skill sources, stale kits and version mismatches',
28
+ examples: ['frontera kit doctor', 'frontera kit doctor --json'],
29
+ offline: true,
30
+ },
31
+
32
+ async run(ctx) {
33
+ const root = kitRoot(ctx)
34
+ const lock = readLock(root)
35
+ const duplicates = findDuplicateSources(root)
36
+ const compat = checkCompatibility(cliVersion())
37
+
38
+ const findings: Array<{ level: 'error' | 'warning' | 'ok'; message: string; fix?: string }> = []
39
+
40
+ if (duplicates.length > 0) {
41
+ findings.push({
42
+ level: 'warning',
43
+ message:
44
+ `${duplicates.length} Frontera skill(s) resolve from more than one source — `
45
+ + 'Codex does not merge equal names across scopes, so both appear',
46
+ fix: lock
47
+ ? 'pick one mode: uninstall the plugin, or delete .agents/skills, .claude/skills and frontera.kit.lock.json'
48
+ : 'pick one mode: uninstall the duplicate plugin install',
49
+ })
50
+ }
51
+
52
+ if (lock && lock.kitVersion !== KIT.kitVersion) {
53
+ findings.push({
54
+ level: 'warning',
55
+ message: `vendored kit ${lock.kitVersion} is behind the ${KIT.kitVersion} this CLI carries`,
56
+ fix: 'frontera kit sync',
57
+ })
58
+ }
59
+
60
+ if (!compat.compatible) {
61
+ findings.push({
62
+ level: 'error',
63
+ message: `${compat.reason} (CLI ${compat.cliVersion}, kit ${KIT.kitVersion})`,
64
+ fix: `install a CLI in [${compat.required.minimum}, ${compat.required.maximumExclusive})`,
65
+ })
66
+ }
67
+
68
+ const claudeMd = join(root, 'CLAUDE.md')
69
+ if (lock && existsSync(claudeMd)) {
70
+ const imports = readFileSync(claudeMd, 'utf8').split('\n').some((l) => l.trim() === '@AGENTS.md')
71
+ if (!imports) {
72
+ findings.push({
73
+ level: 'error',
74
+ message: 'CLAUDE.md does not import @AGENTS.md, so Claude Code never loads the project contract',
75
+ fix: 'frontera kit vendor',
76
+ })
77
+ }
78
+ }
79
+
80
+ if (findings.length === 0) {
81
+ findings.push({
82
+ level: 'ok',
83
+ message: lock
84
+ ? `vendored kit ${lock.kitVersion}, one source per skill, CLI in range`
85
+ : 'not vendored — installed plugins are the normal path, and nothing here conflicts with them',
86
+ })
87
+ }
88
+
89
+ const data = { root, vendored: lock !== null, duplicateSkillSources: duplicates, compatibility: compat, findings }
90
+
91
+ const lines = findings.flatMap((f) => [
92
+ `${f.level === 'ok' ? 'ok' : f.level === 'error' ? 'error' : 'warning'}: ${f.message}`,
93
+ ...(f.fix ? [` → ${f.fix}`] : []),
94
+ ])
95
+ for (const dup of duplicates) {
96
+ lines.push(` ${dup.skill}:`, ...dup.sources.map((s) => ` ${s}`))
97
+ }
98
+
99
+ return { data, text: lines.join('\n') }
100
+ },
101
+ }
@@ -0,0 +1,7 @@
1
+ import { kitDoctor } from './doctor'
2
+ import { kitStatus } from './status'
3
+ import { kitSync } from './sync'
4
+ import { kitVendor } from './vendor'
5
+ import type { Command } from '../types'
6
+
7
+ export const kitCommands: readonly Command[] = [kitVendor, kitStatus, kitSync, kitDoctor]
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { CliError } from '../../errors'
5
+ import { checkCompatibility, KIT } from '../../kit'
6
+ import { resolveBindingRoot } from '../../project-context'
7
+ import { flagString, type CommandContext } from '../types'
8
+
9
+ /**
10
+ * The kit writes into the same root `auth use` binds.
11
+ *
12
+ * One notion of "this project" across authentication and authoring. Two would
13
+ * mean a repository whose profile is selected at the Git root and whose skills
14
+ * land in a subdirectory, which reads as a bug long before anyone finds the two
15
+ * different rules that produced it.
16
+ */
17
+ export function kitRoot(ctx: CommandContext): string {
18
+ return resolveBindingRoot(ctx.cwd, flagString(ctx, 'dir')).root
19
+ }
20
+
21
+ /**
22
+ * The version this CLI reports, which is what compatibility is judged against.
23
+ *
24
+ * A released binary is stamped at build time; a checkout falls back to the
25
+ * manifest. Both are the same number a bug report would name.
26
+ */
27
+ export function cliVersion(): string {
28
+ const stamped = process.env.FRONTERA_CLI_VERSION
29
+ if (stamped) return stamped
30
+ const pkgPath = join(import.meta.dir, '..', '..', '..', 'package.json')
31
+ if (!existsSync(pkgPath)) return '0.0.0'
32
+ return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }).version
33
+ }
34
+
35
+ /**
36
+ * Refuse to write instructions this CLI's own command surface has outgrown.
37
+ *
38
+ * A skill describing commands that moved is worse than no skill: a model acts
39
+ * on it rather than noticing it is stale, and the failure surfaces as a wrong
40
+ * command rather than as a version problem.
41
+ */
42
+ export function requireCompatible(): void {
43
+ const compat = checkCompatibility(cliVersion())
44
+ if (compat.compatible) return
45
+ throw new CliError(
46
+ `authoring kit ${KIT.kitVersion} does not support CLI ${compat.cliVersion} — ${compat.reason}`,
47
+ {
48
+ code: 'KIT_VERSION_MISMATCH',
49
+ hint: `this kit expects a CLI in [${compat.required.minimum}, ${compat.required.maximumExclusive}) — upgrade the CLI, or run \`frontera kit status --json\``,
50
+ },
51
+ )
52
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { checkCompatibility, digest, findDuplicateSources, KIT, LOCK_FILE, readLock, renderKit } from '../../kit'
5
+ import type { Command } from '../types'
6
+ import { cliVersion, kitRoot } from './shared'
7
+
8
+ /**
9
+ * What this repository has, what this CLI carries, and whether they agree.
10
+ *
11
+ * Answerable without writing anything, because the question "is my harness
12
+ * stale" should never require a command that could change the answer.
13
+ */
14
+ export const kitStatus: Command = {
15
+ meta: {
16
+ noun: 'kit',
17
+ verb: 'status',
18
+ args: [],
19
+ flags: { dir: 'string' },
20
+ summary: 'Show the installed and bundled kit versions, local edits and compatibility',
21
+ examples: ['frontera kit status', 'frontera kit status --json'],
22
+ offline: true,
23
+ },
24
+
25
+ async run(ctx) {
26
+ const root = kitRoot(ctx)
27
+ const lock = readLock(root)
28
+ const rendered = renderKit()
29
+ const compat = checkCompatibility(cliVersion())
30
+
31
+ const modified: string[] = []
32
+ const missing: string[] = []
33
+ for (const [rel, content] of Object.entries(rendered.generated)) {
34
+ const path = join(root, rel)
35
+ if (!existsSync(path)) {
36
+ if (lock) missing.push(rel)
37
+ continue
38
+ }
39
+ const current = readFileSync(path, 'utf8')
40
+ if (current === content) continue
41
+ // Modified against the LOCK, not against the bundle: a file matching what
42
+ // we last wrote is out of date, which `sync` fixes, while one matching
43
+ // neither is a local edit, which `sync` must not silently discard.
44
+ if (lock?.files[rel] && digest(current) === lock.files[rel]) continue
45
+ modified.push(rel)
46
+ }
47
+
48
+ const outdated = Boolean(lock && lock.kitVersion !== KIT.kitVersion)
49
+ const duplicates = findDuplicateSources(root)
50
+
51
+ const claudeMd = join(root, 'CLAUDE.md')
52
+ const claudeImportsAgents = existsSync(claudeMd)
53
+ ? readFileSync(claudeMd, 'utf8').split('\n').some((l) => l.trim() === '@AGENTS.md')
54
+ : null
55
+
56
+ const data = {
57
+ root,
58
+ vendored: lock !== null,
59
+ installedKitVersion: lock?.kitVersion ?? null,
60
+ bundledKitVersion: KIT.kitVersion,
61
+ outdated,
62
+ modified,
63
+ missing,
64
+ duplicateSkillSources: duplicates,
65
+ claudeImportsAgents,
66
+ compatibility: compat,
67
+ lock: lock ? LOCK_FILE : null,
68
+ }
69
+
70
+ const lines: string[] = []
71
+ if (!lock) {
72
+ lines.push('Not vendored here — installed plugins are the normal path.')
73
+ lines.push(` bundled kit ${KIT.kitVersion}; run \`frontera kit vendor\` to commit it to this repository`)
74
+ } else {
75
+ lines.push(`kit ${lock.kitVersion} vendored${outdated ? ` — this CLI carries ${KIT.kitVersion}` : ' (current)'}`)
76
+ if (modified.length > 0) lines.push(` ${modified.length} generated file(s) edited locally:`)
77
+ lines.push(...modified.map((f) => ` · ${f}`))
78
+ if (missing.length > 0) lines.push(...missing.map((f) => ` - ${f} (missing)`))
79
+ }
80
+ if (claudeImportsAgents === false) {
81
+ lines.push(' ! CLAUDE.md does not import @AGENTS.md — run `frontera kit vendor` to repair it')
82
+ }
83
+ if (duplicates.length > 0) {
84
+ lines.push(` ! ${duplicates.length} skill(s) resolve from more than one source — run \`frontera kit doctor\``)
85
+ }
86
+ if (!compat.compatible) {
87
+ lines.push(` ! ${compat.reason} — this kit expects [${compat.required.minimum}, ${compat.required.maximumExclusive})`)
88
+ }
89
+
90
+ return { data, text: lines.join('\n') }
91
+ },
92
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { applyClaudeImport, applyManagedBlock, KIT, readLock, renderKit, writeGenerated, writeLock } from '../../kit'
5
+ import { UsageError } from '../../errors'
6
+ import { flagBool, type Command } from '../types'
7
+ import { cliVersion, kitRoot, requireCompatible } from './shared'
8
+
9
+ /**
10
+ * Bring an already-vendored repository up to this CLI's kit.
11
+ *
12
+ * The difference from `vendor` is what it refuses to do: `sync` updates files
13
+ * that are already here and does not adopt a project that never opted in. A
14
+ * command that quietly started managing a repository because someone ran the
15
+ * wrong verb is a command people stop running.
16
+ *
17
+ * An unchanged generated file is updated. A locally edited one is a conflict
18
+ * and needs `--force` — someone changed it for a reason, and finding out by
19
+ * losing it is the worst way to learn that a tool regenerates.
20
+ */
21
+ export const kitSync: Command = {
22
+ meta: {
23
+ noun: 'kit',
24
+ verb: 'sync',
25
+ args: [],
26
+ flags: { force: 'boolean', dir: 'string' },
27
+ summary: 'Update vendored Frontera skills to the kit this CLI carries',
28
+ examples: ['frontera kit sync', 'frontera kit sync --force'],
29
+ offline: true,
30
+ },
31
+
32
+ async run(ctx) {
33
+ requireCompatible()
34
+
35
+ const root = kitRoot(ctx)
36
+ const lock = readLock(root)
37
+ if (!lock) {
38
+ throw new UsageError(
39
+ 'nothing is vendored here',
40
+ 'run `frontera kit vendor` to write the skills into this repository — or use the installed plugins, which need no files at all',
41
+ )
42
+ }
43
+
44
+ const rendered = renderKit()
45
+ const force = flagBool(ctx, 'force')
46
+ const report = writeGenerated(root, rendered.generated, lock, { force, onlyExisting: false })
47
+
48
+ const edited: string[] = []
49
+ const repaired: string[] = []
50
+
51
+ if (rendered.agentsMd !== null) {
52
+ const path = join(root, 'AGENTS.md')
53
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
54
+ const next = applyManagedBlock(existing, rendered.agentsMd)
55
+ if (next !== existing) {
56
+ writeFileSync(path, next)
57
+ edited.push('AGENTS.md')
58
+ }
59
+ }
60
+
61
+ if (rendered.claudeMd !== null) {
62
+ const path = join(root, 'CLAUDE.md')
63
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
64
+ const result = applyClaudeImport(existing, rendered.claudeMd)
65
+ if (result.content !== existing) {
66
+ writeFileSync(path, result.content)
67
+ edited.push('CLAUDE.md')
68
+ }
69
+ if (result.repaired) repaired.push('CLAUDE.md')
70
+ }
71
+
72
+ const locked = Object.fromEntries(
73
+ Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
74
+ )
75
+ writeLock(root, locked, cliVersion())
76
+
77
+ const data = {
78
+ root,
79
+ from: lock.kitVersion,
80
+ to: KIT.kitVersion,
81
+ written: report.written,
82
+ unchanged: report.unchanged,
83
+ conflicts: report.conflicts,
84
+ edited,
85
+ repaired,
86
+ }
87
+
88
+ const lines = [
89
+ `kit ${lock.kitVersion} → ${KIT.kitVersion}`,
90
+ ...report.written.map((f) => ` + ${f}`),
91
+ ...edited.map((f) => ` ~ ${f}`),
92
+ ...repaired.map((f) => ` ! ${f} — replaced the non-importing ./AGENTS.md line with @AGENTS.md`),
93
+ ...report.conflicts.map((f) => ` · ${f} (edited locally — use --force to replace)`),
94
+ ]
95
+ if (report.unchanged.length > 0) lines.push(` = ${report.unchanged.length} file(s) already current`)
96
+
97
+ if (report.conflicts.length > 0 && !force) {
98
+ ctx.output.note(
99
+ `${report.conflicts.length} generated file(s) were edited locally and were left alone — `
100
+ + 'diff them, then re-run with --force to take the kit version',
101
+ )
102
+ }
103
+
104
+ return { data, text: lines.join('\n') }
105
+ },
106
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { CliError } from '../../errors'
5
+ import {
6
+ applyClaudeImport,
7
+ applyManagedBlock,
8
+ KIT,
9
+ LOCK_FILE,
10
+ readLock,
11
+ renderKit,
12
+ writeGenerated,
13
+ writeLock,
14
+ } from '../../kit'
15
+ import { flagBool, type Command } from '../types'
16
+ import { cliVersion, kitRoot, requireCompatible } from './shared'
17
+
18
+ /**
19
+ * Commit the Frontera instructions to a repository.
20
+ *
21
+ * The OPTIONAL path. Installed Codex and Claude plugins are the normal
22
+ * distribution, and a repository needs no generated files for directory-aware
23
+ * authentication or ordinary Frontera authoring. This exists for teams who want
24
+ * the instructions versioned with the code, and for the places a plugin install
25
+ * does not reach: Windows checkouts, archives, remote sandboxes, Codex cloud and
26
+ * Claude cloud.
27
+ *
28
+ * The kit decides which hosts it renders, so there is no `--hosts`. A flag there
29
+ * would let one repository ship half a harness, and the half missing is the half
30
+ * whose absence nobody notices until an agent runs without the contract.
31
+ */
32
+ export const kitVendor: Command = {
33
+ meta: {
34
+ noun: 'kit',
35
+ verb: 'vendor',
36
+ args: [],
37
+ flags: { force: 'boolean', dir: 'string' },
38
+ summary: 'Write the Frontera skills and project contract into this repository',
39
+ examples: ['frontera kit vendor', 'frontera kit vendor --dir ./packages/ops --json'],
40
+ offline: true,
41
+ },
42
+
43
+ async run(ctx) {
44
+ requireCompatible()
45
+
46
+ const root = kitRoot(ctx)
47
+ const rendered = renderKit()
48
+ const lock = readLock(root)
49
+ const force = flagBool(ctx, 'force')
50
+
51
+ const report = writeGenerated(root, rendered.generated, lock, { force })
52
+
53
+ const edited: string[] = []
54
+ const repaired: string[] = []
55
+
56
+ if (rendered.agentsMd !== null) {
57
+ const path = join(root, 'AGENTS.md')
58
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
59
+ const next = applyManagedBlock(existing, rendered.agentsMd)
60
+ if (next !== existing) {
61
+ writeFileSync(path, next)
62
+ edited.push('AGENTS.md')
63
+ }
64
+ }
65
+
66
+ if (rendered.claudeMd !== null) {
67
+ const path = join(root, 'CLAUDE.md')
68
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
69
+ const result = applyClaudeImport(existing, rendered.claudeMd)
70
+ if (result.content !== existing) {
71
+ writeFileSync(path, result.content)
72
+ edited.push('CLAUDE.md')
73
+ }
74
+ if (result.repaired) repaired.push('CLAUDE.md')
75
+ }
76
+
77
+ // The lock records what WAS written. A conflicted file keeps whatever digest
78
+ // it had, so the next run still reports it rather than adopting the local
79
+ // edit as ours.
80
+ const locked = Object.fromEntries(
81
+ Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
82
+ )
83
+ writeLock(root, locked, cliVersion())
84
+
85
+ const data = {
86
+ root,
87
+ kitVersion: KIT.kitVersion,
88
+ hosts: KIT.hosts,
89
+ written: report.written,
90
+ unchanged: report.unchanged,
91
+ conflicts: report.conflicts,
92
+ edited,
93
+ repaired,
94
+ lock: LOCK_FILE,
95
+ }
96
+
97
+ const lines = [
98
+ ...report.written.map((f) => ` + ${f}`),
99
+ ...edited.map((f) => ` ~ ${f}`),
100
+ ...repaired.map((f) => ` ! ${f} — replaced the non-importing ./AGENTS.md line with @AGENTS.md`),
101
+ ...report.conflicts.map((f) => ` · ${f} (edited locally — use --force to replace)`),
102
+ ]
103
+ if (report.unchanged.length > 0) lines.push(` = ${report.unchanged.length} file(s) already current`)
104
+ lines.push(` wrote ${LOCK_FILE} at kit ${KIT.kitVersion}`)
105
+
106
+ if (report.conflicts.length > 0 && !force) {
107
+ // Reported, not thrown: the files that COULD be written were, and stopping
108
+ // the whole command over one edited skill would leave a project half
109
+ // prepared with no record of why.
110
+ ctx.output.note(
111
+ `${report.conflicts.length} generated file(s) were edited locally and were left alone — `
112
+ + 'run `frontera kit sync --force` to replace them',
113
+ )
114
+ }
115
+
116
+ return { data, text: lines.join('\n') }
117
+ },
118
+ }
119
+
120
+ export { CliError }