@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.1

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 (216) hide show
  1. package/README.md +822 -135
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/config/portal-route-roots.json +48 -0
  7. package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
  8. package/dist/base-skills/notis-apps/SKILL.md +93 -0
  9. package/dist/base-skills/notis-apps/references/architecture.md +114 -0
  10. package/dist/base-skills/notis-apps/references/context.md +81 -0
  11. package/dist/base-skills/notis-apps/references/design.md +97 -0
  12. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  13. package/dist/base-skills/notis-apps/references/reading.md +67 -0
  14. package/dist/base-skills/notis-apps/references/release.md +107 -0
  15. package/dist/base-skills/notis-apps/references/sdk.md +116 -0
  16. package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
  17. package/dist/base-skills/notis-apps/references/views.md +142 -0
  18. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  19. package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
  20. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  21. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  22. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  23. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  24. package/dist/base-skills/notis-query/SKILL.md +41 -0
  25. package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
  26. package/dist/base-skills/notis-query/references/documents.md +63 -0
  27. package/dist/base-skills/notis-query/references/query.md +76 -0
  28. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  29. package/dist/sdk/src/agentContext.ts +36 -0
  30. package/dist/sdk/src/bundleModule.ts +79 -0
  31. package/dist/sdk/src/components/Dialog.tsx +46 -0
  32. package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
  33. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  34. package/dist/sdk/src/components/Markdown.tsx +60 -0
  35. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  36. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  37. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  38. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  39. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  40. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  41. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  42. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  43. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  44. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  45. package/dist/sdk/src/config.ts +239 -0
  46. package/dist/sdk/src/documents.ts +258 -0
  47. package/dist/sdk/src/hooks/useAction.ts +28 -0
  48. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  49. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  50. package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
  51. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  52. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  53. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  54. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  56. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  57. package/dist/sdk/src/hooks/useHandover.ts +78 -0
  58. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  59. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  60. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  61. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  62. package/dist/sdk/src/hooks/useNotis.ts +42 -0
  63. package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
  64. package/dist/sdk/src/hooks/useQuery.ts +74 -0
  65. package/dist/sdk/src/hooks/useTool.ts +65 -0
  66. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  67. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  68. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  69. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  70. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  71. package/dist/sdk/src/index.ts +200 -0
  72. package/dist/sdk/src/interactions/actions.ts +59 -0
  73. package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
  74. package/dist/sdk/src/interactions/visibility.ts +13 -0
  75. package/dist/sdk/src/interactions.ts +45 -0
  76. package/dist/sdk/src/nativeData.ts +39 -0
  77. package/dist/sdk/src/presentation.tsx +82 -0
  78. package/dist/sdk/src/provider.tsx +49 -0
  79. package/dist/sdk/src/queryCache.ts +172 -0
  80. package/dist/sdk/src/queryErrors.ts +51 -0
  81. package/dist/sdk/src/runtime.ts +603 -0
  82. package/dist/sdk/src/space.ts +425 -0
  83. package/dist/sdk/src/styles.css +290 -0
  84. package/dist/sdk/src/tailwind.ts +66 -0
  85. package/dist/sdk/src/vite.ts +83 -0
  86. package/dist/skill-sync/index.js +2143 -0
  87. package/dist/skill-sync/index.js.map +7 -0
  88. package/dist/skill-sync-worker.mjs +3487 -0
  89. package/dist/space-harness/host.js +66 -0
  90. package/dist/view-renderer/dist/host.css +1 -0
  91. package/dist/view-renderer/dist/host.js +1042 -0
  92. package/dist/view-renderer/dist/theme.css +73 -0
  93. package/dist/view-renderer/src/dom.js +180 -0
  94. package/dist/view-renderer/src/errors.js +8 -0
  95. package/dist/view-renderer/src/host.tsx +173 -0
  96. package/dist/view-renderer/src/index.js +4 -0
  97. package/dist/view-renderer/src/pool.js +76 -0
  98. package/dist/view-renderer/src/remote-broker.js +59 -0
  99. package/dist/view-renderer/src/renderer.js +201 -0
  100. package/package.json +25 -8
  101. package/skills/notis-apps/cli.md +406 -0
  102. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  103. package/skills/notis-onboarding/BRIEF.md +133 -0
  104. package/skills/notis-query/cli.md +39 -0
  105. package/src/agent-hook-entry.js +5 -0
  106. package/src/cli.js +294 -25
  107. package/src/command-specs/agents.js +392 -0
  108. package/src/command-specs/apps.js +1476 -203
  109. package/src/command-specs/auth.js +114 -137
  110. package/src/command-specs/diagnostics.js +730 -0
  111. package/src/command-specs/files.js +29 -0
  112. package/src/command-specs/handover.js +374 -0
  113. package/src/command-specs/helpers.js +86 -82
  114. package/src/command-specs/index.js +31 -6
  115. package/src/command-specs/meta.js +150 -18
  116. package/src/command-specs/onboarding.js +290 -0
  117. package/src/command-specs/profile.js +358 -0
  118. package/src/command-specs/skills.js +304 -0
  119. package/src/command-specs/smoke.js +386 -0
  120. package/src/command-specs/spaces.js +572 -0
  121. package/src/command-specs/tools.js +455 -139
  122. package/src/command-specs/views.js +69 -0
  123. package/src/runtime/agent-browser.js +700 -0
  124. package/src/runtime/agent-memory-state.js +126 -0
  125. package/src/runtime/agent-setup.js +383 -0
  126. package/src/runtime/app-boundary-validator.js +407 -0
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-data-literals.js +82 -0
  129. package/src/runtime/app-platform.js +2752 -237
  130. package/src/runtime/app-registry-scaffolds.js +367 -0
  131. package/src/runtime/app-test-server.js +293 -0
  132. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  133. package/src/runtime/auth-recovery.js +110 -0
  134. package/src/runtime/base-skills.d.ts +20 -0
  135. package/src/runtime/base-skills.js +167 -0
  136. package/src/runtime/channel.js +133 -0
  137. package/src/runtime/confined-space-build.js +74 -0
  138. package/src/runtime/confined-space-verify.js +49 -0
  139. package/src/runtime/delegated-context.js +68 -0
  140. package/src/runtime/errors.js +1 -0
  141. package/src/runtime/git.js +233 -0
  142. package/src/runtime/login-listener.js +15 -0
  143. package/src/runtime/native-record-files.js +47 -0
  144. package/src/runtime/oauth.js +2622 -0
  145. package/src/runtime/output.js +37 -5
  146. package/src/runtime/ports.js +31 -0
  147. package/src/runtime/profiles.js +906 -55
  148. package/src/runtime/retired-sdk-files.js +20 -0
  149. package/src/runtime/screenshot-framing.js +85 -0
  150. package/src/runtime/skill-sync/cloud-client.ts +121 -0
  151. package/src/runtime/skill-sync/index.ts +905 -0
  152. package/src/runtime/skill-sync/local-scanner.ts +1259 -0
  153. package/src/runtime/skill-sync/native-identity.ts +102 -0
  154. package/src/runtime/skill-sync/native-plan.ts +59 -0
  155. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  156. package/src/runtime/skill-sync/sync-plan.ts +54 -0
  157. package/src/runtime/skill-sync/types.ts +157 -0
  158. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  159. package/src/runtime/skill-sync-service.js +109 -0
  160. package/src/runtime/source-root.js +32 -0
  161. package/src/runtime/space-collection-scaffold.js +66 -0
  162. package/src/runtime/space-database-structures.js +229 -0
  163. package/src/runtime/space-fixture-broker.js +50 -0
  164. package/src/runtime/space-lock.js +337 -0
  165. package/src/runtime/space-platform.js +105 -0
  166. package/src/runtime/space-source.js +281 -0
  167. package/src/runtime/space-test-server.js +327 -0
  168. package/src/runtime/space-verification.js +89 -0
  169. package/src/runtime/space-verify-browser-worker.js +51 -0
  170. package/src/runtime/space-view-manifest.js +254 -0
  171. package/src/runtime/store-screenshot.js +146 -0
  172. package/src/runtime/sync-skills.d.ts +37 -0
  173. package/src/runtime/sync-skills.js +231 -0
  174. package/src/runtime/telemetry.js +92 -0
  175. package/src/runtime/transport.js +331 -45
  176. package/src/runtime/view-render.js +49 -0
  177. package/src/skill-sync-worker-entry.js +2 -0
  178. package/src/skill-sync-worker.js +50 -0
  179. package/src/space-harness-host.ts +103 -0
  180. package/template/.harness/index.html.tmpl +435 -0
  181. package/template/CHANGELOG.md +5 -0
  182. package/template/app/globals.css +29 -3
  183. package/template/app/layout.tsx +6 -3
  184. package/template/app/page.tsx +49 -42
  185. package/template/components/page-heading.tsx +23 -0
  186. package/template/components/ui/badge.tsx +7 -4
  187. package/template/components/ui/button.tsx +1 -1
  188. package/template/components/ui/card.tsx +24 -11
  189. package/template/components/ui/native-select.tsx +24 -0
  190. package/template/notis.config.ts +24 -6
  191. package/template/package-lock.json +3642 -0
  192. package/template/package.json +19 -16
  193. package/template/postcss.config.mjs +1 -1
  194. package/template/tailwind.config.ts +1 -6
  195. package/template/tsconfig.json +1 -0
  196. package/src/command-specs/db.js +0 -163
  197. package/src/runtime/app-preview-server.js +0 -312
  198. package/template/packages/notis-sdk/src/config.ts +0 -48
  199. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  200. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  201. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  202. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  203. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  204. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
  205. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
  206. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  207. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  208. package/template/packages/notis-sdk/src/index.ts +0 -47
  209. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  210. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  211. package/template/packages/notis-sdk/src/styles.css +0 -123
  212. package/template/packages/notis-sdk/src/vite.ts +0 -54
  213. package/template/packages/notis-sdk/tsconfig.json +0 -15
  214. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  215. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  216. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -1,37 +1,96 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
