@frontera-sdk/cli 1.44.0 → 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.
- package/README.md +65 -1
- package/package.json +4 -3
- package/src/api/automation-api.ts +15 -0
- package/src/api/dataset-api.ts +99 -0
- package/src/api/governed-action-api.ts +80 -0
- package/src/api/platform-api.ts +293 -0
- package/src/auth-verify.ts +105 -0
- package/src/binding-registry.ts +87 -0
- package/src/commands/action/deploy.ts +1 -0
- package/src/commands/action/grant.ts +1 -0
- package/src/commands/action/index-commands.ts +8 -0
- package/src/commands/action/prepare.ts +1 -0
- package/src/commands/action/requests.ts +111 -0
- package/src/commands/action/review.ts +1 -0
- package/src/commands/agent/index-commands.ts +189 -7
- package/src/commands/app/init.ts +1 -1
- package/src/commands/app/pull.ts +1 -1
- package/src/commands/auth/add.ts +145 -0
- package/src/commands/auth/current.ts +82 -0
- package/src/commands/auth/index-commands.ts +16 -0
- package/src/commands/auth/list.ts +71 -0
- package/src/commands/auth/remove.ts +80 -0
- package/src/commands/auth/use.ts +84 -0
- package/src/commands/auth/verify.ts +93 -0
- package/src/commands/automation/run.ts +41 -2
- package/src/commands/blueprint/query.ts +294 -0
- package/src/commands/capability/index-commands.ts +334 -0
- package/src/commands/dataset/index-commands.ts +103 -14
- package/src/commands/kit/doctor.ts +101 -0
- package/src/commands/kit/index-commands.ts +7 -0
- package/src/commands/kit/shared.ts +52 -0
- package/src/commands/kit/status.ts +92 -0
- package/src/commands/kit/sync.ts +106 -0
- package/src/commands/kit/vendor.ts +120 -0
- package/src/commands/knowledge/index-commands.ts +165 -0
- package/src/commands/login.ts +64 -84
- package/src/commands/plugin/index-commands.ts +284 -21
- package/src/commands/registry.ts +104 -1
- package/src/commands/setup.ts +248 -0
- package/src/commands/source/index-commands.ts +446 -0
- package/src/commands/types.ts +14 -0
- package/src/config.ts +197 -100
- package/src/credential-store.ts +273 -0
- package/src/dev-env.ts +3 -3
- package/src/exit.ts +29 -2
- package/src/flag-help.ts +65 -3
- package/src/fs-atomic.ts +44 -0
- package/src/harness.ts +155 -4
- package/src/kit.ts +419 -0
- package/src/main.ts +13 -1
- package/src/paths.ts +43 -0
- package/src/profile-migration.ts +101 -0
- package/src/profiles.ts +240 -0
- package/src/project-context.ts +178 -0
- package/src/prompt.ts +23 -0
- package/src/templates/next-app-files.ts +4 -1
- package/src/vendor/kit-assets.json +31 -0
- 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
|
|
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
|
|
149
|
-
const
|
|
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 }
|