acrylblends 0.7.7 → 0.8.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 (195) hide show
  1. package/dist/index.d.ts +14 -6
  2. package/dist/index.js +130 -31
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +3 -3
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/agents/index.md +18 -3
  7. package/docs/framework/getting-started/basic.md +1 -2
  8. package/docs/framework/getting-started/desktop.md +3 -3
  9. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  10. package/docs/framework/guides/client-modules.md +4 -3
  11. package/docs/framework/guides/create-acrylblends.md +5 -4
  12. package/docs/framework/guides/data.md +7 -6
  13. package/docs/framework/helpers/streaming.md +12 -27
  14. package/docs/framework/upstream.json +3 -3
  15. package/lib/browser/read-sse.js +84 -0
  16. package/lib/db/sqlite.d.ts +1 -1
  17. package/lib/db.d.ts +1 -84
  18. package/lib/index-BLTYsyc1.d.ts +93 -0
  19. package/lib/index-D4FAsBZu.d.ts +85 -0
  20. package/lib/kv/db.d.ts +2 -2
  21. package/lib/kv/json.d.ts +1 -1
  22. package/lib/kv/json.js +87 -24
  23. package/lib/kv.d.ts +1 -92
  24. package/lib/node-server.d.ts +3 -3
  25. package/lib/node-server.js +14 -8
  26. package/lib/read-sse.d.ts +33 -0
  27. package/lib/read-sse.js +84 -0
  28. package/package.json +5 -1
  29. package/plugins/acryl-db/plugin.json +7 -2
  30. package/plugins/acryl-db/src/conformance.ts +224 -0
  31. package/plugins/acryl-db/upstream.json +2 -2
  32. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  33. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  34. package/plugins/acryl-door/LICENSE +47 -0
  35. package/plugins/acryl-door/plugin.json +14 -0
  36. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  37. package/plugins/acryl-door/src/host/api.ts +9 -0
  38. package/plugins/acryl-door/src/host/audit.ts +64 -0
  39. package/plugins/acryl-door/src/host/channel.ts +142 -0
  40. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  41. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  42. package/plugins/acryl-door/src/host/door.ts +195 -0
  43. package/plugins/acryl-door/src/host/http.ts +80 -0
  44. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  45. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  46. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  47. package/plugins/acryl-door/src/host/shim.ts +65 -0
  48. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  49. package/plugins/acryl-door/src/index.ts +133 -0
  50. package/plugins/acryl-door/src/page/api.ts +9 -0
  51. package/plugins/acryl-door/src/page/channel.ts +135 -0
  52. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  53. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  54. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  55. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  56. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  57. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  58. package/plugins/acryl-door/src/page/index.ts +40 -0
  59. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  60. package/plugins/acryl-door/src/page/main.ts +7 -0
  61. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  62. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  63. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  64. package/plugins/acryl-door/upstream.json +28 -0
  65. package/plugins/acryl-inspect/upstream.json +1 -1
  66. package/plugins/acryl-kv/plugin.json +7 -2
  67. package/plugins/acryl-kv/src/conformance.ts +226 -0
  68. package/plugins/acryl-kv/upstream.json +1 -1
  69. package/plugins/acryl-kv-db/plugin.json +4 -1
  70. package/plugins/acryl-kv-db/upstream.json +1 -1
  71. package/plugins/acryl-kv-json/plugin.json +4 -1
  72. package/plugins/acryl-kv-json/src/open.ts +86 -22
  73. package/plugins/acryl-kv-json/upstream.json +2 -2
  74. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  75. package/plugins/acryl-node-server/src/client-modules.ts +10 -9
  76. package/plugins/acryl-node-server/upstream.json +2 -2
  77. package/plugins/acryl-router/src/browser/read-sse.ts +3 -0
  78. package/plugins/acryl-router/src/helpers/read-sse.ts +111 -0
  79. package/plugins/acryl-router/src/service.ts +3 -0
  80. package/plugins/acryl-router/upstream.json +2 -2
  81. package/plugins/acryl-router-http/upstream.json +1 -1
  82. package/plugins/acryl-state/upstream.json +1 -1
  83. package/plugins/agent-panel/client/main.ts +159 -0
  84. package/plugins/agent-panel/index.ts +232 -0
  85. package/plugins/agent-panel/plugin.json +21 -0
  86. package/plugins/builder/index.ts +2 -2
  87. package/plugins/builder/plugin.json +1 -1
  88. package/plugins/desktop/main.ts +3 -3
  89. package/plugins/desktop/plugin.json +2 -2
  90. package/plugins/desktop/upstream.json +1 -1
  91. package/plugins/desktop-web/main.ts +2 -2
  92. package/plugins/desktop-web/plugin.json +2 -2
  93. package/plugins/desktop-web/upstream.json +1 -1
  94. package/plugins/extensions/index.ts +19 -1
  95. package/plugins/extensions/plugin.json +1 -1
  96. package/plugins/extensions/resolve.ts +17 -3
  97. package/plugins/manifest/lock.ts +7 -3
  98. package/plugins/manifest/upgrade-cli.ts +1 -1
  99. package/plugins/save/cli.ts +1 -1
  100. package/plugins/save/plugin.json +1 -1
  101. package/plugins/tsconfig.json +6 -0
  102. package/shared/AGENTS.core.md +10 -10
  103. package/shared/DEFAULTS.md +45 -0
  104. package/shared/SKILL.md +14 -0
  105. package/templates/agent/AGENTS.md +1 -1
  106. package/templates/agent/README.md +6 -6
  107. package/templates/agent/blend.yaml +3 -0
  108. package/templates/agent/gitignore +1 -0
  109. package/templates/agent/package.json +8 -8
  110. package/templates/agent/src/app.ts +46 -17
  111. package/templates/agent/src/main.ts +2 -2
  112. package/templates/agent/src/routes.ts +19 -6
  113. package/templates/agent/template.json +2 -1
  114. package/templates/agent/test/agent.test.ts +11 -5
  115. package/templates/agent/test/app.test.ts +30 -9
  116. package/templates/agent/test/builder.test.ts +35 -24
  117. package/templates/agent/test/model.test.ts +25 -20
  118. package/templates/agent/tsconfig.json +48 -50
  119. package/templates/blank/AGENTS.md +1 -1
  120. package/templates/blank/README.md +2 -2
  121. package/templates/blank/gitignore +1 -0
  122. package/templates/blank/package.json +8 -8
  123. package/templates/blank/src/about.tsx +2 -2
  124. package/templates/blank/src/app.ts +29 -9
  125. package/templates/blank/src/main.ts +1 -1
  126. package/templates/blank/src/routes.ts +1 -1
  127. package/templates/blank/test/app.test.ts +29 -10
  128. package/templates/blank/tsconfig.json +1 -1
  129. package/templates/chat/AGENTS.md +16 -0
  130. package/templates/chat/blend.yaml +29 -0
  131. package/templates/chat/gitignore +6 -0
  132. package/templates/chat/migrations/001-chat.sql +12 -0
  133. package/templates/chat/package.json +33 -0
  134. package/templates/chat/src/app.ts +106 -0
  135. package/templates/chat/src/chat-app.ts +101 -0
  136. package/templates/chat/src/client/chat.tsx +115 -0
  137. package/templates/chat/src/client/tsconfig.json +26 -0
  138. package/templates/chat/src/db.ts +43 -0
  139. package/templates/chat/src/door.ts +7 -0
  140. package/templates/chat/src/llm.ts +42 -0
  141. package/templates/chat/src/main.ts +28 -0
  142. package/templates/chat/src/page.tsx +40 -0
  143. package/templates/chat/src/routes.ts +15 -0
  144. package/templates/chat/src/runtime.ts +17 -0
  145. package/templates/chat/template.json +11 -0
  146. package/templates/chat/test/chat.test.ts +198 -0
  147. package/templates/chat/test/server.test.ts +78 -0
  148. package/templates/chat/tsconfig.json +20 -0
  149. package/templates/nodejs/AGENTS.md +9 -0
  150. package/templates/nodejs/blend.yaml +26 -0
  151. package/templates/nodejs/gitignore +3 -0
  152. package/templates/nodejs/package.json +30 -0
  153. package/templates/nodejs/src/app.ts +92 -0
  154. package/templates/nodejs/src/client/counter.tsx +12 -0
  155. package/templates/nodejs/src/client/tsconfig.json +26 -0
  156. package/templates/nodejs/src/door.ts +7 -0
  157. package/templates/nodejs/src/main.ts +25 -0
  158. package/templates/nodejs/src/page.tsx +29 -0
  159. package/templates/nodejs/src/routes.ts +40 -0
  160. package/templates/nodejs/template.json +3 -0
  161. package/templates/nodejs/test/app.test.ts +78 -0
  162. package/templates/nodejs/test/server.test.ts +83 -0
  163. package/templates/nodejs/tsconfig.json +20 -0
  164. package/templates/notes/AGENTS.md +26 -0
  165. package/templates/notes/blend.yaml +29 -0
  166. package/templates/notes/gitignore +6 -0
  167. package/templates/notes/migrations/001-notes.sql +18 -0
  168. package/templates/notes/package.json +33 -0
  169. package/templates/notes/src/app.ts +106 -0
  170. package/templates/notes/src/auth.ts +57 -0
  171. package/templates/notes/src/client/counter.tsx +21 -0
  172. package/templates/notes/src/client/tsconfig.json +26 -0
  173. package/templates/notes/src/db.ts +50 -0
  174. package/templates/notes/src/door.ts +7 -0
  175. package/templates/notes/src/main.ts +28 -0
  176. package/templates/notes/src/notes-app.ts +205 -0
  177. package/templates/notes/src/routes.ts +14 -0
  178. package/templates/notes/src/runtime.ts +17 -0
  179. package/templates/notes/src/schema.ts +38 -0
  180. package/templates/notes/src/views.tsx +136 -0
  181. package/templates/notes/template.json +11 -0
  182. package/templates/notes/test/notes.test.ts +280 -0
  183. package/templates/notes/test/server.test.ts +78 -0
  184. package/templates/notes/tsconfig.json +20 -0
  185. package/templates/todo/README.md +1 -1
  186. package/templates/todo/gitignore +1 -0
  187. package/templates/todo/package.json +8 -8
  188. package/templates/todo/src/app.ts +32 -12
  189. package/templates/todo/src/main.ts +1 -1
  190. package/templates/todo/src/todo.ts +4 -4
  191. package/templates/todo/test/todo.test.ts +48 -23
  192. package/templates/todo/tsconfig.json +1 -1
  193. package/templates/todo/upstream.json +2 -2
  194. package/lib/database-D_UN6We8.d.ts +0 -44
  195. package/lib/kv-DWDm0Lja.d.ts +0 -39