3
+ import { dirname } from 'node:path';
2
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { delegatedContextReason } from './delegated-context.js';
6
+ import {
7
+ credentialIsExpired,
8
+ getJwtExpiration,
9
+ getJwtSubject,
10
+ getProfile,
11
+ isJwtExpired,
12
+ loadConfig,
13
+ resolveWorktreeRuntime,
14
+ } from './profiles.js';
15
+ import { createExpiredAuthError, createInvalidAuthHints } from './auth-recovery.js';
16
+ import { refreshOAuthCredential } from './oauth.js';
3
17
 
4
- const META_TOOL_NAMES = new Set([
5
- 'notis_find_toolkits',
6
- 'notis_find_tools',
7
- 'notis_execute_tool',
8
- 'notis_find_additional_tools',
9
- ]);
18
+ function escapeMultipartHeaderValue(value) {
19
+ return String(value ?? '')
20
+ .replace(/"/g, '%22')
21
+ .replace(/\r/g, '%0D')
22
+ .replace(/\n/g, '%0A');
23
+ }
10
24
 
11
- function canonicalizeToolName(toolName) {
12
- if (typeof toolName !== 'string' || !toolName.length) {
13
- return toolName;
14
- }
25
+ function multipartTextPart(boundary, name, value) {
26
+ return [
27
+ `--${boundary}`,
28
+ `Content-Disposition: form-data; name="${escapeMultipartHeaderValue(name)}"`,
29
+ '',
30
+ value,
31
+ '',
32
+ ].join('\r\n');
33
+ }
15
34
 
16
- if (META_TOOL_NAMES.has(toolName) || toolName.startsWith('notis-default-')) {
17
- return toolName;
18
- }
35
+ function multipartFileHeader(boundary, binding) {
36
+ const filename = escapeMultipartHeaderValue(binding.basename || binding.field_name);
37
+ return [
38
+ `--${boundary}`,
39
+ `Content-Disposition: form-data; name="${escapeMultipartHeaderValue(binding.field_name)}"; filename="${filename}"`,
40
+ `Content-Type: ${binding.contentType || 'application/octet-stream'}`,
41
+ '',
42
+ '',
43
+ ].join('\r\n');
44
+ }
19
45
 
20
- if (toolName.startsWith('notis_')) {
21
- return `notis-default-${toolName.slice('notis_'.length)}`;
46
+ async function* multipartFileBody(boundary, payload, bindingMetadata, fileBindings) {
47
+ yield Buffer.from(multipartTextPart(boundary, 'payload', JSON.stringify(payload)));
48
+ yield Buffer.from(multipartTextPart(boundary, 'file_bindings', JSON.stringify(bindingMetadata)));
49
+ for (const binding of fileBindings) {
50
+ yield Buffer.from(multipartFileHeader(boundary, binding));
51
+ for await (const chunk of createReadStream(binding.localPath)) {
52
+ yield chunk;
53
+ }
54
+ yield Buffer.from('\r\n');
22
55
  }
56
+ yield Buffer.from(`--${boundary}--\r\n`);
57
+ }
23
58
 
24
- if (toolName.includes('_notis_')) {
25
- return `notis-default-${toolName.replace(/_notis_/g, '_')}`;
26
- }
59
+ function createMultipartFileUpload(payload, bindingMetadata, fileBindings) {
60
+ const boundary = `----notis-cli-${randomUUID().replace(/-/g, '')}`;
61
+ const textParts = [
62
+ multipartTextPart(boundary, 'payload', JSON.stringify(payload)),
63
+ multipartTextPart(boundary, 'file_bindings', JSON.stringify(bindingMetadata)),
64
+ ];
65
+ const fileHeaderParts = fileBindings.map((binding) => multipartFileHeader(boundary, binding));
66
+ const closingPart = `--${boundary}--\r\n`;
67
+ const contentLength = [
68
+ ...textParts,
69
+ ...fileHeaderParts,
70
+ ...fileBindings.map(() => '\r\n'),
71
+ closingPart,
72
+ ].reduce((total, part) => total + Buffer.byteLength(part), 0)
73
+ + fileBindings.reduce((total, binding) => total + binding.size, 0);
27
74
 
28
- return toolName;
75
+ return {
76
+ body: multipartFileBody(boundary, payload, bindingMetadata, fileBindings),
77
+ headers: {
78
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
79
+ 'Content-Length': String(contentLength),
80
+ },
81
+ duplex: 'half',
82
+ };
29
83
  }
30
84
 
31
- function normalizeBackendError(status, payload) {
85
+ export function normalizeBackendError(status, payload, runtime) {
32
86
  const backendError = payload?.error;
87
+ // Space errors answer {error: <stable code>, message: <readable text>}: show
88
+ // the text (it can name the Space involved); the code stays in details.
89
+ const codedMessage = typeof backendError === 'string' && /^[a-z][a-z0-9_]*$/.test(backendError)
90
+ && typeof payload?.message === 'string' && payload.message ? payload.message : null;
33
91
  const message =
34
92
  backendError?.message ||
93
+ codedMessage ||
35
94
  backendError ||
36
95
  payload?.message ||
37
96
  payload?.error ||
@@ -43,10 +102,7 @@ function normalizeBackendError(status, payload) {
43
102
  message,
44
103
  exitCode: EXIT_CODES.auth,
45
104
  hints: [
46
- {
47
- command: 'notis auth login --jwt <token>',
48
- reason: 'Refresh the stored JWT for this profile',
49
- },
105
+ ...createInvalidAuthHints(runtime),
50
106
  ],
51
107
  details: payload || {},
52
108
  });
@@ -77,6 +133,18 @@ function normalizeBackendError(status, payload) {
77
133
  });
78
134
  }
79
135
 
136
+ if (status === 402) {
137
+ // Distinct from auth on purpose: an agent that sees an auth exit code will
138
+ // loop trying to re-authenticate, when the actual fix is to add credit.
139
+ return new CliError({
140
+ code: 'payment_required',
141
+ message,
142
+ exitCode: EXIT_CODES.payment,
143
+ details: payload || {},
144
+ hints: payload?.hints || [],
145
+ });
146
+ }
147
+
80
148
  if (status >= 400 && status < 500) {
81
149
  return new CliError({
82
150
  code: 'usage_error',
@@ -97,45 +165,235 @@ function normalizeBackendError(status, payload) {
97
165
  });
98
166
  }
99
167
 
168
+ /**
169
+ * Pick up a dev credential that `./dev.sh` re-minted mid-process.
170
+ *
171
+ * Restarting the local runtime rewrites the worktree lease in place, so a
172
+ * long-running command can recover from a single 401 instead of failing and
173
+ * making the caller rerun it. The refreshed token still has to belong to the
174
+ * worktree's approved test user.
175
+ */
176
+ function reloadDevJwt(runtime, loadedProfile = null) {
177
+ if (runtime.credentialKind !== 'worktree') {
178
+ return false;
179
+ }
180
+ let nextJwt = null;
181
+ const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
182
+ const runtimePath = runtime.worktreeRuntime?.runtime_path;
183
+ if (runtimePath) {
184
+ const refreshedRuntime = resolveWorktreeRuntime(dirname(runtimePath));
185
+ if (
186
+ refreshedRuntime
187
+ && !refreshedRuntime.unavailable
188
+ && refreshedRuntime.runtime_path === runtimePath
189
+ && refreshedRuntime.profile === runtime.profileName
190
+ && refreshedRuntime.api_base === runtime.apiBase
191
+ ) {
192
+ if (
193
+ expectedUserId
194
+ && refreshedRuntime.expected_user_id !== expectedUserId
195
+ ) {
196
+ throw new CliError({
197
+ code: 'dev_runtime_identity_mismatch',
198
+ message: 'The restarted worktree runtime belongs to a different test user',
199
+ exitCode: EXIT_CODES.auth,
200
+ hints: [
201
+ { message: 'Restart this CLI command under the worktree\'s current dev identity.' },
202
+ { message: `Expected user: ${expectedUserId}` },
203
+ ],
204
+ });
205
+ }
206
+ runtime.worktreeRuntime = refreshedRuntime;
207
+ nextJwt = refreshedRuntime.dev_access_token;
208
+ }
209
+ }
210
+ let profile = loadedProfile;
211
+ if (!nextJwt && !profile) {
212
+ try {
213
+ profile = getProfile(loadConfig(), runtime.profileName);
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+ nextJwt = nextJwt || (
219
+ typeof profile?.dev_access_token === 'string' && profile.dev_access_token
220
+ ? profile.dev_access_token
221
+ : null
222
+ );
223
+ if (!nextJwt || nextJwt === runtime.jwt) {
224
+ return false;
225
+ }
226
+ if (expectedUserId && getJwtSubject(nextJwt) !== expectedUserId) {
227
+ throw new CliError({
228
+ code: 'dev_runtime_identity_mismatch',
229
+ message: 'The refreshed dev credential does not belong to this worktree test user',
230
+ exitCode: EXIT_CODES.auth,
231
+ hints: [
232
+ { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
233
+ { message: `Expected user: ${expectedUserId}` },
234
+ ],
235
+ });
236
+ }
237
+ runtime.jwt = nextJwt;
238
+ return true;
239
+ }
240
+
241
+ /**
242
+ * A live worktree lease is authoritative over any stale dev metadata left in
243
+ * the shared profile store. Prefer its explicit expiry, then the active JWT's
244
+ * expiry, and deliberately fail closed when neither can be verified.
245
+ */
246
+ export function getActiveWorktreeCredentialProfile(runtime, loadedProfile = {}) {
247
+ if (runtime.credentialKind !== 'worktree') {
248
+ return loadedProfile;
249
+ }
250
+ return {
251
+ ...loadedProfile,
252
+ dev_access_expires_at:
253
+ runtime.worktreeRuntime?.dev_access_expires_at
254
+ ?? getJwtExpiration(runtime.jwt)
255
+ ?? loadedProfile.dev_access_expires_at,
256
+ };
257
+ }
258
+
100
259
  export async function httpRequest({
101
260
  runtime,
102
261
  method = 'POST',
103
262
  path,
104
263
  body,
264
+ multipart = false,
105
265
  requireAuth = true,
266
+ spacesProtocol,
106
267
  }) {
107
- const controller = new AbortController();
108
- const timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
268
+ // Refresh before spending the credential rather than after a rejection: the
269
+ // rotating refresh token is shared with every other `notis` process reading
270
+ // the same profile, so a lapsed access token is renewed once, under the
271
+ // config write lock, instead of racing on a 401 retry.
272
+ let currentProfile;
273
+ if (runtime.credentialKind === 'oauth') {
274
+ if (requireAuth && credentialIsExpired(runtime, {
275
+ oauth_access_expires_at: runtime.oauthAccessExpiresAt,
276
+ })) {
277
+ await refreshOAuthCredential(runtime);
278
+ }
279
+ currentProfile = getProfile(loadConfig(), runtime.profileName);
280
+ } else {
281
+ currentProfile = getProfile(loadConfig(), runtime.profileName);
282
+ reloadDevJwt(runtime, currentProfile);
283
+ currentProfile = getActiveWorktreeCredentialProfile(runtime, currentProfile);
284
+ }
285
+ if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
286
+ throw createExpiredAuthError(runtime);
287
+ }
288
+
289
+ let controller = new AbortController();
290
+ let timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
109
291
  const requestId = `req_${randomUUID().replace(/-/g, '')}`;
292
+ const resetTimeout = () => {
293
+ clearTimeout(timeout);
294
+ controller = new AbortController();
295
+ timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
296
+ };
110
297
 
111
298
  const headers = {
112
- 'Content-Type': 'application/json',
113
299
  'X-Notis-CLI-Version': runtime.cliVersion,
114
300
  'X-Notis-Request-Id': requestId,
115
301
  };
302
+ if (spacesProtocol !== undefined) headers['X-Notis-Spaces-Protocol'] = String(spacesProtocol);
303
+ if (!multipart) {
304
+ headers['Content-Type'] = 'application/json';
305
+ }
116
306
 
117
307
  if (requireAuth && runtime.jwt) {
118
308
  headers.Authorization = `Bearer ${runtime.jwt}`;
119
309
  }
120
310
 
311
+ const resolveBody = () => {
312
+ if (!multipart) {
313
+ return {
314
+ body: body ? JSON.stringify(body) : undefined,
315
+ headers: {},
316
+ };
317
+ }
318
+ if (typeof body === 'function') {
319
+ return body();
320
+ }
321
+ return {
322
+ body,
323
+ headers: {},
324
+ };
325
+ };
326
+
121
327
  try {
122
- const response = await fetch(`${runtime.apiBase}${path}`, {
328
+ let requestBody = resolveBody();
329
+ let response = await fetch(`${runtime.apiBase}${path}`, {
123
330
  method,
124
- headers,
125
- body: body ? JSON.stringify(body) : undefined,
331
+ headers: { ...headers, ...requestBody.headers },
332
+ body: requestBody.body,
126
333
  signal: controller.signal,
334
+ ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
127
335
  });
128
- clearTimeout(timeout);
129
336
 
130
337
  let payload = null;
338
+ let payloadReadError = null;
131
339
  try {
132
340
  payload = await response.json();
133
- } catch {
341
+ } catch (error) {
342
+ payloadReadError = error;
134
343
  payload = null;
135
344
  }
136
345
 
346
+ if (response.status === 401) {
347
+ const refreshed = runtime.credentialKind === 'oauth'
348
+ ? await refreshOAuthCredential(runtime)
349
+ : reloadDevJwt(runtime) && !isJwtExpired(runtime.jwt);
350
+ if (refreshed) {
351
+ if (requireAuth && runtime.jwt) {
352
+ headers.Authorization = `Bearer ${runtime.jwt}`;
353
+ }
354
+ resetTimeout();
355
+ requestBody = resolveBody();
356
+ response = await fetch(`${runtime.apiBase}${path}`, {
357
+ method,
358
+ headers: { ...headers, ...requestBody.headers },
359
+ body: requestBody.body,
360
+ signal: controller.signal,
361
+ ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
362
+ });
363
+ payloadReadError = null;
364
+ try {
365
+ payload = await response.json();
366
+ } catch (error) {
367
+ payloadReadError = error;
368
+ payload = null;
369
+ }
370
+ }
371
+ }
372
+
373
+ clearTimeout(timeout);
374
+
137
375
  if (!response.ok) {
138
- throw normalizeBackendError(response.status, payload);
376
+ if (
377
+ response.status === 401
378
+ && credentialIsExpired(
379
+ runtime,
380
+ getActiveWorktreeCredentialProfile(
381
+ runtime,
382
+ getProfile(loadConfig(), runtime.profileName),
383
+ ),
384
+ )
385
+ ) {
386
+ throw createExpiredAuthError(runtime);
387
+ }
388
+ throw normalizeBackendError(response.status, payload, runtime);
389
+ }
390
+
391
+ // A successful status is not a successful tool call until its response
392
+ // body has been read. Mutations may already have committed before a socket
393
+ // reset truncates the JSON; surface that as an ambiguous network failure
394
+ // so callers never persist an undefined result or attempt a second write.
395
+ if (response.status !== 204 && payloadReadError) {
396
+ throw payloadReadError;
139
397
  }
140
398
 
141
399
  return {
@@ -177,22 +435,50 @@ export async function callTool({
177
435
  toolName,
178
436
  arguments_: argumentsPayload = {},
179
437
  idempotencyKey,
438
+ fileBindings = [],
180
439
  }) {
181
440
  const requestId = idempotencyKey || (runtime.mutating ? randomUUID() : null);
441
+ const payload = {
442
+ tool_name: toolName,
443
+ arguments: argumentsPayload,
444
+ idempotency_key: requestId,
445
+ cli_context: {
446
+ output_mode: runtime.outputMode,
447
+ profile: runtime.profileName,
448
+ cwd: process.cwd(),
449
+ agent_mode: runtime.agentMode,
450
+ cli_version: runtime.cliVersion,
451
+ // Reported so the server can refuse work-handing tools from a run Notis
452
+ // is itself driving. Advisory -- a determined agent can unset the markers
453
+ // -- so the server treats it as one layer, not the whole guard.
454
+ ...(delegatedContextReason() ? { delegated_context: true } : {}),
455
+ ...(runtime.debugEntitlementOverride
456
+ ? { debug_entitlement_override: runtime.debugEntitlementOverride }
457
+ : {}),
458
+ },
459
+ };
460
+
461
+ if (Array.isArray(fileBindings) && fileBindings.length) {
462
+ const bindingMetadata = fileBindings.map((binding) => {
463
+ const {
464
+ contentType,
465
+ localPath,
466
+ ...metadata
467
+ } = binding;
468
+ return metadata;
469
+ });
470
+
471
+ return httpRequest({
472
+ runtime,
473
+ path: '/cli_tools',
474
+ body: () => createMultipartFileUpload(payload, bindingMetadata, fileBindings),
475
+ multipart: true,
476
+ });
477
+ }
478
+
182
479
  return httpRequest({
183
480
  runtime,
184
481
  path: '/cli_tools',
185
- body: {
186
- tool_name: canonicalizeToolName(toolName),
187
- arguments: argumentsPayload,
188
- idempotency_key: requestId,
189
- cli_context: {
190
- output_mode: runtime.outputMode,
191
- profile: runtime.profileName,
192
- cwd: process.cwd(),
193
- agent_mode: runtime.agentMode,
194
- cli_version: runtime.cliVersion,
195
- },
196
- },
482
+ body: payload,
197
483
  });
198
484
  }
@@ -0,0 +1,49 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, writeFile, unlink } from 'node:fs/promises';
3
+ import { resolve, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { httpRequest } from './transport.js';
6
+ import { CliError, EXIT_CODES, usageError } from './errors.js';
7
+
8
+ async function rendererLibrary() {
9
+ const packaged = new URL('../../dist/view-renderer/src/index.js', import.meta.url);
10
+ if (existsSync(fileURLToPath(packaged))) return import(packaged.href);
11
+ const development = new URL('../../../view-renderer/src/index.js', import.meta.url);
12
+ if (existsSync(fileURLToPath(development))) return import(development.href);
13
+ throw usageError('The shared view renderer is missing. Rebuild or reinstall this CLI package.');
14
+ }
15
+
16
+ /** Root command registration supplies flags/output formatting; this adapter holds no browser JWT. */
17
+ export async function renderViewWithCliRuntime({ runtime, url, outputs = ['markdown', 'screenshot'], width = 1440,
18
+ directory, signal, loadLibrary = rendererLibrary, request = httpRequest }) {
19
+ if (typeof url !== 'string' || !url.trim() || ![390, 1440].includes(width)) throw usageError('Choose a view link and width 390 or 1440.');
20
+ const ticket = (await request({ runtime, method: 'POST', path: '/portal_view_render/prepare', spacesProtocol: 1,
21
+ body: { url, outputs, width }, signal })).payload;
22
+ if (typeof ticket?.credential !== 'string') throw usageError('The short-lived render ticket is missing.');
23
+ const library = await loadLibrary();
24
+ const broker = library.createRemoteRenderBroker({ apiBase: runtime.apiBase, credential: ticket.credential });
25
+ let result;
26
+ try {
27
+ result = await library.renderView({ broker, outputs, width, signal });
28
+ await broker.complete(result, { signal });
29
+ }
30
+ catch (error) {
31
+ if (error instanceof library.ViewRenderError) throw new CliError({ code: error.code,
32
+ message: error.message, exitCode: EXIT_CODES.backend, retryable: error.status >= 500 || error.status === 429, cause: error });
33
+ throw error;
34
+ }
35
+ if (!directory) return { result, artifacts: null };
36
+ const root = resolve(directory), created = [], artifacts = {};
37
+ await mkdir(root, { recursive: true });
38
+ try {
39
+ for (const [kind, name, bytes] of [
40
+ ['markdown', 'view.md', result.markdown === undefined ? undefined : Buffer.from(result.markdown, 'utf8')],
41
+ ['screenshot', 'view.png', result.screenshot ? Buffer.from(result.screenshot.data_base64, 'base64') : undefined],
42
+ ]) {
43
+ if (bytes === undefined) continue;
44
+ const path = join(root, name);
45
+ await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); created.push(path); artifacts[kind] = path;
46
+ }
47
+ } catch (error) { await Promise.allSettled(created.map(path => unlink(path))); throw error; }
48
+ return { result, artifacts };
49
+ }
@@ -0,0 +1,2 @@
1
+ import { main } from './skill-sync-worker.js';
2
+ await main();
@@ -0,0 +1,50 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { resolveRuntimeProfile } from './runtime/profiles.js';
5
+ import { ensureFreshOAuthCredential } from './runtime/oauth.js';
6
+ import { runSkillSync, fetchSyncSettings } from '../dist/skill-sync/index.js';
7
+ import { withSkillSyncLock } from './runtime/sync-skills.js';
8
+
9
+ export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
10
+ resolveRuntime = resolveRuntimeProfile, refresh = ensureFreshOAuthCredential,
11
+ settings = fetchSyncSettings, sync = runSkillSync, lock = withSkillSyncLock,
12
+ } = {}) {
13
+ const runtime = resolveRuntime({ profile }, { requireAuth: true });
14
+ if (runtime.credentialKind !== 'oauth' || runtime.apiBase !== apiBase
15
+ || runtime.oauthUserId !== userId) throw new Error('Automatic skill sync account changed; run notis skills sync to rebind');
16
+ await refresh(runtime);
17
+ const saved = await settings(runtime.apiBase, runtime.jwt);
18
+ if (saved.user_id !== userId) throw new Error('Automatic skill sync identity mismatch');
19
+ // Do not gather, unlink foreign accounts or write anything when opted out.
20
+ if (!saved.sync_enabled) return { status: 'disabled' };
21
+ const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
22
+ fetchSyncSettings: async () => saved,
23
+ }, { honorSyncEnabled: true }));
24
+ return { status: (result.failedLinks?.length || result.failedPushes?.length) ? 'partial' : 'synced', ...result };
25
+ }
26
+
27
+ export async function main(args = process.argv.slice(2)) {
28
+ const [profile, apiBase, userId] = args;
29
+ const root = join(homedir(), '.notis', 'skills', 'service');
30
+ const record = (value) => {
31
+ mkdirSync(root, { recursive: true, mode: 0o700 });
32
+ const target = join(root, 'status.json');
33
+ const temporary = `${target}.${process.pid}.tmp`;
34
+ writeFileSync(temporary, JSON.stringify({ ...value, profile, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
35
+ renameSync(temporary, target);
36
+ };
37
+ const deadline = setTimeout(() => {
38
+ record({ status: 'error', code: 'sync_timeout' });
39
+ process.exit(1);
40
+ }, 240_000);
41
+ try {
42
+ record(await runAutomaticSkillSync({ profile, apiBase, userId }));
43
+ } catch (error) {
44
+ // Persist only a classified error, never a bearer, signed URL or response body.
45
+ record({ status: 'error', code: error.code || 'sync_failed' });
46
+ process.exitCode = 1;
47
+ } finally {
48
+ clearTimeout(deadline);
49
+ }
50
+ }
@@ -0,0 +1,103 @@
1
+ import React from 'react';
2
+ import * as ReactDOM from 'react-dom';
3
+ import * as ReactDOMClient from 'react-dom/client';
4
+ import * as JSXRuntime from 'react/jsx-runtime';
5
+ import { importBundleSource, resolveBundleRouteExport } from '../../sdk/src/bundleModule';
6
+ import { SdkPresentation } from '../../sdk/src/presentation';
7
+ import { createPrefetchQueue, createQueryClient } from '../../sdk/src/queryCache';
8
+ import type { NotisRuntime } from '../../sdk/src/runtime';
9
+ import { SPACE_VIEWER_READ_OPERATIONS } from '../../sdk/src/space';
10
+ import type { SpaceActionOptions, SpaceDocumentBodyRequest, SpaceViewerReadOperation } from '../../sdk/src/space';
11
+
12
+ type Call = { op: string; actionId?: string; ok: boolean | null; error?: string };
13
+ const state = { mounted: false, renderStarted: false, errors: [] as Array<{ phase: string; message: string }>, runtimeCalls: [] as Call[] };
14
+ Object.assign(window, { __harness: state, React, ReactDOM, ReactDOMClient, __ReactJSXRuntime: JSXRuntime });
15
+ const diagnostic = (phase: string, reason: unknown) => state.errors.push({ phase,
16
+ message: reason instanceof Error ? reason.message : String(reason || 'Verification failed.') });
17
+ window.addEventListener('error', event => diagnostic('window_error', event.error || event.message));
18
+ window.addEventListener('unhandledrejection', event => diagnostic('unhandled_rejection', event.reason));
19
+ window.addEventListener('securitypolicyviolation', event => diagnostic('security_policy', `Blocked ${event.violatedDirective}`));
20
+
21
+ async function start() {
22
+ const response = await fetch('/snapshot', { credentials: 'omit' });
23
+ if (!response.ok) throw new Error('The frozen verification snapshot could not be loaded.');
24
+ const snapshot = await response.json();
25
+ const authority = structuredClone(snapshot.descriptor.space);
26
+ const runFixture = async (call: Call, input: unknown) => {
27
+ state.runtimeCalls.push(call);
28
+ try {
29
+ const result = await fetch('/fixture', { method: 'POST', credentials: 'omit',
30
+ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(5_000) });
31
+ const payload = await result.json();
32
+ if (!result.ok) throw new Error(payload.message || 'No matching synthetic fixture.');
33
+ call.ok = true; return payload.result;
34
+ } catch (error) {
35
+ call.ok = false; call.error = error instanceof Error ? error.message : 'Verification request failed.';
36
+ throw error;
37
+ }
38
+ };
39
+ const protocolFailure = (message: string): Promise<never> => { diagnostic('protocol', message); return Promise.reject(new Error(message)); };
40
+ const deny = () => protocolFailure('Use a declared Space action.');
41
+ const queue = createPrefetchQueue(2);
42
+ const runtime: NotisRuntime = {
43
+ ...snapshot.descriptor, databases: [],
44
+ context: { ...snapshot.descriptor.context,
45
+ resourceId: snapshot.descriptor.context?.resource_id ?? snapshot.descriptor.context?.resourceId ?? null,
46
+ collectionItem: snapshot.descriptor.context?.collection_item ?? snapshot.descriptor.context?.collectionItem ?? null },
47
+ queryClient: createQueryClient({ maxEntries: 100, schedule: queue }),
48
+ listTools: deny, callTool: deny, request: deny,
49
+ callAction<T>(id: string, inputs: Record<string, unknown> = {}, options: SpaceActionOptions = {}): Promise<T> {
50
+ if (!Object.hasOwn(authority.actions, id)) return protocolFailure('Undeclared verification action.');
51
+ if ((options.readOnly || options.dedupe) && authority.actions[id].readOnly !== true) {
52
+ return protocolFailure('A write action cannot run as a cached read.');
53
+ }
54
+ return runFixture({ op: 'callAction', actionId: id, ok: null }, { action_id: id, inputs });
55
+ },
56
+ viewerRead: authority.viewerReads?.length ? <T,>(operation: SpaceViewerReadOperation, input: Record<string, unknown> = {}): Promise<T> => {
57
+ const family = Object.hasOwn(SPACE_VIEWER_READ_OPERATIONS, operation) ? SPACE_VIEWER_READ_OPERATIONS[operation] : null;
58
+ if (!family || !authority.viewerReads.includes(family)) return protocolFailure('Undeclared verification viewer read.');
59
+ return runFixture({ op: 'viewerRead', actionId: operation, ok: null }, { viewer_read: { operation, input } });
60
+ } : undefined,
61
+ shown: Object.values(authority.shows || {}).some(list => 'database' in list) ? <T,>(name: string, params: Record<string, unknown> = {}): Promise<T> => {
62
+ const list = authority.shows && Object.hasOwn(authority.shows, name) ? authority.shows[name] : null;
63
+ if (!list || !('database' in list)) return protocolFailure('Undeclared verification list.');
64
+ return runFixture({ op: 'shown', actionId: name, ok: null }, { shown: { name, params } });
65
+ } : undefined,
66
+ documentBody(request: SpaceDocumentBodyRequest) {
67
+ return runFixture({ op: 'documentBody', actionId: request?.readAction, ok: null }, { document_body: request });
68
+ },
69
+ navigate(payload) {
70
+ if (payload.kind !== 'space-reference' || typeof payload.alias !== 'string' || !authority.navigation?.includes(payload.alias)) {
71
+ return protocolFailure('Offline navigation requires a declared synthetic destination.');
72
+ }
73
+ // This observes a declaration only; no live navigation or access is claimed.
74
+ state.runtimeCalls.push({ op: 'navigate', actionId: payload.alias, ok: true });
75
+ },
76
+ publishActiveResource() {}, captureContextSelection() {},
77
+ addContext: async () => false, updateContext: async () => false, removeContext: async () => false,
78
+ registerTopBarSearch() {}, setTopBarSearchValue() {}, setTopBarSearchLoading() {},
79
+ };
80
+ const module = await importBundleSource(snapshot.source);
81
+ const view = resolveBundleRouteExport(module, ['SpaceView'], true);
82
+ if (!view) throw new Error('The SpaceView export is missing from this presentation.');
83
+ const host = document.createElement('div'); host.style.cssText = 'min-height:100vh;height:100vh';
84
+ const shadow = host.attachShadow({ mode: 'open' });
85
+ const css = document.createElement('style'); css.textContent = snapshot.css; shadow.appendChild(css);
86
+ const mount = document.createElement('div'); mount.setAttribute('data-notis-app-mount', 'true');
87
+ mount.style.cssText = 'min-height:0;height:100%'; shadow.appendChild(mount);
88
+ document.body.appendChild(host);
89
+ const controller = document.createElement('div'); document.body.appendChild(controller);
90
+ const mode = new URLSearchParams(location.search).get('theme') === 'dark' ? 'dark' : 'light';
91
+ document.documentElement.classList.toggle('dark', mode === 'dark');
92
+ state.renderStarted = true;
93
+ ReactDOMClient.createRoot(controller).render(React.createElement(SdkPresentation, {
94
+ mount, runtime, component: view.component, shell: module.__AppShell, theme: { mode, style: { colorScheme: mode } },
95
+ resetKey: snapshot.cacheKey,
96
+ onCommit: (hasContent: boolean) => { state.mounted = hasContent; },
97
+ onError: (error: Error) => { state.mounted = false; state.errors.push({ phase: 'render', message: error.message }); },
98
+ onProtocolError: (error: Error) => diagnostic('protocol', error),
99
+ fallback: (error: Error) => React.createElement('div', { role: 'alert' }, error.message),
100
+ }));
101
+ }
102
+
103
+ void start().catch(reason => state.errors.push({ phase: 'boot', message: reason instanceof Error ? reason.message : 'Verification did not start.' }));