@frontera-sdk/cli 1.44.1 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +419 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +31 -0
  58. package/src/vendor/sdk-sources.json +1 -1
package/src/exit.ts CHANGED
@@ -29,9 +29,32 @@ export interface ErrorEnvelope {
29
29
  hint?: string
30
30
  }
31
31
 
32
- const USAGE_CODES = new Set(['USAGE', 'VALIDATION_ERROR', 'BAD_REQUEST', 'NOT_FOUND'])
32
+ const USAGE_CODES = new Set([
33
+ 'USAGE',
34
+ 'VALIDATION_ERROR',
35
+ 'BAD_REQUEST',
36
+ 'NOT_FOUND',
37
+ // Profile selection. Every one of these is fixed by changing the command or
38
+ // the directory binding — never by retrying, which is what separates them
39
+ // from AUTH below: a wrong profile is a usage mistake, a revoked key is not.
40
+ 'PROFILE_NOT_FOUND',
41
+ 'PROFILE_NOT_SELECTED',
42
+ 'PROFILE_ORIGIN_MISMATCH',
43
+ 'PROFILE_INVALID',
44
+ 'PROJECT_CONTEXT_NOT_FOUND',
45
+ 'PROJECT_CONTEXT_CORRUPT',
46
+ 'PROJECT_CONTEXT_UNTRUSTED',
47
+ // Authoring kit. Both are resolved by running a named command, not by retry.
48
+ 'KIT_VERSION_MISMATCH',
49
+ 'GENERATED_FILE_CONFLICT',
50
+ ])
33
51
  const CONFLICT_CODES = new Set(['DRAFT_CONFLICT', 'CONFLICT', 'VALIDATION_STALE'])
34
- const AUTH_CODES = new Set(['UNAUTHORIZED', 'FORBIDDEN'])
52
+ const AUTH_CODES = new Set([
53
+ 'UNAUTHORIZED',
54
+ 'FORBIDDEN',
55
+ // The key is gone, not merely unselected — same recovery as a revoked one.
56
+ 'PROFILE_SECRET_MISSING',
57
+ ])
35
58
 