@@ -17,7 +17,7 @@ import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
17
17
  import { resolveDesktopUserDataOverride } from './user-data.ts'
18
18
  import { windowOptions } from './window.ts'
19
19
 
20
- const projectRoot = fileURLToPath(new URL('../../..', import.meta.url))
20
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
21
21
  const manifest = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8')) as {
22
22
  name?: string
23
23
  acrylblends?: { desktopPath?: string }
@@ -73,7 +73,7 @@ if (!app.requestSingleInstanceLock()) {
73
73
 
74
74
  void app.whenReady().then(async () => {
75
75
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
76
- const { default: acrylApp, close } = (await import('../../app.ts')) as {
76
+ const { default: acrylApp, close } = (await import('../../src/app.ts')) as {
77
77
  default: { fetch: Parameters<typeof serve>[0]['fetch'] }
78
78
  close?: () => Promise<void>
79
79
  }
@@ -9,9 +9,9 @@
9
9
  "electron": "44.9.3"
10
10
  },
11
11
  "scripts": {
12
- "desktop": "electron src/plugins/desktop-web/main.ts"
12
+ "desktop": "electron plugins/desktop-web/main.ts"
13
13
  },
14
14
  "gitignore": [".acrylblends/"],
15
15
  "omit": ["**/*.test.*"],
16
- "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in src/plugins/desktop-web)"
16
+ "mount": "nothing to mount: run `npm install`, then `npm run desktop` (the window opens on the app's own server; every electron call is in plugins/desktop-web)"
17
17
  }
@@ -5,7 +5,7 @@
5
5
  "apps/acryl-desktop/src/shell/desktop-user-data.ts": "user-data.ts",
6
6
  "apps/acryl-desktop/src/shell/shutdown.ts": "shutdown.ts"
7
7
  },
8
- "commit": "f8e385e4389fe2fdc6fd2a4366a7f2a7389faacb"
8
+ "commit": "20bdc263d6f0f8febbd967007bda4cf4a802efd2"
9
9
  },
