@frontera-sdk/cli 0.1.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/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { CliError, UsageError } from './errors'
|
|
8
|
+
|
|
9
|
+
export interface Credential {
|
|
10
|
+
apiUrl: string
|
|
11
|
+
token: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Env = Record<string, string | undefined>
|
|
15
|
+
|
|
16
|
+
interface StoredConfig {
|
|
17
|
+
/**
|
|
18
|
+
* The origin `frontera login` last verified.
|
|
19
|
+
*
|
|
20
|
+
* Without it, logging in successfully still left every following command
|
|
21
|
+
* failing on "no API origin configured" — the credential was stored under a
|
|
22
|
+
* key nothing knew to look up. Storing the origin is what makes `login` a
|
|
23
|
+
* complete act rather than half of one.
|
|
24
|
+
*/
|
|
25
|
+
defaultOrigin?: string
|
|
26
|
+
origins?: Record<string, { token?: string }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const KEYCHAIN_SERVICE = 'frontera-cli'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* XDG, not `~/.frontera`.
|
|
33
|
+
*
|
|
34
|
+
* The spec assigns credentials to `$XDG_CONFIG_HOME` and disposable data to
|
|
35
|
+
* `$XDG_CACHE_HOME`; `gh` follows it, and Supabase's `~/.supabase` is its
|
|
36
|
+
* fallback rather than its design. A bare dotfile directory is the shape
|
|
37
|
+
* nobody chose deliberately.
|
|
38
|
+
*/
|
|
39
|
+
export function configPath(env: Env = process.env): string {
|
|
40
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config')
|
|
41
|
+
return join(base, 'frontera', 'config.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Disposable, keyed by API origin because the registry is per-deployment. */
|
|
45
|
+
export function cacheDir(apiUrl: string, env: Env = process.env): string {
|
|
46
|
+
const base = env.XDG_CACHE_HOME || join(env.HOME || homedir(), '.cache')
|
|
47
|
+
const key = createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
|
|
48
|
+
return join(base, 'frontera', key)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readStored(env: Env): StoredConfig {
|
|
52
|
+
const path = configPath(env)
|
|
53
|
+
if (!existsSync(path)) return {}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(path, 'utf8')) as StoredConfig
|
|
56
|
+
} catch {
|
|
57
|
+
// A corrupt config must not brick every command — treat it as absent and
|
|
58
|
+
// let the missing-credential error say what to do.
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* macOS keychain read. Returns null on any failure, including "not macOS" and
|
|
65
|
+
* "no keychain in this sandbox" — both ordinary, since the file fallback is
|
|
66
|
+
* the hot path in CI and inside the Computer.
|
|
67
|
+
*/
|
|
68
|
+
function readKeychain(apiUrl: string): string | null {
|
|
69
|
+
try {
|
|
70
|
+
const res = spawnSync(
|
|
71
|
+
'security',
|
|
72
|
+
['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', apiUrl, '-w'],
|
|
73
|
+
{ encoding: 'utf8' },
|
|
74
|
+
)
|
|
75
|
+
if (res.status !== 0) return null
|
|
76
|
+
const value = res.stdout.trim()
|
|
77
|
+
return value || null
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CredentialDeps {
|
|
84
|
+
env?: Env
|
|
85
|
+
keychain?: (apiUrl: string) => string | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the API origin and token.
|
|
90
|
+
*
|
|
91
|
+
* Order: `--api-url` → environment → OS keychain → XDG config file. The
|
|
92
|
+
* environment keeps precedence over stored credentials so that adding storage
|
|
93
|
+
* changes nothing for the Computer or CI, both of which set the variable.
|
|
94
|
+
*
|
|
95
|
+
* The token is never read from a flag. A secret in a flag lands in process
|
|
96
|
+
* listings and shell history, and a CLI that offers the option will have it
|
|
97
|
+
* used.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveCredential(
|
|
100
|
+
opts: { apiUrl?: string } = {},
|
|
101
|
+
deps: CredentialDeps = {},
|
|
102
|
+
): Credential {
|
|
103
|
+
const env = deps.env ?? process.env
|
|
104
|
+
const keychain = deps.keychain ?? readKeychain
|
|
105
|
+
|
|
106
|
+
const stored = readStored(env)
|
|
107
|
+
const apiUrl = opts.apiUrl || env.FRONTERA_API_URL || stored.defaultOrigin
|
|
108
|
+
if (!apiUrl) {
|
|
109
|
+
throw new UsageError(
|
|
110
|
+
'no API origin configured',
|
|
111
|
+
'run `frontera login --api-url <origin>`, set FRONTERA_API_URL, or pass --api-url',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const token = env.FRONTERA_TOKEN || keychain(apiUrl) || stored.origins?.[apiUrl]?.token
|
|
116
|
+
|
|
117
|
+
if (!token) {
|
|
118
|
+
throw new CliError(`no credential for ${apiUrl}`, {
|
|
119
|
+
code: 'UNAUTHORIZED',
|
|
120
|
+
hint: `set FRONTERA_TOKEN to an sk-ws- workspace key, or store one at ${configPath(env)}`,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { apiUrl, token }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Persist a token for one origin, leaving any other origin's untouched. */
|
|
128
|
+
export function writeStoredToken(apiUrl: string, token: string, env: Env = process.env): void {
|
|
129
|
+
const path = configPath(env)
|
|
130
|
+
const current = readStored(env)
|
|
131
|
+
const next: StoredConfig = {
|
|
132
|
+
...current,
|
|
133
|
+
// The most recent successful login becomes the default, so the next
|
|
134
|
+
// command needs no environment at all.
|
|
135
|
+
defaultOrigin: apiUrl,
|
|
136
|
+
origins: { ...current.origins, [apiUrl]: { token } },
|
|
137
|
+
}
|
|
138
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
139
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`)
|
|
140
|
+
// 0600 because this holds a key with full workspace write.
|
|
141
|
+
chmodSync(path, 0o600)
|
|
142
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { UsageError } from './errors'
|
|
5
|
+
import { readProject, type ProjectConfig } from './project'
|
|
6
|
+
|
|
7
|
+
export interface AppProject extends ProjectConfig {
|
|
8
|
+
/** Absolute path of the directory holding package.json. */
|
|
9
|
+
root: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Context {
|
|
13
|
+
cwd: string
|
|
14
|
+
/** Resolved lazily by the commands that need it — see `requireProject`. */
|
|
15
|
+
project: AppProject | null
|
|
16
|
+
apiUrl: string
|
|
17
|
+
token: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is this directory the root of a Frontera app project?
|
|
22
|
+
*
|
|
23
|
+
* The marker is `package.json` carrying a `frontera` field, which the scaffold
|
|
24
|
+
* writes at init (`frontera: { displayName }`) and `writeAppId` fills in after
|
|
25
|
+
* the first deploy. A bare `package.json` deliberately does NOT match: without
|
|
26
|
+
* the field, every JavaScript repository on the machine would look like an app.
|
|
27
|
+
*/
|
|
28
|
+
function isProjectRoot(dir: string): boolean {
|
|
29
|
+
const pkgPath = join(dir, 'package.json')
|
|
30
|
+
if (!existsSync(pkgPath)) return false
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { frontera?: unknown }
|
|
33
|
+
return typeof pkg.frontera === 'object' && pkg.frontera !== null
|
|
34
|
+
} catch {
|
|
35
|
+
// A malformed package.json is not a project — and must not abort the walk,
|
|
36
|
+
// since an unrelated broken file higher up would strand a valid project.
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Walk up from `from` to the filesystem root looking for a project.
|
|
43
|
+
*
|
|
44
|
+
* Nearest wins, so an app nested inside another tree resolves to itself.
|
|
45
|
+
* Returns null rather than throwing; `requireProject` owns the error, because
|
|
46
|
+
* only some commands need a project at all.
|
|
47
|
+
*/
|
|
48
|
+
export function findProjectRoot(from: string): string | null {
|
|
49
|
+
let dir = resolve(from)
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (isProjectRoot(dir)) return dir
|
|
52
|
+
const parent = dirname(dir)
|
|
53
|
+
if (parent === dir) return null
|
|
54
|
+
dir = parent
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function requireProject(from: string): AppProject {
|
|
59
|
+
const root = findProjectRoot(from)
|
|
60
|
+
if (!root) {
|
|
61
|
+
throw new UsageError(
|
|
62
|
+
'not in a Frontera app directory',
|
|
63
|
+
'cd into an app project, or run `frontera app init <name>`',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return { ...readProject(root), root }
|
|
67
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the CLI raises itself.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `FronteraError`, which carries a code the SERVICE emitted.
|
|
5
|
+
* Both reach the same renderer and the same exit-code mapping; the difference
|
|
6
|
+
* is only who decided the request was wrong.
|
|
7
|
+
*
|
|
8
|
+
* Every CliError carries a `hint` naming the next command. An error that
|
|
9
|
+
* leaves the caller — usually a model — with nothing to try next is
|
|
10
|
+
* incomplete, and a wrong retry costs more than the message saved.
|
|
11
|
+
*/
|
|
12
|
+
export class CliError extends Error {
|
|
13
|
+
readonly code: string
|
|
14
|
+
readonly hint?: string
|
|
15
|
+
|
|
16
|
+
constructor(message: string, opts: { code: string; hint?: string }) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'CliError'
|
|
19
|
+
this.code = opts.code
|
|
20
|
+
if (opts.hint) this.hint = opts.hint
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Bad invocation: unknown flag, missing value, wrong arity. Always exit 2. */
|
|
25
|
+
export class UsageError extends CliError {
|
|
26
|
+
constructor(message: string, hint?: string) {
|
|
27
|
+
super(message, hint ? { code: 'USAGE', hint } : { code: 'USAGE' })
|
|
28
|
+
this.name = 'UsageError'
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/exit.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CliError } from './errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Exit codes, one per action the caller can take.
|
|
5
|
+
*
|
|
6
|
+
* A caller — usually a model — checks the code before parsing anything, so
|
|
7
|
+
* each one has to map to a genuinely different next move. Conflict and auth
|
|
8
|
+
* earn their own codes for that reason: no retry fixes a 4, and a 3 has a
|
|
9
|
+
* specific two-step recovery that can be automated.
|
|
10
|
+
*/
|
|
11
|
+
export const EXIT = {
|
|
12
|
+
OK: 0,
|
|
13
|
+
/** Transient or remote. Retry, then report. */
|
|
14
|
+
FAILURE: 1,
|
|
15
|
+
/** Bad invocation or bad input. Fix the command. */
|
|
16
|
+
USAGE: 2,
|
|
17
|
+
/** Someone else changed it first. Re-fetch, reapply. */
|
|
18
|
+
CONFLICT: 3,
|
|
19
|
+
/** Credentials or permission. Neither retry nor rewording helps. */
|
|
20
|
+
AUTH: 4,
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT]
|
|
24
|
+
|
|
25
|
+
export interface ErrorEnvelope {
|
|
26
|
+
error: true
|
|
27
|
+
code: string
|
|
28
|
+
message: string
|
|
29
|
+
hint?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const USAGE_CODES = new Set(['USAGE', 'VALIDATION_ERROR', 'BAD_REQUEST', 'NOT_FOUND'])
|
|
33
|
+
const CONFLICT_CODES = new Set(['DRAFT_CONFLICT', 'CONFLICT', 'VALIDATION_STALE'])
|
|
34
|
+
const AUTH_CODES = new Set(['UNAUTHORIZED', 'FORBIDDEN'])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unrecognised codes fall to FAILURE rather than throwing. The SDK does not
|
|
38
|
+
* enumerate service codes — deliberately, so the service can add one without
|
|
39
|
+
* an SDK release — which means this function must treat "never seen it" as
|
|
40
|
+
* ordinary, not exceptional.
|
|
41
|
+
*/
|
|
42
|
+
export function exitCodeFor(code: string): ExitCode {
|
|
43
|
+
if (USAGE_CODES.has(code)) return EXIT.USAGE
|
|
44
|
+
if (CONFLICT_CODES.has(code)) return EXIT.CONFLICT
|
|
45
|
+
if (AUTH_CODES.has(code)) return EXIT.AUTH
|
|
46
|
+
return EXIT.FAILURE
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hints for codes the SERVICE raises. A `CliError` brings its own, because the
|
|
51
|
+
* CLI knows exactly which command failed; for a service code the best we can
|
|
52
|
+
* say is what class of recovery applies.
|
|
53
|
+
*/
|
|
54
|
+
const SERVICE_HINTS: Record<string, string> = {
|
|
55
|
+
DRAFT_CONFLICT: 'someone else changed it first — re-fetch, reapply your edit, then try again',
|
|
56
|
+
// Deliberately vaguer than DRAFT_CONFLICT. The service also raises CONFLICT
|
|
57
|
+
// for "this identity already exists" — an immutable version being republished
|
|
58
|
+
// — where re-fetching and reapplying does nothing at all. Commands that know
|
|
59
|
+
// which case they are in throw a CliError with a specific hint instead.
|
|
60
|
+
CONFLICT: 'the message above names what conflicted — resolve that, then retry',
|
|
61
|
+
VALIDATION_STALE: 're-fetch the resource and re-run validation against the current revision',
|
|
62
|
+
UNAUTHORIZED: 'set FRONTERA_TOKEN to a valid sk-ws- workspace key',
|
|
63
|
+
FORBIDDEN: 'this credential lacks permission for that resource — ask an admin, or use a different key',
|
|
64
|
+
NOT_FOUND: 'list the resource first to confirm the id',
|
|
65
|
+
RATE_LIMITED: 'wait and retry',
|
|
66
|
+
SERVICE_UNAVAILABLE: 'the service is unavailable — retry shortly',
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isFronteraError(err: unknown): err is { code: string; message: string } {
|
|
70
|
+
return (
|
|
71
|
+
err instanceof Error &&
|
|
72
|
+
'code' in err &&
|
|
73
|
+
typeof (err as { code: unknown }).code === 'string'
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Normalise anything thrown into the one envelope shape stderr emits. */
|
|
78
|
+
export function toEnvelope(err: unknown): ErrorEnvelope {
|
|
79
|
+
if (err instanceof CliError) {
|
|
80
|
+
return {
|
|
81
|
+
error: true,
|
|
82
|
+
code: err.code,
|
|
83
|
+
message: err.message,
|
|
84
|
+
...(err.hint ? { hint: err.hint } : {}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isFronteraError(err)) {
|
|
89
|
+
const hint = SERVICE_HINTS[err.code]
|
|
90
|
+
return { error: true, code: err.code, message: err.message, ...(hint ? { hint } : {}) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
error: true,
|
|
95
|
+
code: 'INTERNAL_ERROR',
|
|
96
|
+
message: err instanceof Error ? err.message : String(err),
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/flag-help.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every flag means, in one place.
|
|
3
|
+
*
|
|
4
|
+
* `--help` used to print a bare list of flag names with no descriptions at
|
|
5
|
+
* all — the reader learned that `--expect-revision` exists and nothing about
|
|
6
|
+
* what to pass it. Codex and Claude Code describe every option; so does this
|
|
7
|
+
* now.
|
|
8
|
+
*
|
|
9
|
+
* Central rather than per-command because a flag name means the same thing
|
|
10
|
+
* everywhere it appears: `--force` overrides a refusal, `--json` switches the
|
|
11
|
+
* output contract. Declaring it once removes the way that guarantee usually
|
|
12
|
+
* dies — the same flag described two different ways in two commands. A test
|
|
13
|
+
* fails if a command declares a flag this file does not describe.
|
|
14
|
+
*/
|
|
15
|
+
export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
16
|
+
// Global
|
|
17
|
+
json: 'machine-readable output on stdout; stderr still carries commentary',
|
|
18
|
+
quiet: 'suppress progress commentary on stderr',
|
|
19
|
+
yes: 'assume yes for confirmations',
|
|
20
|
+
help: 'show this help',
|
|
21
|
+
'api-url': 'API origin to use, overriding FRONTERA_API_URL and the stored default',
|
|
22
|
+
|
|
23
|
+
// Project resolution
|
|
24
|
+
dir: 'project directory to act on (default: walk up from the working directory)',
|
|
25
|
+
|
|
26
|
+
// Writes
|
|
27
|
+
file: 'read the document from this path, or `-` for stdin',
|
|
28
|
+
force: 'proceed past a refusal — overwrite existing files, or a dirty tree',
|
|
29
|
+
'expect-revision':
|
|
30
|
+
'refuse the write unless the resource is still at this revision; exit 3 if it moved',
|
|
31
|
+
notes: 'note recorded against the published version',
|
|
32
|
+
|
|
33
|
+
// Apps
|
|
34
|
+
version: 'version to publish, overriding the one in package.json',
|
|
35
|
+
'no-promote': 'publish the version without moving the live pointer',
|
|
36
|
+
draft: 'act on your saved draft rather than a published version',
|
|
37
|
+
|
|
38
|
+
// Auth and setup
|
|
39
|
+
'token-stdin': 'read the workspace key from stdin instead of prompting',
|
|
40
|
+
'no-input': 'never prompt; fail instead, for use in scripts and CI',
|
|
41
|
+
config: 'print the resolved configuration and where each value came from',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a value-taking flag expects, so the placeholder says something. `--dir
|
|
46
|
+
* <value>` tells the reader only that a value goes there; `--dir <path>` tells
|
|
47
|
+
* them what kind.
|
|
48
|
+
*/
|
|
49
|
+
const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
50
|
+
'api-url': 'origin',
|
|
51
|
+
dir: 'path',
|
|
52
|
+
file: 'path',
|
|
53
|
+
version: 'semver',
|
|
54
|
+
'expect-revision': 'hash',
|
|
55
|
+
notes: 'text',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `--flag <thing>` or `--flag`, so the reader can see which take an argument
|
|
60
|
+
* without cross-referencing anything.
|
|
61
|
+
*/
|
|
62
|
+
export function flagUsage(name: string, type: 'string' | 'boolean', alias?: string): string {
|
|
63
|
+
const short = alias ? `-${alias}, ` : ''
|
|
64
|
+
if (type === 'boolean') return `${short}--${name}`
|
|
65
|
+
return `${short}--${name} <${PLACEHOLDER[name] ?? 'value'}>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function describeFlag(name: string): string {
|
|
69
|
+
return FLAG_HELP[name] ?? ''
|
|
70
|
+
}
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Files that prepare a directory for a coding harness.
|
|
6
|
+
*
|
|
7
|
+
* A skill only works where a harness scans. `.agents/skills/` is the
|
|
8
|
+
* vendor-neutral location, and AGENTS.md is what most harnesses read first —
|
|
9
|
+
* so the contract is placed in the project, committed, rather than hidden in
|
|
10
|
+
* a home directory nothing looks at.
|
|
11
|
+
*
|
|
12
|
+
* The skill ROUTES; it does not restate. `--help` is generated from the
|
|
13
|
+
* command table, so it is authoritative for syntax, and a skill that listed
|
|
14
|
+
* flags would drift into handing a model wrong instructions. What lives here
|
|
15
|
+
* is workflow, invariants and recovery — the things `--help` cannot say.
|
|
16
|
+
*/
|
|
17
|
+
const SKILL = `---
|
|
18
|
+
name: using-frontera-cli
|
|
19
|
+
description: Use when reading or changing anything on the Frontera platform from a shell — agents, skills, plugins, knowledge, Blueprint, apps, or automations — via the \`frontera\` CLI.
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Using the Frontera CLI
|
|
23
|
+
|
|
24
|
+
\`frontera <noun> <verb>\`. Help is generated from the command table, so it is
|
|
25
|
+
never out of date — read it instead of guessing, and instead of trusting this
|
|
26
|
+
file for syntax.
|
|
27
|
+
|
|
28
|
+
## Discover the surface as data
|
|
29
|
+
|
|
30
|
+
\`\`\`bash
|
|
31
|
+
frontera help --json
|
|
32
|
+
\`\`\`
|
|
33
|
+
|
|
34
|
+
Returns every command: usage, arguments, flags with types, examples, whether it
|
|
35
|
+
is available, and two preconditions worth knowing before you call — whether it
|
|
36
|
+
needs an app project (\`needsProject\`) and whether it needs a credential
|
|
37
|
+
(\`requiresCredential\`). Narrow it with \`frontera <noun> --help --json\`, or one
|
|
38
|
+
command with \`frontera <noun> <verb> --help --json\`.
|
|
39
|
+
|
|
40
|
+
Prefer this over parsing \`--help\` text. Everything \`--help\` shows is in it,
|
|
41
|
+
and it cannot drift, because both are projected from the same table.
|
|
42
|
+
|
|
43
|
+
## The two shapes
|
|
44
|
+
|
|
45
|
+
**Apps are projects.** \`frontera app …\` resolves the project by walking up from
|
|
46
|
+
the working directory, so run it from anywhere inside the app. Not in one is an
|
|
47
|
+
error naming the fix.
|
|
48
|
+
|
|
49
|
+
**Everything else is addressed by id.** \`frontera agent get <id>\` writes a
|
|
50
|
+
document to stdout; edit it; \`frontera agent apply <id> -f <file>\` sends it
|
|
51
|
+
back. Nothing is implicit, so nothing goes stale.
|
|
52
|
+
|
|
53
|
+
## Reading the result
|
|
54
|
+
|
|
55
|
+
Pass \`--json\` for machine-readable output. The contract:
|
|
56
|
+
|
|
57
|
+
- **stdout is data and nothing else.** Progress and errors go to stderr.
|
|
58
|
+
- **exit 0 means stdout is trustworthy.** Check the code before parsing.
|
|
59
|
+
|
|
60
|
+
| Exit | Meaning | What to do |
|
|
61
|
+
|------|---------|-----------|
|
|
62
|
+
| 0 | success | continue |
|
|
63
|
+
| 1 | transient or remote failure | retry, then report |
|
|
64
|
+
| 2 | usage or input error | fix the command |
|
|
65
|
+
| 3 | conflict — someone changed it first | re-fetch, reapply, retry |
|
|
66
|
+
| 4 | auth or permission | different credential, or ask an admin |
|
|
67
|
+
|
|
68
|
+
Errors carry a \`hint\` naming the next command. Read it.
|
|
69
|
+
|
|
70
|
+
## Invariants
|
|
71
|
+
|
|
72
|
+
- **On exit 3, re-fetch — never force.** The document you hold is stale. Get it
|
|
73
|
+
again, reapply your edit on top, then send it. Overwriting discards whatever
|
|
74
|
+
the other writer did.
|
|
75
|
+
- **Secrets never go in a flag.** Use \`--secret name@-\` (stdin), \`name@./file\`,
|
|
76
|
+
or \`name=ref:<workspace-secret>\`. A value on the command line lands in
|
|
77
|
+
process listings and shell history.
|
|
78
|
+
- **\`.env\` is never packaged**, and that is not overridable.
|
|
79
|
+
- **A pull refuses to clobber.** If it reports dirty files, deal with them —
|
|
80
|
+
inside a sandbox there is usually no git to recover from.
|
|
81
|
+
|
|
82
|
+
## Building an app
|
|
83
|
+
|
|
84
|
+
\`\`\`bash
|
|
85
|
+
frontera app init <name> # scaffold — installs with public npm only
|
|
86
|
+
bun install
|
|
87
|
+
frontera blueprint list # what data this workspace actually has
|
|
88
|
+
frontera app add <component> # registry source into the project
|
|
89
|
+
bun run typecheck # vite build does NOT type-check
|
|
90
|
+
bun run build # the deploy gate — must succeed
|
|
91
|
+
frontera app deploy --no-promote
|
|
92
|
+
frontera app promote <version>
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
Read \`frontera blueprint list\` and \`frontera blueprint get <apiName>\` before
|
|
96
|
+
designing against data. What they return is the slice this workspace is granted,
|
|
97
|
+
which is exactly what the app will be able to query at runtime — so anything
|
|
98
|
+
absent there will be absent for the app too.
|
|
99
|
+
|
|
100
|
+
\`src/frontera/\` in a scaffolded project is the SDK, copied in as source so the
|
|
101
|
+
project installs anywhere. Read it; never edit it, never rewrite an
|
|
102
|
+
\`@frontera-sdk/…\` import to a relative path, never add \`@frontera-sdk/*\` to
|
|
103
|
+
\`package.json\`. The project's own \`using-frontera-sdk\` skill has the detail.
|
|
104
|
+
|
|
105
|
+
\`bun run dev\` serves the app, but opening \`/\` directly renders "This app runs
|
|
106
|
+
inside Frontera" instead of mounting: the handshake refuses an unknown parent,
|
|
107
|
+
which is correct, not a broken build.
|
|
108
|
+
|
|
109
|
+
Open **\`/dev-host.html\`** instead. It frames the app and plays the host side of
|
|
110
|
+
the bridge, so the app mounts and reads real data. That is the URL to screenshot
|
|
111
|
+
or click through when verifying your work — a screenshot of \`/\` is a screenshot
|
|
112
|
+
of the refusal panel.
|
|
113
|
+
`
|
|
114
|
+
|
|
115
|
+
const AGENTS_MD = `# Frontera
|
|
116
|
+
|
|
117
|
+
This directory works with the Frontera platform through the \`frontera\` CLI.
|
|
118
|
+
|
|
119
|
+
Load the skill at \`.agents/skills/using-frontera-cli/SKILL.md\` before running
|
|
120
|
+
any \`frontera\` command. It carries the workflow, the exit-code contract and the
|
|
121
|
+
invariants that are not obvious from \`--help\`.
|
|
122
|
+
|
|
123
|
+
For syntax, run \`frontera help\` or \`frontera <noun> <verb> --help\` — help is
|
|
124
|
+
generated from the command table and is always current.
|
|
125
|
+
`
|
|
126
|
+
|
|
127
|
+
export interface HarnessResult {
|
|
128
|
+
written: string[]
|
|
129
|
+
skipped: string[]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Idempotent: an existing file is left alone unless `force`, so re-running
|
|
134
|
+
* changes nothing and never clobbers a customised AGENTS.md.
|
|
135
|
+
*/
|
|
136
|
+
export function writeHarnessFiles(dir: string, opts: { force?: boolean } = {}): HarnessResult {
|
|
137
|
+
const files: Array<[string, string]> = [
|
|
138
|
+
['AGENTS.md', AGENTS_MD],
|
|
139
|
+
[join('.agents', 'skills', 'using-frontera-cli', 'SKILL.md'), SKILL],
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
const written: string[] = []
|
|
143
|
+
const skipped: string[] = []
|
|
144
|
+
|
|
145
|
+
for (const [rel, content] of files) {
|
|
146
|
+
const dest = join(dir, rel)
|
|
147
|
+
if (existsSync(dest) && !opts.force) {
|
|
148
|
+
// Unchanged content is not a conflict — report it as written so a
|
|
149
|
+
// re-run reads as a no-op rather than a refusal.
|
|
150
|
+
if (readFileSync(dest, 'utf8') === content) continue
|
|
151
|
+
skipped.push(rel)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
155
|
+
writeFileSync(dest, content)
|
|
156
|
+
written.push(rel)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { written, skipped }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const HARNESS_SKILL_BODY = SKILL
|