@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -1,34 +1,88 @@
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
+ function normalizeBackendError(status, payload, runtime) {
32
86
  const backendError = payload?.error;
33
87
  const message =
34
88
  backendError?.message ||
@@ -43,10 +97,7 @@ function normalizeBackendError(status, payload) {
43
97
  message,
44
98
  exitCode: EXIT_CODES.auth,
45
99
  hints: [
46
- {
47
- command: 'notis auth login --jwt <token>',
48
- reason: 'Refresh the stored JWT for this profile',
49
- },
100
+ ...createInvalidAuthHints(runtime),
50
101
  ],
51
102
  details: payload || {},
52
103
  });
@@ -77,6 +128,18 @@ function normalizeBackendError(status, payload) {
77
128
  });
78
129
  }
79
130
 
131
+ if (status === 402) {
132
+ // Distinct from auth on purpose: an agent that sees an auth exit code will
133
+ // loop trying to re-authenticate, when the actual fix is to add credit.
134
+ return new CliError({
135
+ code: 'payment_required',
136
+ message,
137
+ exitCode: EXIT_CODES.payment,
138
+ details: payload || {},
139
+ hints: payload?.hints || [],
140
+ });
141
+ }
142
+
80
143
  if (status >= 400 && status < 500) {
81
144
  return new CliError({
82
145
  code: 'usage_error',
@@ -97,45 +160,233 @@ function normalizeBackendError(status, payload) {
97
160
  });
98
161
  }
99
162
 
163
+ /**
164
+ * Pick up a dev credential that `./dev.sh` re-minted mid-process.
165
+ *
166
+ * Restarting the local runtime rewrites the worktree lease in place, so a
167
+ * long-running command can recover from a single 401 instead of failing and
168
+ * making the caller rerun it. The refreshed token still has to belong to the
169
+ * worktree's approved test user.
170
+ */
171
+ function reloadDevJwt(runtime, loadedProfile = null) {
172
+ if (runtime.credentialKind !== 'worktree') {
173
+ return false;
174
+ }
175
+ let nextJwt = null;
176
+ const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
177
+ const runtimePath = runtime.worktreeRuntime?.runtime_path;
178
+ if (runtimePath) {
179
+ const refreshedRuntime = resolveWorktreeRuntime(dirname(runtimePath));
180
+ if (
181
+ refreshedRuntime
182
+ && !refreshedRuntime.unavailable
183
+ && refreshedRuntime.runtime_path === runtimePath
184
+ && refreshedRuntime.profile === runtime.profileName
185
+ && refreshedRuntime.api_base === runtime.apiBase
186
+ ) {
187
+ if (
188
+ expectedUserId
189
+ && refreshedRuntime.expected_user_id !== expectedUserId
190
+ ) {
191
+ throw new CliError({
192
+ code: 'dev_runtime_identity_mismatch',
193
+ message: 'The restarted worktree runtime belongs to a different test user',
194
+ exitCode: EXIT_CODES.auth,
195
+ hints: [
196
+ { message: 'Restart this CLI command under the worktree\'s current dev identity.' },
197
+ { message: `Expected user: ${expectedUserId}` },
198
+ ],
199
+ });
200
+ }
201
+ runtime.worktreeRuntime = refreshedRuntime;
202
+ nextJwt = refreshedRuntime.dev_access_token;
203
+ }
204
+ }
205
+ let profile = loadedProfile;
206
+ if (!nextJwt && !profile) {
207
+ try {
208
+ profile = getProfile(loadConfig(), runtime.profileName);
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+ nextJwt = nextJwt || (
214
+ typeof profile?.dev_access_token === 'string' && profile.dev_access_token
215
+ ? profile.dev_access_token
216
+ : null
217
+ );
218
+ if (!nextJwt || nextJwt === runtime.jwt) {
219
+ return false;
220
+ }
221
+ if (expectedUserId && getJwtSubject(nextJwt) !== expectedUserId) {
222
+ throw new CliError({
223
+ code: 'dev_runtime_identity_mismatch',
224
+ message: 'The refreshed dev credential does not belong to this worktree test user',
225
+ exitCode: EXIT_CODES.auth,
226
+ hints: [
227
+ { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
228
+ { message: `Expected user: ${expectedUserId}` },
229
+ ],
230
+ });
231
+ }
232
+ runtime.jwt = nextJwt;
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * A live worktree lease is authoritative over any stale dev metadata left in
238
+ * the shared profile store. Prefer its explicit expiry, then the active JWT's
239
+ * expiry, and deliberately fail closed when neither can be verified.
240
+ */
241
+ export function getActiveWorktreeCredentialProfile(runtime, loadedProfile = {}) {
242
+ if (runtime.credentialKind !== 'worktree') {
243
+ return loadedProfile;
244
+ }
245
+ return {
246
+ ...loadedProfile,
247
+ dev_access_expires_at:
248
+ runtime.worktreeRuntime?.dev_access_expires_at
249
+ ?? getJwtExpiration(runtime.jwt)
250
+ ?? loadedProfile.dev_access_expires_at,
251
+ };
252
+ }
253
+
100
254
  export async function httpRequest({
101
255
  runtime,
102
256
  method = 'POST',
103
257
  path,
104
258
  body,
259
+ multipart = false,
105
260
  requireAuth = true,
106
261
  }) {
107
- const controller = new AbortController();
108
- const timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
262
+ // Refresh before spending the credential rather than after a rejection: the
263
+ // rotating refresh token is shared with every other `notis` process reading
264
+ // the same profile, so a lapsed access token is renewed once, under the
265
+ // config write lock, instead of racing on a 401 retry.
266
+ let currentProfile;
267
+ if (runtime.credentialKind === 'oauth') {
268
+ if (requireAuth && credentialIsExpired(runtime, {
269
+ oauth_access_expires_at: runtime.oauthAccessExpiresAt,
270
+ })) {
271
+ await refreshOAuthCredential(runtime);
272
+ }
273
+ currentProfile = getProfile(loadConfig(), runtime.profileName);
274
+ } else {
275
+ currentProfile = getProfile(loadConfig(), runtime.profileName);
276
+ reloadDevJwt(runtime, currentProfile);
277
+ currentProfile = getActiveWorktreeCredentialProfile(runtime, currentProfile);
278
+ }
279
+ if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
280
+ throw createExpiredAuthError(runtime);
281
+ }
282
+
283
+ let controller = new AbortController();
284
+ let timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
109
285
  const requestId = `req_${randomUUID().replace(/-/g, '')}`;
286
+ const resetTimeout = () => {
287
+ clearTimeout(timeout);
288
+ controller = new AbortController();
289
+ timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
290
+ };
110
291
 
111
292
  const headers = {
112
- 'Content-Type': 'application/json',
113
293
  'X-Notis-CLI-Version': runtime.cliVersion,
114
294
  'X-Notis-Request-Id': requestId,
115
295
  };
296
+ if (!multipart) {
297
+ headers['Content-Type'] = 'application/json';
298
+ }
116
299
 
117
300
  if (requireAuth && runtime.jwt) {
118
301
  headers.Authorization = `Bearer ${runtime.jwt}`;
119
302
  }
120
303
 
304
+ const resolveBody = () => {
305
+ if (!multipart) {
306
+ return {
307
+ body: body ? JSON.stringify(body) : undefined,
308
+ headers: {},
309
+ };
310
+ }
311
+ if (typeof body === 'function') {
312
+ return body();
313
+ }
314
+ return {
315
+ body,
316
+ headers: {},
317
+ };
318
+ };
319
+
121
320
  try {
122
- const response = await fetch(`${runtime.apiBase}${path}`, {
321
+ let requestBody = resolveBody();
322
+ let response = await fetch(`${runtime.apiBase}${path}`, {
123
323
  method,
124
- headers,
125
- body: body ? JSON.stringify(body) : undefined,
324
+ headers: { ...headers, ...requestBody.headers },
325
+ body: requestBody.body,
126
326
  signal: controller.signal,
327
+ ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
127
328
  });
128
- clearTimeout(timeout);
129
329
 
130
330
  let payload = null;
331
+ let payloadReadError = null;
131
332
  try {
132
333
  payload = await response.json();
133
- } catch {
334
+ } catch (error) {
335
+ payloadReadError = error;
134
336
  payload = null;
135
337
  }
136
338
 
339
+ if (response.status === 401) {
340
+ const refreshed = runtime.credentialKind === 'oauth'
341
+ ? await refreshOAuthCredential(runtime)
342
+ : reloadDevJwt(runtime) && !isJwtExpired(runtime.jwt);
343
+ if (refreshed) {
344
+ if (requireAuth && runtime.jwt) {
345
+ headers.Authorization = `Bearer ${runtime.jwt}`;
346
+ }
347
+ resetTimeout();
348
+ requestBody = resolveBody();
349
+ response = await fetch(`${runtime.apiBase}${path}`, {
350
+ method,
351
+ headers: { ...headers, ...requestBody.headers },
352
+ body: requestBody.body,
353
+ signal: controller.signal,
354
+ ...(requestBody.duplex ? { duplex: requestBody.duplex } : {}),
355
+ });
356
+ payloadReadError = null;
357
+ try {
358
+ payload = await response.json();
359
+ } catch (error) {
360
+ payloadReadError = error;
361
+ payload = null;
362
+ }
363
+ }
364
+ }
365
+
366
+ clearTimeout(timeout);
367
+
137
368
  if (!response.ok) {
138
- throw normalizeBackendError(response.status, payload);
369
+ if (
370
+ response.status === 401
371
+ && credentialIsExpired(
372
+ runtime,
373
+ getActiveWorktreeCredentialProfile(
374
+ runtime,
375
+ getProfile(loadConfig(), runtime.profileName),
376
+ ),
377
+ )
378
+ ) {
379
+ throw createExpiredAuthError(runtime);
380
+ }
381
+ throw normalizeBackendError(response.status, payload, runtime);
382
+ }
383
+
384
+ // A successful status is not a successful tool call until its response
385
+ // body has been read. Mutations may already have committed before a socket
386
+ // reset truncates the JSON; surface that as an ambiguous network failure
387
+ // so callers never persist an undefined result or attempt a second write.
388
+ if (response.status !== 204 && payloadReadError) {
389
+ throw payloadReadError;
139
390
  }
140
391
 
141
392
  return {
@@ -177,22 +428,50 @@ export async function callTool({
177
428
  toolName,
178
429
  arguments_: argumentsPayload = {},
179
430
  idempotencyKey,
431
+ fileBindings = [],
180
432
  }) {
181
433
  const requestId = idempotencyKey || (runtime.mutating ? randomUUID() : null);
434
+ const payload = {
435
+ tool_name: toolName,
436
+ arguments: argumentsPayload,
437
+ idempotency_key: requestId,
438
+ cli_context: {
439
+ output_mode: runtime.outputMode,
440
+ profile: runtime.profileName,
441
+ cwd: process.cwd(),
442
+ agent_mode: runtime.agentMode,
443
+ cli_version: runtime.cliVersion,
444
+ // Reported so the server can refuse work-handing tools from a run Notis
445
+ // is itself driving. Advisory -- a determined agent can unset the markers
446
+ // -- so the server treats it as one layer, not the whole guard.
447
+ ...(delegatedContextReason() ? { delegated_context: true } : {}),
448
+ ...(runtime.debugEntitlementOverride
449
+ ? { debug_entitlement_override: runtime.debugEntitlementOverride }
450
+ : {}),
451
+ },
452
+ };
453
+
454
+ if (Array.isArray(fileBindings) && fileBindings.length) {
455
+ const bindingMetadata = fileBindings.map((binding) => {
456
+ const {
457
+ contentType,
458
+ localPath,
459
+ ...metadata
460
+ } = binding;
461
+ return metadata;
462
+ });
463
+
464
+ return httpRequest({
465
+ runtime,
466
+ path: '/cli_tools',
467
+ body: () => createMultipartFileUpload(payload, bindingMetadata, fileBindings),
468
+ multipart: true,
469
+ });
470
+ }
471
+
182
472
  return httpRequest({
183
473
  runtime,
184
474
  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
- },
475
+ body: payload,
197
476
  });
198
477
  }
@@ -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
+ }