10
10
  "rewrittenSpecifiers": 0,
11
11
  "digest": "sha256:7dfe960a8bbb476c35540d2202a7aaa62b33492c2ac3e34314e2ecfca45f32f2"
@@ -55,8 +55,24 @@ export interface ExtensionSource {
55
55
  readonly trust: Trust
56
56
  }
57
57
 
58
+ /** The library copies a plugins folder holds (`library.json`, written by `acrylblends add`): the app mounts them itself, so they are not extensions. */
59
+ function libraryCopies(dir: string): ReadonlySet<string> {
60
+ try {
61
+ const listed = (
62
+ JSON.parse(readFileSync(join(dir, 'library.json'), 'utf8')) as { plugins?: unknown }
63
+ ).plugins
64
+ return new Set(
65
+ Array.isArray(listed)
66
+ ? listed.filter((name): name is string => typeof name === 'string')
67
+ : [],
68
+ )
69
+ } catch {
70
+ return new Set()
71
+ }
72
+ }
73
+
58
74
  export interface ExtensionsConfig {
59
- /** Where extension folders may come from. Defaults to `<app home>/extensions`, trusted to install. */
75
+ /** Where extension folders may come from. An app passes its `plugins/` folder (library copies listed in its `library.json` are skipped). Defaults to `<app home>/extensions`, trusted to install, for a service used without an app folder. */
60
76
  sources?: readonly ExtensionSource[]
61
77
  /** When true, every source that would install by itself asks for approval instead (`ask`). */
62
78
  approval?: boolean
@@ -373,11 +389,13 @@ export class Extensions extends Service {
373
389
  const found: Array<{ name: string; dir: string; trust: Trust }> = []
374
390
  for (const source of this.sources) {
375
391
  if (!existsSync(source.dir)) continue
392
+ const library = libraryCopies(source.dir)
376
393
  for (const entry of readdirSync(source.dir, { withFileTypes: true })) {
377
394
  const dir = join(source.dir, entry.name)
378
395
  if (
379
396
  entry.isDirectory() &&
380
397
  NAME.test(entry.name) &&
398
+ !library.has(entry.name) &&
381
399
  ENTRIES.some((file) => existsSync(join(dir, file)))
382
400
  ) {
383
401
  if (!found.some((other) => other.name === entry.name))
@@ -8,5 +8,5 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@webboxes/blends-core": "0.4.0"
10
10
  },
11
- "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'extensions'), trust: 'install' }] })"
11
+ "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'plugins'), trust: 'install' }] })"
12
12
  }
@@ -4,10 +4,10 @@
4
4
  * module inside the staging folder, resolves the same name from the app's project folder. Uses Node's public `module.registerHooks`
5
5
  * (ACRYL needed the same fallback on Deno); the returned function removes the hook.
6
6
  *
7
- * The framework plugins are not packages in an app (they are source under `src/plugins/`), so `aliases` names the files a framework specifier such as
8
- * `acryl-router` stands for. A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
7
+ * The framework plugins are not packages in an app (they are source under `plugins/`), so `aliases` names the files a framework specifier such as
8
+ * `acryl-router` stands for (and the ports and providers beside it: `acryl-kv`, `acryl-kv-json`, `acryl-db`, ..., and each as `#acryl-kv` ... the name the app's `package.json` "imports" gives them). A staged plugin then imports the same module the app itself runs, so `App` is the very class the router mounts.
9
9
  */
10
- import { existsSync, mkdirSync, realpathSync } from 'node:fs'
10
+ import { existsSync, mkdirSync, readdirSync, realpathSync } from 'node:fs'
11
11
  import { registerHooks } from 'node:module'
12
12
  import { join } from 'node:path'
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -90,6 +90,20 @@ export function routerAliases(): Record<string, string> {
90
90
  add('acryl-router', 'index.ts')
91
91
  add('acryl-router/middleware', 'middleware/index.ts')
92
92
  add('acryl-router/helpers', 'helpers/index.ts')
93
+ // The ports and their providers sit beside the router as source too (`acrylblends add kv` copies `acryl-kv` and `acryl-kv-json`). A plugin installed live, a
94
+ // provider of your own for example, imports them by name and gets the very modules the app runs.
95
+ const plugins = new URL('../', import.meta.url)
96
+ for (const folder of readdirSync(fileURLToPath(plugins), { withFileTypes: true })) {
97
+ if (!folder.isDirectory() || !folder.name.startsWith('acryl-')) continue
98
+ const entry = fileURLToPath(new URL(`${folder.name}/src/index.ts`, plugins))
99
+ if (!existsSync(entry)) continue
100
+ // The bare name, and the app's own `#name` (a `package.json` "imports" entry that `acrylblends add` writes): a staged copy lives in the app's home, which may be
101
+ // outside the app folder, where Node would not find that entry.
102
+ aliases[folder.name] ??= entry
103
+ aliases[`#${folder.name}`] = entry
104
+ const suite = fileURLToPath(new URL(`${folder.name}/src/conformance.ts`, plugins))
105
+ if (existsSync(suite)) aliases[`#${folder.name}/conformance`] = suite
106
+ }
93
107
  return aliases
94
108
  }
95
109
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `npm run lock`: write `blend.lock.json` (formatVersion 3, the lock ACRYL writes too) next to the manifest, recording what the app is
3
3
  * built from: the manifest's identity and digest, the Blueprint it was resolved over, its resolved parameters and rows, and a content
4
- * digest of every plugin folder in `src/plugins/` and every extension in `extensions/`. Commit it; a diff of the lock is a review of
4
+ * digest of every plugin folder in `plugins/`. Commit it; a diff of the lock is a review of
5
5
  * what changed.
6
6
  */
7
7
  import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
@@ -69,7 +69,11 @@ export function writeLock(appRoot: string): LockV3 {
69
69
  version: document.metadata.version,
70
70
  digest: manifestDigest(text),
71
71
  },
72
- modules: [...localModules(appRoot, 'src/plugins'), ...localModules(appRoot, 'extensions')],
72
+ modules: [
73
+ ...localModules(appRoot, 'plugins'),
74
+ ...localModules(appRoot, 'src/plugins'),
75
+ ...localModules(appRoot, 'extensions'),
76
+ ],
73
77
  ...(lineage
74
78
  ? {
75
79
  lineage: {
@@ -86,7 +90,7 @@ export function writeLock(appRoot: string): LockV3 {
86
90
  }
87
91
 
88
92
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
89
- const appRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)))
93
+ const appRoot = resolve(fileURLToPath(new URL('../..', import.meta.url)))
90
94
  const locked = writeLock(appRoot)
91
95
  console.log(
92
96
  `wrote ${LOCK_FILE}: ${locked.rows.length} rows, ${locked.modules.length} local plugins`,
@@ -65,6 +65,6 @@ const parse = (argv: readonly string[]) =>
65
65
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
66
66
  process.exitCode = runUpgrade(
67
67
  process.argv.slice(2),
68
- resolve(fileURLToPath(new URL('../../..', import.meta.url))),
68
+ resolve(fileURLToPath(new URL('../..', import.meta.url))),
69
69
  )
70
70
  }
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
4
4
  import { saveFolder } from './index.ts'
5
5
 
6
6
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
7
- const appRoot = fileURLToPath(new URL('../../..', import.meta.url))
7
+ const appRoot = fileURLToPath(new URL('../..', import.meta.url))
8
8
  const result = saveFolder(appRoot, process.argv.slice(2).join(' ').trim() || 'save the app')
9
9
  if (result.status === 'refused') {
10
10
  console.error(`not saved: ${result.reason}`)
@@ -8,5 +8,5 @@
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
9
  "@webboxes/app-persistence": "0.1.1"
10
10
  },
11
- "mount": "add a script \"save\": \"node src/plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
11
+ "mount": "add a script \"save\": \"node plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
12
12
  }
@@ -26,6 +26,12 @@
26
26
  "acryl-db-sqlite/tests/loader.spec.ts",
27
27
  "acryl-kv-json/tests/loader.spec.ts",
28
28
  "acryl-kv-db/tests/loader.spec.ts",
29
+ "acryl-door/tests/harness.ts",
30
+ "acryl-door/tests/host/loader.spec.ts",
31
+ "acryl-door/tests/host/door.spec.ts",
32
+ "acryl-door/tests/host/shim.spec.ts",
33
+ "acryl-door/tests/host/verbs.spec.ts",
34
+ "acryl-door/tests/page",
29
35
  "acryl-state/src/react.ts",
30
36
  "acryl-state/tests/react.spec.ts",
31
37
  "acryl-router-http/tests/adapter.spec.ts",
@@ -9,17 +9,17 @@ Start at `docs/framework/README.md`; `api/summary.md` is the one-page summary. R
9
9
  `api/context.md` (`c`), `cordis/validation.md` and `guides/validation.md`, `guides/helpers.md` (`streamSSE`), `concepts/middleware.md`, `cordis/plugin-lifecycle.md` (what a plugin
10
10
  owns and when it is unloaded), `cordis/mounting.md` and `cordis/node-server.md`. Pages marked "not yet available" describe parts that are not in this app.
11
11
 
12
- The docs write `import { Acryl } from 'acrylblends'` and `from 'acrylblends/<subpath>'`. In this app the framework is the vendored plugin source in `src/plugins/acryl-router/src`
12
+ The docs write `import { Acryl } from 'acrylblends'` and `from 'acrylblends/<subpath>'`. In this app the framework is the vendored plugin source in `plugins/acryl-router/src`
13
13
  (read it, do not edit it: a change belongs in ACRYL, upstream), so from a file in `src/` import:
14
14
 
15
- - `./plugins/acryl-router/src/index.ts` for `Acryl`, `Context`, `HTTPException`, `connectionOf`, `getConnInfo` and the types;
16
- - `./plugins/acryl-router/src/middleware/index.ts` for every middleware (`cors`, `bodyLimit`, `trimTrailingSlash`, `loopbackOnly`, ...);
17
- - `./plugins/acryl-router/src/helpers/index.ts` for the helpers and validators (`streamSSE`, `schemaValidator`, `validator`, cookies, `testClient`, ...).
18
- - `./plugins/acryl-router/src/core/jsx/index.ts` for `acrylblends/jsx` (`jsx`, `Fragment`, the types), and `.../core/jsx/streaming.ts` for `acrylblends/jsx/streaming`.
15
+ - `../plugins/acryl-router/src/index.ts` for `Acryl`, `Context`, `HTTPException`, `connectionOf`, `getConnInfo` and the types;
16
+ - `../plugins/acryl-router/src/middleware/index.ts` for every middleware (`cors`, `bodyLimit`, `trimTrailingSlash`, `loopbackOnly`, ...);
17
+ - `../plugins/acryl-router/src/helpers/index.ts` for the helpers and validators (`streamSSE`, `schemaValidator`, `validator`, cookies, `testClient`, ...).
18
+ - `../plugins/acryl-router/src/core/jsx/index.ts` for `acrylblends/jsx` (`jsx`, `Fragment`, the types), and `.../core/jsx/streaming.ts` for `acrylblends/jsx/streaming`.
19
19
 
20
- **JSX: write `.tsx`.** The scripts in `package.json` run Node.js with `--import ./src/plugins/acryl-node-server/src/register.ts`, which compiles `.tsx` files with this app's own `typescript`; `tsconfig.json` sets `jsx: react-jsx` and `jsxImportSource: #acryl-jsx`, which `package.json`'s `imports` maps to the vendored runtime in `src/plugins/acryl-router`. See `src/about.tsx` (blank template) for a page and `test/app.test.ts` for how it is tested. Render a component with `.toString()` and return it with `c.html(...)`; import `jsx`, `Fragment` and the types from `./plugins/acryl-router/src/core/jsx/index.ts` when you need them. A command that starts the app without those scripts (a bare `node src/main.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')`, children after the props.
20
+ **JSX: write `.tsx`.** The scripts in `package.json` run Node.js with `--import ./plugins/acryl-node-server/src/register.ts`, which compiles `.tsx` files with this app's own `typescript`; `tsconfig.json` sets `jsx: react-jsx` and `jsxImportSource: #acryl-jsx`, which `package.json`'s `imports` maps to the vendored runtime in `plugins/acryl-router`. See `src/about.tsx` (blank template) for a page and `test/app.test.ts` for how it is tested. Render a component with `.toString()` and return it with `c.html(...)`; import `jsx`, `Fragment` and the types from `../plugins/acryl-router/src/core/jsx/index.ts` when you need them. A command that starts the app without those scripts (a bare `node src/main.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')`, children after the props.
21
21
 
22
- A plugin written into `extensions/` while the app runs may use the docs' own names instead: `acrylblends` and `acrylblends/<subpath>` resolve to the same files. `App` is the
22
+ A plugin written into `plugins/` while the app runs may use the docs' own names instead: `acrylblends` and `acrylblends/<subpath>` resolve to the same files. `App` is the
23
23
  deprecated name of `Acryl`.
24
24
 
25
25
  ## How a feature is built
@@ -28,8 +28,8 @@ Routes are an ordinary `Acryl` app that a plugin mounts once with `ctx.router.ro
28
28
 
29
29
  ```ts
30
30
  import type { Context } from '@deepseek-ai/cordis'
31
- import { Acryl, HTTPException } from './plugins/acryl-router/src/index.ts'
32
- import { schemaValidator, type StandardSchema } from './plugins/acryl-router/src/helpers/index.ts'
31
+ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
32
+ import { schemaValidator, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
33
33
 
34
34
  export const name = 'notes' // a plugin's name; blend.yaml refers to it
35
35
  export const inject = ['router'] // services it needs
@@ -44,7 +44,7 @@ export function apply(ctx: Context) {
44
44
 
45
45
  - A small route goes on the `App` in `src/routes.ts`. A new feature is a new plugin file: add it to `registry` in `src/app.ts`, add a row
46
46
  (`- id: notes` / `name: notes`) to `blend.yaml` before the `acryl-node-server` row, and pass runtime values (a data file) as its config in `createApp`.
47
- - State, when `src/plugins/acryl-state` exists: `const cell = ctx.state.cell<T>('notes.list', initial)` (add `'state'` to `inject`), then
47
+ - State, when `plugins/acryl-state` exists: `const cell = ctx.state.cell<T>('notes.list', initial)` (add `'state'` to `inject`), then
48
48
  `cell.get()`, `cell.set(value)`, `cell.update(fn)` and `cell.subscribe(fn)` (returns the unsubscribe). Every cell is also served as JSON at `/api/state/<key>`
49
49
  and streamed at `/api/state/<key>/events`.
50
50
  - Test a route without a socket: `await ctx.router.request('/notes')`, or boot the app as `test/app.test.ts` does and use `fetch`.
@@ -0,0 +1,45 @@
1
+
2
+ ## Defaults you can swap
3
+
4
+ The framework is not rigid. Every choice is a plugin behind a port, and what the app starts with is only a fallback. Name the default before you change it,
5
+ and swap it at the one place that mounts it.
6
+
7
+ | Choice | Default | To swap it |
8
+ | --- | --- | --- |
9
+ | Records | the `db` port on SQLite (already mounted in the notes and chat templates; elsewhere `npx acrylblends add db`) | mount another provider of `db` in `src/app.ts`; plugins use `ctx.db` and do not change |
10
+ | Small state | the `kv` port on one JSON file (`npx acrylblends add kv`) | mount `acryl-kv-db` (the same port over `db`) or a provider of your own |
11
+ | Server | Node (`acryl-node-server`) | mount the adapter of another runtime in `src/app.ts` |
12
+ | Window | the browser | `npx acrylblends add desktop` adds an Electron window on the same server |
13
+
14
+ - **An outside tool** (a database server, a bundler, a router such as Vike) joins the app as a provider: a plugin in `plugins/<name>/` that implements a port and
15
+ passes that port's conformance tests, then `src/app.ts` mounts it. Postgres is such a provider and is not part of this package; add it when the app needs it.
16
+ - **The dependency rule:** only the file that mounts a provider imports it; everything else takes the port as an argument or injects it. `npx acrylblends check-deps` enforces it.
17
+
18
+ ## Plugins
19
+
20
+ There is one plugin folder: `plugins/` at the root of the app. A plugin is a folder `plugins/<name>/` with an `index.ts`. `npx acrylblends add <name>` copies a library
21
+ plugin there; a plugin you or an agent write goes there too (the `extensions` plugin installs it into the running app, and `plugins/library.json` lists the copies it must not
22
+ install a second time). Do not create `src/plugins/` or `extensions/`.
23
+
24
+ ## The door
25
+
26
+ The door is a command in this folder, `.acryl/bin/acryl`, that lets you look at and change the running app. The running app writes it, and it reaches only the app running from this folder. Its state (a secret and an audit log) lives under `.acryl/`, which git ignores: never commit it. It is on in development and off with `NODE_ENV=production` or `ACRYL_DOOR=off`; one process at a time serves a folder's door, so a second `npm start` in the same folder stops with a message that says so (the tests set `ACRYL_DOOR=off`). If `.acryl/bin/acryl` is not here, the app is not running: start it with `npm run dev` (in the background) and open its address in a browser.
27
+
28
+ ```
29
+ .acryl/bin/acryl brief what this app is: its plugins, where the docs are, the verbs, and whether a page is open
30
+ .acryl/bin/acryl look the page as text, with refs (1.1, 1.2, ...): roles, names, values, visible text
31
+ .acryl/bin/acryl act click --ref <r>
32
+ .acryl/bin/acryl act type --ref <r> --text <t> [--submit] [--no-clear]
33
+ .acryl/bin/acryl act select --ref <r> --option <o>
34
+ .acryl/bin/acryl act press --key <Enter|Escape|...>
35
+ .acryl/bin/acryl act scroll [--ref <r>] --direction up|down [--amount <n>]
36
+ .acryl/bin/acryl act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
37
+ .acryl/bin/acryl plugin list | add <folder> | remove <name> | reload
38
+ .acryl/bin/acryl tool list | call --tool <name> --args '<json>'
39
+ .acryl/bin/acryl verbs the verb list
40
+ ```
41
+
42
+ - `look` and `act` need the app's page open in a browser (`brief` says `Page: not open` when it is not). Look again before each `act`: refs belong to the latest `look`.
43
+ - A password field is not shown and cannot be typed into through the door; sign in by hand, or test the sign-in with a test.
44
+ - Every verb takes `--json`. The exit code is 0 for done, 1 when the verb failed, 2 for a usage error and 3 when no app is running in this folder.
45
+ - `plugin add plugins/<name>` checks the plugin, installs it and makes it live without a restart (when the app has the `extensions` plugin; `acrylblends init --template agent` does). `tool call` reaches only the tools on the door's allowlist (empty by default). On Windows the command is `.acryl\bin\acryl.cmd`.
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: app-guide
3
+ description: How to change, run and check the __APP_NAME__ app. Use it before adding a feature, a route, a plugin or a dependency to this app, and before saying the work is done.
4
+ ---
5
+
6
+ # Working in __APP_NAME__
7
+
8
+ 1. **Read first.** `AGENTS.md` in this folder says how the app is laid out. The framework's API is in `docs/framework/`: start at `docs/framework/README.md`, and
9
+ `docs/framework/api/summary.md` is the one-page summary. Parts the docs mark "not yet available" are not in this app.
10
+ 2. **Make the change small.** Your own routes are a plugin (`src/routes.ts`) that builds an `Acryl` app and mounts it; test a route with `await app.request('/path')`.
11
+ 3. **One plugin folder.** A plugin lives in `plugins/<name>/` (`npx acrylblends add <name>` copies a library plugin there). There is no `src/plugins/` and no `extensions/`.
12
+ 4. **Defaults are swappable.** The database, the key-value store, the server and the window are plugins behind ports. The table "Defaults you can swap" in `AGENTS.md` names each default and where it is swapped. Never reach around a port to a driver: `npx acrylblends check-deps` fails the check.
13
+ 5. **Look at the running app.** Start it (`npm run dev`, in the background), open its address in a browser, then run `.acryl/bin/acryl brief`, `look` and `act`: the door shows the page as text with refs and clicks or types on it. Read the section "The door" of `AGENTS.md` for the verbs. Without the door, check the page by hand.
14
+ 6. **Done means checked.** `npm test` passes, and the change shows in the running app after a restart. No secret goes in a file or in `git status`: keys come from environment variables.
@@ -1,6 +1,6 @@
1
1
 
2
2
  ## This app (agent)
3
3
 
4
- Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `extensions/` while the app runs and install them with
4
+ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plugins into `plugins/` while the app runs and install them with
5
5
  rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
6
6
  and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
@@ -13,14 +13,14 @@ curl -X POST <url>/ask -H 'content-type: application/json' -d '{"question":"add
13
13
  # {"status":"completed","output":"The answer is 5.","steps":2}
14
14
  ```
15
15
 
16
- - `src/main.ts` chooses where the app lives (`src/plugins/instance`) and starts it.
16
+ - `src/main.ts` chooses where the app lives (`plugins/instance`) and starts it.
17
17
  - `blend.yaml` is the app as data: which plugins it mounts, in what order, with what config and parameters. Switch
18
18
  one off with `disabled: true`. `npm run lock` writes `blend.lock.json`: the manifest's digest and a digest of
19
19
  every plugin folder, so a diff of the lock shows what changed.
20
20
  - `src/app.ts` holds the registry of plugins the code provides, and mounts the manifest's rows.
21
- - `src/plugins/agent` is the offline agent: a scripted model, a tool registry, memory, and one child fiber per run.
22
- - `src/plugins/extensions` installs, updates and removes plugins while the app runs; a failed update keeps the previous
23
- version running. `src/plugins/builder` gives the agent tools to do that itself, writing into `extensions/`.
21
+ - `plugins/agent` is the offline agent: a scripted model, a tool registry, memory, and one child fiber per run.
22
+ - `plugins/extensions` installs, updates and removes plugins while the app runs; a failed update keeps the previous
23
+ version running. `plugins/builder` gives the agent tools to do that itself, writing into `plugins/`.
24
24
 
25
25
  Give it a real model and it builds any Cordis plugin you describe (any OpenAI-compatible API; nothing is installed):
26
26
 
@@ -40,9 +40,9 @@ curl <url>/tasks # every run as a durable task, with the tool calls
40
40
  ```
41
41
 
42
42
  Other commands: `update route <name> <path> <text>`, `break plugin <name>` (shows the rollback), `remove plugin <name>`,
43
- `list plugins`. What the agent writes lands in `extensions/`, is part of your app, and starts again at the next start.
43
+ `list plugins`. What the agent writes lands in `plugins/`, is part of your app, and starts again at the next start.
44
44
  - `src/routes.ts` is your own plugin: it builds an `App` (`import { App } from './plugins/acryl-router/src/index.ts'`) and mounts it with `ctx.router.route(prefix, app)`. Routes added to the app after it is mounted are not served. A page mounted at `/x` answers `/x`, not `/x/` (trailing slashes are strict). The router API is documented at https://acrylblends.github.io/docs/framework/the-acryl-framework and in `docs/framework` of the acrylblends repository.
45
- - `src/plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
45
+ - `plugins/` holds the plugins copied from the ACRYLBLENDS library. They are yours now; edit them freely.
46
46
  Add more with `acrylblends add <plugin>`.
47
47
 
48
48
  `npm run save -- "message"` commits the app with git (and pushes when it has a remote). It refuses a file that looks like
@@ -54,3 +54,6 @@ spec:
54
54
  name: acryl-node-server
55
55
  config:
56
56
  hostname: "{{parameters.hostname}}"
57
+ # The door: an agent working in this folder looks at the page and acts on it through .acryl/bin/acryl. Development only: src/app.ts leaves it out when NODE_ENV=production.
58
+ - id: acryl-door
59
+ name: acryl-door
@@ -1,2 +1,3 @@
1
1
  node_modules/
2
2
  .acrylblends/
3
+ .acryl/
@@ -4,20 +4,20 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "imports": {
7
- "#acryl-jsx/jsx-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
- "#acryl-jsx/jsx-dev-runtime": "./src/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
7
+ "#acryl-jsx/jsx-runtime": "./plugins/acryl-router/src/core/jsx/jsx-runtime.ts",
8
+ "#acryl-jsx/jsx-dev-runtime": "./plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=22.19.0"
12
12
  },
13
13
  "scripts": {
14
- "start": "node --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
15
- "dev": "node --watch --import ./src/plugins/acryl-node-server/src/register.ts src/main.ts",
16
- "test": "node --import ./src/plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
14
+ "start": "node --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
15
+ "dev": "node --watch --import ./plugins/acryl-node-server/src/register.ts src/main.ts",
16
+ "test": "node --import ./plugins/acryl-node-server/src/register.ts --test \"test/**/*.test.{ts,tsx}\"",
17
17
  "check": "tsc --noEmit",
18
- "lock": "node src/plugins/manifest/lock.ts",
19
- "save": "node src/plugins/save/cli.ts",
20
- "upgrade": "node src/plugins/manifest/upgrade-cli.ts"
18
+ "lock": "node plugins/manifest/lock.ts",
19
+ "save": "node plugins/save/cli.ts",
20
+ "upgrade": "node plugins/manifest/upgrade-cli.ts"
21
21
  },
22
22
  "dependencies": {
23
23
  "typescript": "6.0.3"
@@ -1,23 +1,25 @@
1
+ import { existsSync } from 'node:fs'
1
2
  import { join } from 'node:path'
2
3
  import { fileURLToPath } from 'node:url'
3
4
  import { Context, type Fiber } from '@deepseek-ai/cordis'
4
- import * as nodeServer from './plugins/acryl-node-server/src/index.ts'
5
- import * as router from './plugins/acryl-router/src/index.ts'
6
- import * as state from './plugins/acryl-state/src/index.ts'
7
- import { agent, memory, model, tools } from './plugins/agent/index.ts'
8
- import { builder } from './plugins/builder/index.ts'
9
- import { extensions } from './plugins/extensions/index.ts'
10
- import { type AppInstance, instance } from './plugins/instance/index.ts'
5
+ import * as door from '../plugins/acryl-door/src/index.ts'
6
+ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
7
+ import * as router from '../plugins/acryl-router/src/index.ts'
8
+ import * as state from '../plugins/acryl-state/src/index.ts'
9
+ import { agent, memory, model, tools } from '../plugins/agent/index.ts'
10
+ import { builder } from '../plugins/builder/index.ts'
11
+ import { extensions } from '../plugins/extensions/index.ts'
12
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
11
13
  import {
12
14
  blueprintsIn,
13
15
  compose,
14
16
  loadManifest,
15
17
  type ParameterValue,
16
- } from './plugins/manifest/index.ts'
17
- import { type OpenAIConfig, openai } from './plugins/openai/index.ts'
18
- import { sandboxPlugin } from './plugins/sandbox/index.ts'
19
- import { save } from './plugins/save/index.ts'
20
- import { tasks } from './plugins/tasks/index.ts'
18
+ } from '../plugins/manifest/index.ts'
19
+ import { type OpenAIConfig, openai } from '../plugins/openai/index.ts'
20
+ import { sandboxPlugin } from '../plugins/sandbox/index.ts'
21
+ import { save } from '../plugins/save/index.ts'
22
+ import { tasks } from '../plugins/tasks/index.ts'
21
23
  import { routes } from './routes.ts'
22
24
 
23
25
  /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them. */
@@ -35,6 +37,7 @@ export const registry = {
35
37
  sandbox: sandboxPlugin,
36
38
  routes,
37
39
  'acryl-node-server': nodeServer,
40
+ 'acryl-door': door,
38
41
  }
39
42
 
40
43
  /** The manifest next to this app's source. */
@@ -54,12 +57,13 @@ export function readManifest(values?: Readonly<Record<string, ParameterValue>>)
54
57
 
55
58
  export interface App {
56
59
  readonly root: Context
60
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
57
61
  readonly url: string
58
62
  stop(): Promise<void>
59
63
  }
60
64
 
61
65
  /**
62
- * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's own `extensions/`
66
+ * The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. The app's `plugins/`
63
67
  * folder is trusted to install at start, and is where the builder writes.
64
68
  */
65
69
  export async function createApp(
@@ -68,10 +72,30 @@ export async function createApp(
68
72
  values?: Readonly<Record<string, ParameterValue>>
69
73
  /** A real model (any OpenAI-compatible API) in place of the offline scripted one. */
70
74
  model?: OpenAIConfig
75
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
76
+ listen?: boolean
71
77
  } = {},
72
78
  ): Promise<App> {
73
- const manifest = readManifest(options.values)
74
- const extensionsDir = join(chosen.root, 'extensions')
79
+ const read = readManifest(options.values)
80
+ // Rows left out: the server (and so the door, which needs it) when the app is reached in process; the door in production and with ACRYL_DOOR=off (one process at a time
81
+ // serves a folder's door, so the tests that start several apps set it).
82
+ const listen = options.listen !== false
83
+ const leaveOut = new Set<string>()
84
+ if (!listen) leaveOut.add('acryl-node-server').add('acryl-door')
85
+ if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off')
86
+ leaveOut.add('acryl-door')
87
+ const manifest = {
88
+ ...read,
89
+ plugins: read.plugins.map((row) => (leaveOut.has(row.use) ? { ...row, disabled: true } : row)),
90
+ }
91
+ const extensionsDir = join(chosen.root, 'plugins')
92
+ // One release of grace: an app made before 0.7.8 kept what the agent wrote in `extensions/`. It is still installed, with a warning.
93
+ const legacyDir = join(chosen.root, 'extensions')
94
+ const legacy = existsSync(legacyDir)
95
+ if (legacy)
96
+ console.warn(
97
+ 'warning: extensions/ is the old folder for plugins the agent writes; move them to plugins/ ("git mv extensions/<name> plugins/<name>"). extensions/ is read for this release only.',
98
+ )
75
99
  const root = new Context()
76
100
  const first = root.plugin(instance, chosen)
77
101
  await first.await()
@@ -83,14 +107,19 @@ export async function createApp(
83
107
  // Runtime values the manifest cannot hold: the manifest's own digest (every task records it), and who may install.
84
108
  tasks: { manifest: `sha256:${manifest.digest}` },
85
109
  // The `approval` parameter in blend.yaml (the extensions row) turns this source into one that asks.
86
- extensions: { sources: [{ dir: extensionsDir, trust: 'install' }] },
110
+ extensions: {
111
+ sources: [
112
+ { dir: extensionsDir, trust: 'install' },
113
+ ...(legacy ? [{ dir: legacyDir, trust: 'install' as const }] : []),
114
+ ],
115
+ },
87
116
  builder: { dir: extensionsDir },
88
117
  server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0, hostname: chosen.host },
89
118
  })),
90
119
  ]
91
120
  return {
92
121
  root,
93
- url: root.nodeServer.url,
122
+ url: listen ? root.nodeServer.url : '',
94
123
  async stop() {
95
124
  for (const fiber of [...fibers].reverse()) await fiber.dispose()
96
125
  },
@@ -1,7 +1,7 @@
1
1
  import { fileURLToPath } from 'node:url'
2
+ import { selectInstance } from '../plugins/instance/index.ts'
3
+ import { modelConfigFrom } from '../plugins/openai/index.ts'
2
4
  import { createApp } from './app.ts'
3
- import { selectInstance } from './plugins/instance/index.ts'
4
- import { modelConfigFrom } from './plugins/openai/index.ts'
5
5
  import { APP_NAME } from './routes.ts'
6
6
 
7
7
  // The composition root: the one place that reads the environment. selectInstance chooses where the app lives;
@@ -1,12 +1,18 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
- import { Acryl } from './plugins/acryl-router/src/index.ts'
3
- import { bodyLimit } from './plugins/acryl-router/src/middleware/index.ts'
4
- import type {} from './plugins/agent/index.ts'
5
- import type {} from './plugins/extensions/index.ts'
6
- import type {} from './plugins/tasks/index.ts'
2
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
3
+ import { bodyLimit } from '../plugins/acryl-router/src/middleware/index.ts'
4
+ import type {} from '../plugins/agent/index.ts'
5
+ import type {} from '../plugins/extensions/index.ts'
6
+ import type {} from '../plugins/tasks/index.ts'
7
7
 
8
8
  export const APP_NAME = '__APP_NAME__'
9
9
 
10
+ /** The door's page script (`.acryl/bin/acryl look` and `act`), in development only: `src/app.ts` leaves the door out in production and with ACRYL_DOOR=off. */
11
+ const DOOR_TAG =
12
+ process.env.NODE_ENV !== 'production' && process.env.ACRYL_DOOR !== 'off'
13
+ ? '<script type="module" src="/api/acryl-door/page.js"></script>'
14
+ : ''
15
+
10
16
  /**
11
17
  * The app's own routes on the router: `POST /ask` for the agent; `GET /extensions` and `GET /tasks` to observe what it did (every run is a
12
18
  * durable task owning one task per tool call); `POST /tasks/cancel`. What changes is also streamed: `GET /api/state/tasks/events` and
@@ -25,7 +31,14 @@ export const routes = {
25
31
  }
26
32
  const app = new Acryl()
27
33
  .use(bodyLimit({ maxSize: 64 * 1024 }))
28
- .get('/', (c) => c.json({ app: APP_NAME, ok: true }))
34
+ // A browser gets a page (the door's and the agent panel's script tags are added where they are mounted); everything else gets the JSON.
35
+ .get('/', (c) =>
36
+ (c.req.header('accept') ?? '').includes('text/html')
37
+ ? c.html(
38
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent. Ask it for a feature (<code>POST /ask</code>), or add the chat panel with <code>npx acrylblends add agent-panel</code>.</p>${DOOR_TAG}</body></html>`,
39
+ )
40
+ : c.json({ app: APP_NAME, ok: true }),
41
+ )
29
42
  .get('/health', (c) => c.json({ ok: true }))
30
43
  .get('/extensions', (c) =>
31
44
  c.json({
@@ -11,6 +11,7 @@
11
11
  "extensions",
12
12
  "builder",
13
13
  "manifest",
14
- "save"
14
+ "save",
15
+ "acryl-door"
15
16
  ]
16
17
  }
@@ -1,17 +1,23 @@
1
1
  import assert from 'node:assert/strict'
2
+ import { mkdirSync } from 'node:fs'
2
3
  import { mkdtemp, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { test } from 'node:test'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
7
8
  import { type App, createApp } from '../src/app.ts'
8
- import { pinnedInstance, withPort } from '../src/plugins/instance/index.ts'
9
+
10
+ /** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
11
+ const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
12
+ mkdirSync(scratch, { recursive: true })
9
13
 
10
14
  const projectRoot = fileURLToPath(new URL('..', import.meta.url))
11
15
 
12
16
  async function withApp(run: (app: App) => Promise<void>) {
13
- const home = await mkdtemp(join(tmpdir(), 'agent-test-'))
14
- const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0))
17
+ const home = await mkdtemp(join(scratch, 'agent-test-'))
18
+ const app = await createApp(withPort(pinnedInstance(home, { root: projectRoot }), 0), {
19
+ listen: false,
20
+ })
15
21
  try {
16
22
  await run(app)
17
23
  } finally {
@@ -21,7 +27,7 @@ async function withApp(run: (app: App) => Promise<void>) {
21
27
  }
22
28
 
23
29
  const ask = (app: App, body: unknown) =>
24
- fetch(`${app.url}/ask`, {
30
+ app.root.router.request(`/ask`, {
25
31
  method: 'POST',
26
32
  headers: { 'content-type': 'application/json' },
27
33
  body: JSON.stringify(body),