36
59
  /**
37
60
  * Unrecognised codes fall to FAILURE rather than throwing. The SDK does not
@@ -63,6 +86,10 @@ const SERVICE_HINTS: Record<string, string> = {
63
86
  FORBIDDEN: 'this credential lacks permission for that resource — ask an admin, or use a different key',
64
87
  NOT_FOUND: 'list the resource first to confirm the id',
65
88
  RATE_LIMITED: 'wait and retry',
89
+ // Exit 1: the store may be locked or momentarily unreachable, so one retry is
90
+ // a reasonable move before reporting.
91
+ SECURE_STORE_UNAVAILABLE:
92
+ 'unlock the OS credential store and retry, or set FRONTERA_SECRET_STORE=file',
66
93
  SERVICE_UNAVAILABLE: 'the service is unavailable — retry shortly',
67
94
  }
68
95
 
package/src/flag-help.ts CHANGED
@@ -41,7 +41,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
41
41
  quiet: 'suppress progress commentary on stderr',
42
42
  yes: 'assume yes for confirmations',
43
43
  help: 'show this help',
44
- 'api-url': 'API origin to use, overriding FRONTERA_API_URL and the stored default',
44
+ 'api-url': "API origin to use must match the selected profile's origin",
45
+ profile: 'credential profile to use for this command, overriding the directory selection',
45
46
  output: 'project-relative .ts path for generated output',
46
47
  check: 'verify generated output is current without writing it',
47
48
 
@@ -82,16 +83,64 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
82
83
  // Packs
83
84
  'remove-apps': 'also uninstall the apps the pack installed',
84
85
 
85
- // Secrets
86
+ // Secrets, and the same rule for API keys in `auth add`
86
87
  from: 'where to read the value from: `-` for stdin, or a file path. Never an inline value',
88
+ // Same rule as `--from`, named separately because a Source definition file
89
+ // carries every other field and only this one is refused inside it.
90
+ 'password-from':
91
+ 'where to read the connection password from: `-` for stdin, or a file path. Never an inline '
92
+ + 'value, and never inside the definition file',
93
+
94
+ // Blueprint reads
95
+ select: 'comma-separated property API names to return (default: whatever the service returns)',
96
+ where:
97
+ 'filter as <property>:<value>, <property>:<op>:<value>, or a JSON where-node for anything '
98
+ + 'with and/or in it',
99
+ order: 'sort as <property>:asc or <property>:desc, comma-separated for more than one',
100
+ // No number here on purpose. This map is keyed by flag NAME across every
101
+ // command, and the three surfaces that take `--limit` cap a page at 1000,
102
+ // 200 and 100 — so any figure stated once is wrong for two of them. Each
103
+ // command's own refusal names its real ceiling.
104
+ limit: 'maximum rows to return in one page; each surface has its own ceiling',
105
+ 'page-token': 'cursor from the previous response’s nextPageToken; omit for the first page',
106
+ // Named for the field the response carries, which differs by surface —
107
+ // Blueprint answers `nextPageToken`, data-integration answers `nextCursor`.
108
+ // Matching each flag to its own response beats one name that is wrong half
109
+ // the time.
110
+ cursor: 'cursor from the previous response’s nextCursor; omit for the first page',
111
+ lifecycle: 'only requests in this lifecycle state; comma-separate for more than one',
112
+
113
+ // Plugins and agent capabilities
114
+ enable: 'switch the install on',
115
+ disable: 'switch the install off, leaving it installed',
116
+ actions:
117
+ 'Action policy for the install: allow_all, read_only or custom. read_only refuses every '
118
+ + 'Action capability grant on it',
119
+ 'new-capability': 'what a newly discovered capability defaults to: enabled or disabled',
120
+ 'end-user':
121
+ 'bind as end_user, so every caller connects their own account, instead of agent_owned, '
122
+ + 'which uses one credential the workspace holds',
123
+ 'accept-orphaned-knowledge':
124
+ 'revert even though the old version names a knowledge base that no longer exists',
87
125
 
88
126
  // Knowledge
89
127
  description: 'one-line description stored on the resource',
128
+ 'top-k': 'how many chunks to return (default 10)',
129
+ // A DISTANCE cutoff, not the similarity the output column shows — the query
130
+ // keeps chunks whose cosine distance is below it, so a HIGHER value is more
131
+ // permissive and 0 matches nothing at all. Described the wrong way round
132
+ // first, which made `--threshold 0` look like "return everything".
133
+ threshold:
134
+ 'maximum cosine distance a chunk may have; higher is more permissive (default 0.9). '
135
+ + 'Not the similarity score shown in the output — 0 matches nothing',
90
136
  strategy:
91
137
  'text extraction to use per file: auto (default), text, or ocr; ocr needs an OCR engine on the base',
92
138
 
93
139
  // Auth and setup
94
- 'token-stdin': 'read the workspace key from stdin instead of prompting',
140
+ // "key", not "workspace key": `login` verifies an organization key too, and
141
+ // naming only one kind here was the last place the CLI still implied that a
142
+ // holder of the other had no route in.
143
+ 'token-stdin': 'read the key from stdin instead of prompting — sk-ws- or sk-org-',
95
144
  'no-input': 'never prompt; fail instead, for use in scripts and CI',
96
145
  config: 'print the resolved configuration and where each value came from',
97
146
  }
@@ -103,6 +152,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
103
152
  */
104
153
  const PLACEHOLDER: Readonly<Record<string, string>> = {
105
154
  'api-url': 'origin',
155
+ profile: 'name',
106
156
  output: 'path',
107
157
  dataset: 'name',
108
158
  plan: 'path',
@@ -116,8 +166,20 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
116
166
  description: 'text',
117
167
  strategy: 'auto|text|ocr',
118
168
  from: '-|path',
169
+ 'password-from': '-|path',
119
170
  name: 'text',
120
171
  kind: 'conversation|work',
172
+ select: 'a,b,c',
173
+ where: 'prop:value',
174
+ order: 'prop:asc',
175
+ limit: 'n',
176
+ 'page-token': 'cursor',
177
+ cursor: 'cursor',
178
+ lifecycle: 'state',
179
+ actions: 'allow_all|read_only|custom',
180
+ 'new-capability': 'enabled|disabled',
181
+ 'top-k': 'n',
182
+ threshold: '0-2',
121
183
  template: 'non-negative|percentage|status-enum|code-length',
122
184
  }
123
185
 
