@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
package/src/kit.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve as resolvePath } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { CliError } from './errors'
|
|
7
|
+
import vendored from './vendor/kit-assets.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The authoring kit, as the CLI carries it.
|
|
11
|
+
*
|
|
12
|
+
* The bytes are pinned at build time (`scripts/sync-kit.ts`), so vendoring is
|
|
13
|
+
* offline and deterministic: the same CLI release writes the same instructions
|
|
14
|
+
* on any machine, on any day, with no network. That is what makes a lock file
|
|
15
|
+
* over them mean anything, and it is why upgrading a project's instructions is
|
|
16
|
+
* a CLI upgrade rather than a silent fetch at session start.
|
|
17
|
+
*/
|
|
18
|
+
export interface KitAssets {
|
|
19
|
+
kitVersion: string
|
|
20
|
+
cli: { minimum: string; maximumExclusive: string }
|
|
21
|
+
hosts: { codex: boolean; claudeCode: boolean }
|
|
22
|
+
assets: Record<string, string>
|
|
23
|
+
agentsBlock: string
|
|
24
|
+
claudeBlock: string
|
|
25
|
+
pluginManifests: { claudeCode: string; codex: string }
|
|
26
|
+
marketplaceManifests: { claudeCode: string; codex: string }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const KIT: KitAssets = vendored as KitAssets
|
|
30
|
+
|
|
31
|
+
export const LOCK_FILE = 'frontera.kit.lock.json'
|
|
32
|
+
|
|
33
|
+
export const MANAGED_BEGIN = '<!-- frontera:begin — managed by `frontera kit`; edits here are replaced -->'
|
|
34
|
+
export const MANAGED_END = '<!-- frontera:end -->'
|
|
35
|
+
|
|
36
|
+
export interface KitLock {
|
|
37
|
+
schemaVersion: 1
|
|
38
|
+
kitVersion: string
|
|
39
|
+
cliVersion: string
|
|
40
|
+
/** Project-relative path → digest of the bytes this CLI wrote. */
|
|
41
|
+
files: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function digest(content: string): string {
|
|
45
|
+
return `sha256:${createHash('sha256').update(content).digest('hex').slice(0, 16)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The managed block, delimited and bounded.
|
|
50
|
+
*
|
|
51
|
+
* Whole-file generation is what makes a tool unwelcome in a repository someone
|
|
52
|
+
* else owns: a team's AGENTS.md carries their conventions, and replacing it to
|
|
53
|
+
* add five lines is not a trade they agreed to. Only what sits between the
|
|
54
|
+
* markers is ours.
|
|
55
|
+
*/
|
|
56
|
+
export function managedBlock(body: string): string {
|
|
57
|
+
return `${MANAGED_BEGIN}\n${body.trim()}\n${MANAGED_END}\n`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Insert or replace the managed block, leaving every other line where it is. */
|
|
61
|
+
export function applyManagedBlock(existing: string | null, body: string): string {
|
|
62
|
+
const block = managedBlock(body)
|
|
63
|
+
if (existing === null || existing.trim() === '') return block
|
|
64
|
+
|
|
65
|
+
const begin = existing.indexOf(MANAGED_BEGIN)
|
|
66
|
+
const end = existing.indexOf(MANAGED_END)
|
|
67
|
+
if (begin !== -1 && end > begin) {
|
|
68
|
+
const before = existing.slice(0, begin)
|
|
69
|
+
const after = existing.slice(end + MANAGED_END.length).replace(/^\n/, '')
|
|
70
|
+
return `${before}${block}${after}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Appended, not prepended. The first lines of an AGENTS.md are where a team
|
|
74
|
+
// puts what matters most to them, and a tool that inserts itself above that
|
|
75
|
+
// has decided its own instructions outrank theirs.
|
|
76
|
+
const separator = existing.endsWith('\n') ? '\n' : '\n\n'
|
|
77
|
+
return `${existing}${separator}${block}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Claude Code imports with `@AGENTS.md`.
|
|
82
|
+
*
|
|
83
|
+
* `./AGENTS.md` is a relative path Claude does not import — it reads as prose,
|
|
84
|
+
* so the contract silently never loads. Generated projects shipped exactly that
|
|
85
|
+
* line, which is why this repairs it rather than only writing it correctly for
|
|
86
|
+
* new projects.
|
|
87
|
+
*/
|
|
88
|
+
export function applyClaudeImport(existing: string | null, importLine: string): { content: string; repaired: boolean } {
|
|
89
|
+
const line = importLine.trim()
|
|
90
|
+
if (existing === null || existing.trim() === '') return { content: `${line}\n`, repaired: false }
|
|
91
|
+
|
|
92
|
+
const lines = existing.split('\n')
|
|
93
|
+
const broken = lines.findIndex((l) => l.trim() === './AGENTS.md' || l.trim() === '@./AGENTS.md')
|
|
94
|
+
if (broken !== -1) {
|
|
95
|
+
lines[broken] = line
|
|
96
|
+
return { content: lines.join('\n'), repaired: true }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (lines.some((l) => l.trim() === line)) return { content: existing, repaired: false }
|
|
100
|
+
|
|
101
|
+
// An import has to be reachable, so it goes first — unlike the AGENTS.md
|
|
102
|
+
// block, this single line is the mechanism rather than instructions competing
|
|
103
|
+
// for a reader's attention.
|
|
104
|
+
return { content: `${line}\n\n${existing.replace(/^\n+/, '')}`, repaired: false }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RenderedProject {
|
|
108
|
+
/** Generated files: fully owned by the kit, replaceable, digest-tracked. */
|
|
109
|
+
generated: Record<string, string>
|
|
110
|
+
/** Files the kit edits in place without owning. */
|
|
111
|
+
agentsMd: string | null
|
|
112
|
+
claudeMd: string | null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One source, two native projections — Codex reads neither host's other tree. */
|
|
116
|
+
export function renderKit(kit: KitAssets = KIT): RenderedProject {
|
|
117
|
+
const generated: Record<string, string> = {}
|
|
118
|
+
for (const rel of Object.keys(kit.assets).sort()) {
|
|
119
|
+
const content = kit.assets[rel]!
|
|
120
|
+
if (kit.hosts.codex) generated[join('.agents', 'skills', rel)] = content
|
|
121
|
+
if (kit.hosts.claudeCode) generated[join('.claude', 'skills', rel)] = content
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
generated,
|
|
125
|
+
agentsMd: kit.hosts.codex ? kit.agentsBlock : null,
|
|
126
|
+
claudeMd: kit.hosts.claudeCode ? kit.claudeBlock : null,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Write a complete, installable plugin marketplace to disk.
|
|
132
|
+
*
|
|
133
|
+
* This is what makes distribution possible without anything public. Both hosts
|
|
134
|
+
* accept a local DIRECTORY as a marketplace, and the CLI already carries every
|
|
135
|
+
* byte of the kit pinned at build time — so `frontera setup` can lay out a real
|
|
136
|
+
* marketplace on a machine with no checkout, no git remote and no network, and
|
|
137
|
+
* point the host at it. No repository to publish, no directory to submit to,
|
|
138
|
+
* and no customer's plugin tree leaving their machine.
|
|
139
|
+
*
|
|
140
|
+
* The layout mirrors the authoring repository exactly, because that is what the
|
|
141
|
+
* hosts validate against:
|
|
142
|
+
*
|
|
143
|
+
* <root>/.claude-plugin/marketplace.json Claude Code reads this
|
|
144
|
+
* <root>/.agents/plugins/marketplace.json Codex prefers this
|
|
145
|
+
* <root>/plugin/.claude-plugin/plugin.json
|
|
146
|
+
* <root>/plugin/.codex-plugin/plugin.json
|
|
147
|
+
* <root>/plugin/skills/<name>/SKILL.md
|
|
148
|
+
*
|
|
149
|
+
* Fully rewritten each time: it is generated data with a version stamp, not
|
|
150
|
+
* somewhere anyone should be editing.
|
|
151
|
+
*/
|
|
152
|
+
export function materializeMarketplace(root: string, kit: KitAssets = KIT): string[] {
|
|
153
|
+
const files: Record<string, string> = {
|
|
154
|
+
[join('.claude-plugin', 'marketplace.json')]: kit.marketplaceManifests.claudeCode,
|
|
155
|
+
[join('.agents', 'plugins', 'marketplace.json')]: kit.marketplaceManifests.codex,
|
|
156
|
+
[join('plugin', '.claude-plugin', 'plugin.json')]: kit.pluginManifests.claudeCode,
|
|
157
|
+
[join('plugin', '.codex-plugin', 'plugin.json')]: kit.pluginManifests.codex,
|
|
158
|
+
}
|
|
159
|
+
for (const [rel, content] of Object.entries(kit.assets)) {
|
|
160
|
+
files[join('plugin', 'skills', rel)] = content
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A stamp, so a later `setup` can tell whether the tree on disk is this
|
|
164
|
+
// CLI's kit without diffing every file.
|
|
165
|
+
files['frontera-kit.json'] = `${JSON.stringify({ kitVersion: kit.kitVersion, materializedBy: 'frontera setup' }, null, 2)}\n`
|
|
166
|
+
|
|
167
|
+
const written: string[] = []
|
|
168
|
+
for (const [rel, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
169
|
+
const dest = join(root, rel)
|
|
170
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
171
|
+
writeFileSync(dest, content)
|
|
172
|
+
written.push(rel)
|
|
173
|
+
}
|
|
174
|
+
return written
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The kit version already materialized at `root`, if any. */
|
|
178
|
+
export function materializedVersion(root: string): string | null {
|
|
179
|
+
const stamp = join(root, 'frontera-kit.json')
|
|
180
|
+
if (!existsSync(stamp)) return null
|
|
181
|
+
try {
|
|
182
|
+
return (JSON.parse(readFileSync(stamp, 'utf8')) as { kitVersion?: string }).kitVersion ?? null
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readLock(root: string): KitLock | null {
|
|
189
|
+
const path = join(root, LOCK_FILE)
|
|
190
|
+
if (!existsSync(path)) return null
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(readFileSync(path, 'utf8')) as KitLock
|
|
193
|
+
} catch {
|
|
194
|
+
throw new CliError(`${LOCK_FILE} is not readable JSON`, {
|
|
195
|
+
code: 'GENERATED_FILE_CONFLICT',
|
|
196
|
+
hint: `delete ${LOCK_FILE} and run \`frontera kit vendor\` again`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface WriteReport {
|
|
202
|
+
written: string[]
|
|
203
|
+
unchanged: string[]
|
|
204
|
+
conflicts: string[]
|
|
205
|
+
repaired: string[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write the generated tree.
|
|
210
|
+
*
|
|
211
|
+
* The rule that keeps this safe to re-run: a file is replaced only when its
|
|
212
|
+
* current bytes are the bytes this CLI last wrote. Anything else is a local
|
|
213
|
+
* edit, which is reported as a conflict rather than silently discarded —
|
|
214
|
+
* someone changed a skill for a reason, and finding out by losing it is the
|
|
215
|
+
* worst way to learn the tool regenerates.
|
|
216
|
+
*/
|
|
217
|
+
export function writeGenerated(
|
|
218
|
+
root: string,
|
|
219
|
+
generated: Record<string, string>,
|
|
220
|
+
lock: KitLock | null,
|
|
221
|
+
opts: { force?: boolean; onlyExisting?: boolean } = {},
|
|
222
|
+
): WriteReport {
|
|
223
|
+
const report: WriteReport = { written: [], unchanged: [], conflicts: [], repaired: [] }
|
|
224
|
+
|
|
225
|
+
for (const [rel, content] of Object.entries(generated)) {
|
|
226
|
+
const dest = join(root, rel)
|
|
227
|
+
const exists = existsSync(dest)
|
|
228
|
+
|
|
229
|
+
if (!exists) {
|
|
230
|
+
// `sync` updates what vendoring already put here; it does not adopt a
|
|
231
|
+
// project that never opted in.
|
|
232
|
+
if (opts.onlyExisting) continue
|
|
233
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
234
|
+
writeFileSync(dest, content)
|
|
235
|
+
report.written.push(rel)
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const current = readFileSync(dest, 'utf8')
|
|
240
|
+
if (current === content) {
|
|
241
|
+
report.unchanged.push(rel)
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const recorded = lock?.files[rel]
|
|
246
|
+
if (opts.force || (recorded && digest(current) === recorded)) {
|
|
247
|
+
writeFileSync(dest, content)
|
|
248
|
+
report.written.push(rel)
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
report.conflicts.push(rel)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return report
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function writeLock(root: string, generated: Record<string, string>, cliVersion: string): KitLock {
|
|
259
|
+
const lock: KitLock = {
|
|
260
|
+
schemaVersion: 1,
|
|
261
|
+
kitVersion: KIT.kitVersion,
|
|
262
|
+
cliVersion,
|
|
263
|
+
files: Object.fromEntries(Object.entries(generated).map(([rel, content]) => [rel, digest(content)])),
|
|
264
|
+
}
|
|
265
|
+
writeFileSync(join(root, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`)
|
|
266
|
+
return lock
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `1.45.0-dev` and `1.45.0` compare equal — the prerelease tail is noise here. */
|
|
270
|
+
export function compareVersions(a: string, b: string): number {
|
|
271
|
+
const parse = (v: string) => v.split('-')[0]!.split('.').map((n) => Number.parseInt(n, 10) || 0)
|
|
272
|
+
const [x, y] = [parse(a), parse(b)]
|
|
273
|
+
for (let i = 0; i < 3; i += 1) {
|
|
274
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0)
|
|
275
|
+
}
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface Compatibility {
|
|
280
|
+
compatible: boolean
|
|
281
|
+
cliVersion: string
|
|
282
|
+
required: { minimum: string; maximumExclusive: string }
|
|
283
|
+
reason?: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function checkCompatibility(cliVersion: string, kit: KitAssets = KIT): Compatibility {
|
|
287
|
+
const required = kit.cli
|
|
288
|
+
if (compareVersions(cliVersion, required.minimum) < 0) {
|
|
289
|
+
return {
|
|
290
|
+
compatible: false,
|
|
291
|
+
cliVersion,
|
|
292
|
+
required,
|
|
293
|
+
reason: `this CLI is older than the kit's minimum ${required.minimum}`,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (compareVersions(cliVersion, required.maximumExclusive) >= 0) {
|
|
297
|
+
return {
|
|
298
|
+
compatible: false,
|
|
299
|
+
cliVersion,
|
|
300
|
+
required,
|
|
301
|
+
reason: `this CLI is at or past the kit's ${required.maximumExclusive} boundary`,
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { compatible: true, cliVersion, required }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type SkillHost = 'codex' | 'claudeCode'
|
|
308
|
+
|
|
309
|
+
export interface DuplicateSource {
|
|
310
|
+
skill: string
|
|
311
|
+
host: SkillHost
|
|
312
|
+
sources: string[]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Every `<dir>/<skill>/SKILL.md` under one scope, for the skills this kit ships. */
|
|
316
|
+
function skillsUnder(dir: string): string[] {
|
|
317
|
+
if (!existsSync(dir)) return []
|
|
318
|
+
const shipped = new Set(Object.keys(KIT.assets).map((rel) => rel.split('/')[0]!))
|
|
319
|
+
const found: string[] = []
|
|
320
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
321
|
+
if (!entry.isDirectory()) continue
|
|
322
|
+
// A project's own skills are not duplicates of anything of ours.
|
|
323
|
+
if (!shipped.has(entry.name)) continue
|
|
324
|
+
if (!existsSync(join(dir, entry.name, 'SKILL.md'))) continue
|
|
325
|
+
found.push(entry.name)
|
|
326
|
+
}
|
|
327
|
+
return found
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Every installed plugin's `skills` directory under `<home>/.<host>/plugins`.
|
|
332
|
+
*
|
|
333
|
+
* Searched rather than constructed. Both hosts currently nest an install as
|
|
334
|
+
* `plugins/cache/<marketplace>/<plugin>/<version>/skills`, which no hand-written
|
|
335
|
+
* path guessed correctly — and a diagnostic that silently finds nothing because
|
|
336
|
+
* a vendor moved a directory is worse than no diagnostic, since it reports
|
|
337
|
+
* "one source per skill" either way. The depth cap keeps it from walking a
|
|
338
|
+
* plugin's own contents.
|
|
339
|
+
*/
|
|
340
|
+
function pluginScopes(home: string, host: 'codex' | 'claude'): string[] {
|
|
341
|
+
const root = join(home, `.${host}`, 'plugins')
|
|
342
|
+
const out: string[] = []
|
|
343
|
+
|
|
344
|
+
const walk = (dir: string, depth: number): void => {
|
|
345
|
+
if (depth > 5) return
|
|
346
|
+
let entries
|
|
347
|
+
try {
|
|
348
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
349
|
+
} catch {
|
|
350
|
+
// An unreadable directory is not a finding — this is a diagnostic, and
|
|
351
|
+
// failing it on a permissions quirk would help nobody.
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
if (!entry.isDirectory()) continue
|
|
356
|
+
if (entry.name === 'skills') {
|
|
357
|
+
out.push(join(dir, entry.name))
|
|
358
|
+
// Do not descend: everything below is one plugin's own skill bodies.
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
walk(join(dir, entry.name), depth + 1)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (existsSync(root)) walk(root, 0)
|
|
366
|
+
return out
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The same skill reachable twice — WITHIN one host.
|
|
371
|
+
*
|
|
372
|
+
* Grouped per host, because `.agents/skills` and `.claude/skills` in one project
|
|
373
|
+
* are the two required projections rather than a duplication: Codex reads only
|
|
374
|
+
* the first and Claude Code only the second, and reporting them against each
|
|
375
|
+
* other would flag every correctly vendored repository.
|
|
376
|
+
*
|
|
377
|
+
* The condition that does matter is an installed plugin AND a vendored copy in
|
|
378
|
+
* the same host. Codex does not merge equal skill names across scopes, so both
|
|
379
|
+
* appear, and a model choosing between two identical-looking skills is making a
|
|
380
|
+
* coin flip nobody asked it to make. Claude namespaces plugin skills, but the
|
|
381
|
+
* recommendation is the same for both hosts so behaviour does not depend on
|
|
382
|
+
* which one the person happens to be in.
|
|
383
|
+
*
|
|
384
|
+
* Evidence-based: it reports the paths it actually found, so a wrong diagnosis
|
|
385
|
+
* is visible rather than asserted.
|
|
386
|
+
*/
|
|
387
|
+
export function findDuplicateSources(root: string, home = homedir()): DuplicateSource[] {
|
|
388
|
+
const scopes: Array<[SkillHost, string, string]> = [
|
|
389
|
+
['codex', 'vendored', join(root, '.agents', 'skills')],
|
|
390
|
+
['codex', 'user skills', join(home, '.agents', 'skills')],
|
|
391
|
+
...pluginScopes(home, 'codex').map((dir) => ['codex', 'codex plugin', dir] as [SkillHost, string, string]),
|
|
392
|
+
['claudeCode', 'vendored', join(root, '.claude', 'skills')],
|
|
393
|
+
...pluginScopes(home, 'claude').map((dir) => ['claudeCode', 'claude plugin', dir] as [SkillHost, string, string]),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
const seen = new Map<string, string[]>()
|
|
397
|
+
// Resolved, because $HOME and the project root can be the same directory
|
|
398
|
+
// reached by two paths — and a scope counted twice is a duplicate that is not
|
|
399
|
+
// there.
|
|
400
|
+
const visited = new Set<string>()
|
|
401
|
+
|
|
402
|
+
for (const [host, label, dir] of scopes) {
|
|
403
|
+
const key = `${host}:${resolvePath(dir)}`
|
|
404
|
+
if (visited.has(key)) continue
|
|
405
|
+
visited.add(key)
|
|
406
|
+
for (const skill of skillsUnder(dir)) {
|
|
407
|
+
const at = `${host}:${skill}`
|
|
408
|
+
seen.set(at, [...(seen.get(at) ?? []), `${label}: ${join(dir, skill)}`])
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return [...seen.entries()]
|
|
413
|
+
.filter(([, sources]) => sources.length > 1)
|
|
414
|
+
.map(([at, sources]) => {
|
|
415
|
+
const [host, skill] = at.split(':') as [SkillHost, string]
|
|
416
|
+
return { skill, host, sources }
|
|
417
|
+
})
|
|
418
|
+
.sort((a, b) => a.skill.localeCompare(b.skill) || a.host.localeCompare(b.host))
|
|
419
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -168,9 +168,21 @@ async function main(): Promise<number> {
|
|
|
168
168
|
|
|
169
169
|
// Offline commands scaffold before a credential exists, so resolving one
|
|
170
170
|
// would make `frontera init` impossible on a fresh machine.
|
|
171
|
+
//
|
|
172
|
+
// The resolver is given `cwd` — the SAME directory `--dir` already
|
|
173
|
+
// redirects project discovery to — so a profile selection and an app
|
|
174
|
+
// project are always read from one place. Two directories would mean
|
|
175
|
+
// `--dir ../other-customer` could edit one customer's app with another
|
|
176
|
+
// customer's key.
|
|
171
177
|
const credential = command.meta.offline
|
|
172
178
|
? { apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : (process.env.FRONTERA_API_URL ?? ''), token: '' }
|
|
173
|
-
: resolveCredential(
|
|
179
|
+
: await resolveCredential(
|
|
180
|
+
{
|
|
181
|
+
apiUrl: typeof flags['api-url'] === 'string' ? flags['api-url'] : undefined,
|
|
182
|
+
profile: typeof flags.profile === 'string' ? flags.profile : undefined,
|
|
183
|
+
},
|
|
184
|
+
{ cwd },
|
|
185
|
+
)
|
|
174
186
|
|
|
175
187
|
const result = await command.run({
|
|
176
188
|
cwd,
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export type Env = Record<string, string | undefined>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* XDG, not `~/.frontera`.
|
|
9
|
+
*
|
|
10
|
+
* The spec assigns credentials to `$XDG_CONFIG_HOME` and disposable data to
|
|
11
|
+
* `$XDG_CACHE_HOME`; `gh` follows it, and Supabase's `~/.supabase` is its
|
|
12
|
+
* fallback rather than its design. A bare dotfile directory is the shape
|
|
13
|
+
* nobody chose deliberately.
|
|
14
|
+
*/
|
|
15
|
+
export function configDir(env: Env = process.env): string {
|
|
16
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config')
|
|
17
|
+
return join(base, 'frontera')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function configPath(env: Env = process.env): string {
|
|
21
|
+
return join(configDir(env), 'config.json')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Durable, non-secret data the CLI owns — as distinct from configuration the
|
|
26
|
+
* user edits and cache anything may delete.
|
|
27
|
+
*
|
|
28
|
+
* `frontera setup` materializes an installable plugin here, so a coding host
|
|
29
|
+
* can be pointed at a real marketplace on a machine with no checkout, no git
|
|
30
|
+
* remote and no network. Deleting it costs one `frontera setup` re-run, which
|
|
31
|
+
* is the correct blast radius for data neither secret nor authored.
|
|
32
|
+
*/
|
|
33
|
+
export function dataDir(env: Env = process.env): string {
|
|
34
|
+
const base = env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share')
|
|
35
|
+
return join(base, 'frontera')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Disposable, keyed by API origin because the registry is per-deployment. */
|
|
39
|
+
export function cacheDir(apiUrl: string, env: Env = process.env): string {
|
|
40
|
+
const base = env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache')
|
|
41
|
+
const key = createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
|
|
42
|
+
return join(base, 'frontera', key)
|
|
43
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { credentialStore } from './credential-store'
|
|
2
|
+
import { CliError } from './errors'
|
|
3
|
+
import { configPath, type Env } from './paths'
|
|
4
|
+
import {
|
|
5
|
+
fingerprint,
|
|
6
|
+
nameFromOrigin,
|
|
7
|
+
readLegacyConfig,
|
|
8
|
+
writeProfileConfig,
|
|
9
|
+
type ProfileConfig,
|
|
10
|
+
type ProfileMetadata,
|
|
11
|
+
} from './profiles'
|
|
12
|
+
|
|
13
|
+
export interface MigrationResult {
|
|
14
|
+
migrated: boolean
|
|
15
|
+
profiles: Array<{ name: string; apiUrl: string }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Version 1 → version 2, once, without ever holding the only copy of a key.
|
|
20
|
+
*
|
|
21
|
+
* The order matters more than the code does. Every secret is written to the
|
|
22
|
+
* new profile-addressed store and READ BACK before the legacy config is
|
|
23
|
+
* rewritten, so a failure at any step leaves the old file intact and the old
|
|
24
|
+
* credentials recoverable. The alternative — rewrite the metadata first,
|
|
25
|
+
* migrate the secrets after — turns one bad keychain prompt into a machine
|
|
26
|
+
* with no working credential and no record of what it used to have.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent: a config already at version 2 returns immediately, and a partial
|
|
29
|
+
* run simply re-copies what it already copied.
|
|
30
|
+
*/
|
|
31
|
+
export async function migrateLegacyConfig(env: Env = process.env): Promise<MigrationResult> {
|
|
32
|
+
const legacy = readLegacyConfig(env)
|
|
33
|
+
if (!legacy) return { migrated: false, profiles: [] }
|
|
34
|
+
|
|
35
|
+
const origins = Object.entries(legacy.origins ?? {})
|
|
36
|
+
.filter((entry): entry is [string, { token: string }] => typeof entry[1]?.token === 'string' && entry[1].token.length > 0)
|
|
37
|
+
|
|
38
|
+
if (origins.length === 0) {
|
|
39
|
+
// Nothing recoverable. Still stamp version 2 so the legacy shape stops
|
|
40
|
+
// being re-read on every command.
|
|
41
|
+
writeProfileConfig({ schemaVersion: 2, profiles: {} }, env)
|
|
42
|
+
return { migrated: true, profiles: [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The legacy secrets are already plaintext in this very file, so relocating
|
|
46
|
+
// them into a 0600 store is not a new disclosure — and refusing on a machine
|
|
47
|
+
// without a keychain would strand the only copy.
|
|
48
|
+
const store = credentialStore(env, { allowPlaintext: true })
|
|
49
|
+
|
|
50
|
+
const taken = new Set<string>()
|
|
51
|
+
const planned: Array<{ name: string; apiUrl: string; token: string }> = []
|
|
52
|
+
|
|
53
|
+
// The previous default becomes `default`, so the first command after
|
|
54
|
+
// upgrading behaves exactly as it did before.
|
|
55
|
+
const defaultOrigin = legacy.defaultOrigin
|
|
56
|
+
if (defaultOrigin && legacy.origins?.[defaultOrigin]?.token) {
|
|
57
|
+
taken.add('default')
|
|
58
|
+
planned.push({ name: 'default', apiUrl: defaultOrigin, token: legacy.origins[defaultOrigin]!.token! })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [apiUrl, entry] of origins) {
|
|
62
|
+
if (apiUrl === defaultOrigin && taken.has('default')) continue
|
|
63
|
+
const name = nameFromOrigin(apiUrl, taken)
|
|
64
|
+
taken.add(name)
|
|
65
|
+
planned.push({ name, apiUrl, token: entry.token })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = new Date().toISOString()
|
|
69
|
+
const profiles: Record<string, ProfileMetadata> = {}
|
|
70
|
+
|
|
71
|
+
for (const { name, apiUrl, token } of planned) {
|
|
72
|
+
await store.set(name, token)
|
|
73
|
+
const readBack = await store.get(name)
|
|
74
|
+
if (readBack !== token) {
|
|
75
|
+
throw new CliError(`could not verify the migrated key for "${name}"`, {
|
|
76
|
+
code: 'SECURE_STORE_UNAVAILABLE',
|
|
77
|
+
hint:
|
|
78
|
+
`your existing credentials are untouched at ${configPath(env)} — `
|
|
79
|
+
+ 'retry, or set FRONTERA_SECRET_STORE=file and run any command again',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
profiles[name] = {
|
|
83
|
+
apiUrl,
|
|
84
|
+
// The legacy file recorded no kind. The prefix is the only evidence, and
|
|
85
|
+
// `auth verify` refreshes the rest on demand.
|
|
86
|
+
credentialKind: token.startsWith('sk-org-') ? 'organization' : 'workspace',
|
|
87
|
+
workspaceId: null,
|
|
88
|
+
orgId: null,
|
|
89
|
+
fingerprint: fingerprint(token),
|
|
90
|
+
createdAt: now,
|
|
91
|
+
lastVerifiedAt: now,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Only now: every secret is stored AND verified readable, so dropping the
|
|
96
|
+
// plaintext tokens cannot lose one.
|
|
97
|
+
const next: ProfileConfig = { schemaVersion: 2, profiles }
|
|
98
|
+
writeProfileConfig(next, env)
|
|
99
|
+
|
|
100
|
+
return { migrated: true, profiles: planned.map(({ name, apiUrl }) => ({ name, apiUrl })) }
|
|
101
|
+
}
|