@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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. 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
+ }
@@ -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