@@ -0,0 +1,44 @@
1
+ import { chmodSync, closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Write a file so a reader never sees half of it.
6
+ *
7
+ * Profile metadata and directory context are read by every command, including
8
+ * the ones running concurrently in another terminal. A plain `writeFileSync`
9
+ * truncates first and fills after, so a crash — or a reader arriving in that
10
+ * window — finds an empty or partial JSON document, which the resolver would
11
+ * then have to treat as corrupt. Temp file, fsync, atomic rename: the reader
12
+ * sees the old bytes or the new ones and nothing between.
13
+ *
14
+ * Mode 0600 by default because both files this is used for are personal
15
+ * machine configuration, and one of them sits next to a credential store.
16
+ */
17
+ export function writeFileAtomic(path: string, content: string, mode = 0o600): void {
18
+ mkdirSync(dirname(path), { recursive: true })
19
+ // Same directory, so the rename stays within one filesystem — a cross-device
20
+ // rename is not atomic and not even permitted.
21
+ const tmp = join(dirname(path), `.${Date.now()}-${process.pid}.tmp`)
22
+ try {
23
+ writeFileSync(tmp, content, { mode })
24
+ // Explicitly, because the create mode is masked by the process umask — a
25
+ // file asked for as 0664 lands as 0644 under the usual 022, so preserving
26
+ // an existing file's mode across an atomic replace requires this.
27
+ chmodSync(tmp, mode)
28
+ const fd = openSync(tmp, 'r+')
29
+ try {
30
+ fsyncSync(fd)
31
+ } finally {
32
+ closeSync(fd)
33
+ }
34
+ renameSync(tmp, path)
35
+ } catch (err) {
36
+ try {
37
+ unlinkSync(tmp)
38
+ } catch {
39
+ // The temp file may never have been created. Losing it is not the failure
40
+ // worth reporting — the write is.
41
+ }
42
+ throw err
43
+ }
44
+ }
package/src/harness.ts CHANGED
@@ -25,6 +25,22 @@ description: Use when reading or changing anything on the Frontera platform from
25
25
  never out of date — read it instead of guessing, and instead of trusting this
26
26
  file for syntax.
27
27
 
28
+ ## Preflight: know which customer you are about to change
29
+
30
+ \`\`\`bash
31
+ frontera auth current --json
32
+ \`\`\`
33
+
34
+ The working directory selects the credential profile, so this is the only
35
+ reliable answer to "which workspace am I in". It reports the profile, its API
36
+ origin, the credential kind and where the selection came from — and no key
37
+ material, ever.
38
+
39
+ Exit 2 with \`PROFILE_NOT_SELECTED\` means this directory is bound to nothing.
40
+ Do not work around it with \`--profile\`; report it, and offer
41
+ \`frontera auth list\` and \`frontera auth use <profile>\`. Only the person can
42
+ add a profile, because it needs their API key.
43
+
28
44
  ## Discover the surface as data
29
45
 
30
46
  \`\`\`bash
@@ -40,6 +56,52 @@ command with \`frontera <noun> <verb> --help --json\`.
40
56
  Prefer this over parsing \`--help\` text. Everything \`--help\` shows is in it,
41
57
  and it cannot drift, because both are projected from the same table.
42
58
 
59
+ Two fields decide whether a call can work at all:
60
+
61
+ - \`authLane\` — \`api-key\` reaches the command with \`sk-ws-\`/\`sk-org-\` or a
62
+ session. \`session\` reaches it with a session token ONLY: a valid key answers
63
+ 401 there, which looks exactly like an expired credential. Run
64
+ \`frontera login\` before those.
65
+ - \`available\` — \`false\` means the verb is named but unbuilt, and
66
+ \`unavailableReason\` says why. Pick another route rather than retrying.
67
+
68
+ ## Two kinds of credential
69
+
70
+ \`frontera login\` accepts either, stores both the same way, and verifies them
71
+ differently. One origin holds ONE key, so logging in again on the same
72
+ \`--api-url\` replaces whatever was there.
73
+
74
+ - **\`sk-ws-\`, a workspace key.** One workspace's slice. Reaches \`agent\`,
75
+ \`skill\`, \`plugin\`, \`capability\`, \`knowledge\`, \`pack\`, \`secret\`,
76
+ \`automation\`, \`app\`, and Blueprint READS through the granted slice.
77
+ - **\`sk-org-\`, an organization key.** Reaches the organization-level Blueprint
78
+ draft, which no workspace key can — that draft is shared by every workspace,
79
+ so a workspace credential is refused there by design, not by oversight.
80
+ Needed for Blueprint AUTHORING (\`adopt\`, \`create\`, \`update\`, \`publish\`,
81
+ \`apply\`, \`pull\`, \`plan\`, \`validate\`, \`rollback\`) and for \`dataset\` and
82
+ \`source\`.
83
+
84
+ A 403 on one of those nouns usually means the wrong KIND of key, not a missing
85
+ permission. Rather than re-logging in each time, pass the other per call:
86
+
87
+ \`\`\`bash
88
+ FRONTERA_TOKEN=sk-org-… frontera blueprint pull
89
+ FRONTERA_TOKEN=sk-org-… frontera source list
90
+ \`\`\`
91
+
92
+ An organization key carries a second rule worth knowing: its effective grant is
93
+ its own permissions INTERSECTED with its creator's authority, re-resolved on
94
+ every request. Demoting whoever minted it shrinks the key; removing them kills
95
+ it.
96
+
97
+ ## Where the CLI ends
98
+
99
+ Most of the platform has no CLI surface, on purpose — channels, workspaces,
100
+ members, API keys, sheets, tasks and the evaluation suite are Console-only. The
101
+ CLI says so by name: reaching for one of those nouns answers with what it is
102
+ and where it lives, not "unknown command". Read that hint instead of guessing a
103
+ synonym; there isn't one.
104
+
43
105
  ## The two shapes
44
106
 
45
107
  **Apps are projects.** \`frontera app …\` resolves the project by walking up from
@@ -61,7 +123,7 @@ Pass \`--json\` for machine-readable output. The contract:
61
123
  |------|---------|-----------|
62
124
  | 0 | success | continue |
63
125
  | 1 | transient or remote failure | retry, then report |
64
- | 2 | usage or input error | fix the command |
126
+ | 2 | usage, input, or profile selection | fix the command |
65
127
  | 3 | conflict — someone changed it first | re-fetch, reapply, retry |
66
128
  | 4 | auth or permission | different credential, or ask an admin |
67
129
 
@@ -106,6 +168,72 @@ Three things about this are not visible from \`--help\`:
106
168
  Naming a directory is the normal case: unsupported types inside it are skipped
107
169
  and counted. Naming an unsupported file directly is an error and uploads nothing.
108
170
 
171
+ ## Proving a write landed
172
+
173
+ A write verb reports what it SENT. Whether the thing is now readable is a
174
+ different question, and on this platform the two come apart often enough that
175
+ assuming they agree is how a broken binding ships:
176
+
177
+ \`\`\`bash
178
+ frontera knowledge search <base> "a question a user would ask"
179
+ frontera blueprint query <apiName> --limit 5
180
+ frontera blueprint instance <apiName> <pk>
181
+ \`\`\`
182
+
183
+ - **An empty \`knowledge search\` right after an upload is normal.** Ingestion is
184
+ asynchronous. Check \`frontera knowledge sources <base>\` — anything still
185
+ \`processing\` is not searchable yet, and that is not a failed upload.
186
+ - **An empty \`blueprint query\` after a bind is not normal.** It usually means
187
+ the object type has no dataset binding on the ACTIVE release, not that the
188
+ table is empty. \`frontera blueprint get <apiName>\` shows the binding.
189
+ - Reads resolve through the granted slice, so what comes back is exactly what an
190
+ app or agent sees at runtime. Anything absent here is absent for them too.
191
+
192
+ \`--where\` takes \`<property>:<value>\` or \`<property>:<op>:<value>\` for one
193
+ condition; pass a JSON where-node for anything with and/or in it, or
194
+ \`--file\` a whole query document for unions and link traversals.
195
+
196
+ ## Configuring an agent
197
+
198
+ \`\`\`bash
199
+ frontera agent get <agent> --json > agent.json # edit it
200
+ frontera agent apply <agent> -f agent.json # stages onto the draft
201
+ frontera agent skill-attach <agent> <skillId> # skill push does NOT bind
202
+ frontera capability available <installId> # what a plugin offers
203
+ frontera capability grant <agent> <capabilityId> # stages onto the draft
204
+ frontera agent diff <agent> # what is staged vs live
205
+ frontera agent publish <agent> # the only step that goes live
206
+ \`\`\`
207
+
208
+ - **Almost everything stages.** \`apply\`, \`capability grant\` and \`capability
209
+ revoke\` all write the DRAFT. A caller who grants, tests the live agent, sees
210
+ no change and grants again has skipped \`publish\` — that is the one step that
211
+ reaches the running agent.
212
+ - **\`agent revert\` is the exception.** It publishes an old version as a new live
213
+ one immediately, with no draft in between.
214
+ - **Granting a capability auto-binds its plugin install** if the agent did not
215
+ already hold it, so an install you never named appears in \`agent diff\`.
216
+ - **An install set to \`read_only\` refuses every Action capability grant.** The
217
+ conflict names the install, not the capability; \`frontera plugin policy
218
+ <installId> --actions allow_all\` is the fix, and retrying the grant is not.
219
+ - **Binding is not granting.** \`capability bind\` decides whose account the agent
220
+ reaches (\`agent_owned\` by default, \`--end-user\` for per-caller accounts) and
221
+ grants nothing on its own.
222
+
223
+ ## Connecting a database
224
+
225
+ \`\`\`bash
226
+ printf %s "$PGPASSWORD" | frontera source create -f ./source.json --password-from -
227
+ frontera source list
228
+ frontera dataset test-source <sourceId>
229
+ \`\`\`
230
+
231
+ The password is never in the definition file — a file carrying \`config.password\`
232
+ is refused, not stripped. Everything else lives in the file so it can be
233
+ committed. \`source update\`, \`source revise\` and \`source disable\` re-read the
234
+ Source first and send its own concurrency tokens, so a write refused with exit 3
235
+ means someone else changed it: re-read and retry, never force.
236
+
109
237
  ## Building an app
110
238
 
111
239
  \`\`\`bash
@@ -143,14 +271,29 @@ const AGENTS_MD = `# Frontera
143
271
 
144
272
  This directory works with the Frontera platform through the \`frontera\` CLI.
145
273
 
146
- Load the skill at \`.agents/skills/using-frontera-cli/SKILL.md\` before running
147
- any \`frontera\` command. It carries the workflow, the exit-code contract and the
148
- invariants that are not obvious from \`--help\`.
274
+ Load the \`using-frontera-cli\` skill before running any \`frontera\` command. It
275
+ carries the workflow, the exit-code contract and the invariants that are not
276
+ obvious from \`--help\`. Codex finds it at
277
+ \`.agents/skills/using-frontera-cli/SKILL.md\`; Claude Code finds the same bytes
278
+ at \`.claude/skills/using-frontera-cli/SKILL.md\`.
279
+
280
+ Before anything that writes, run \`frontera auth current --json\` — the working
281
+ directory selects which customer's credential is in play.
149
282
 
150
283
  For syntax, run \`frontera help\` or \`frontera <noun> <verb> --help\` — help is
151
284
  generated from the command table and is always current.
152
285
  `
153
286
 
287
+ /**
288
+ * Claude Code imports with \`@AGENTS.md\`.
289
+ *
290
+ * \`./AGENTS.md\` is a relative path Claude does not import — it reads as prose,
291
+ * so the contract silently never loads. One character, and the difference
292
+ * between a harness that works in both hosts and one that works in one.
293
+ */
294
+ const CLAUDE_MD = `@AGENTS.md
295
+ `
296
+
154
297
  export interface HarnessResult {
155
298
  written: string[]
156
299
  skipped: string[]
@@ -159,11 +302,19 @@ export interface HarnessResult {
159
302
  /**
160
303
  * Idempotent: an existing file is left alone unless `force`, so re-running
161
304
  * changes nothing and never clobbers a customised AGENTS.md.
305
+ *
306
+ * The same skill is written to BOTH host directories. Codex discovers
307
+ * `.agents/skills` and Claude Code discovers `.claude/skills`, and neither
308
+ * reads the other's — so a single tree is a harness that works in one host and
309
+ * silently does nothing in the other. `frontera kit vendor` is the fuller
310
+ * version of this, carrying the whole authoring kit rather than one skill.
162
311
  */
163
312
  export function writeHarnessFiles(dir: string, opts: { force?: boolean } = {}): HarnessResult {
164
313
  const files: Array<[string, string]> = [
165
314
  ['AGENTS.md', AGENTS_MD],
315
+ ['CLAUDE.md', CLAUDE_MD],
166
316
  [join('.agents', 'skills', 'using-frontera-cli', 'SKILL.md'), SKILL],
317
+ [join('.claude', 'skills', 'using-frontera-cli', 'SKILL.md'), SKILL],
167
318
  ]
168
319
 
169
320
  const written: string[] = []