@notis_ai/cli 0.2.7 → 0.2.9

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 (183) hide show
  1. package/README.md +56 -2
  2. package/dist/scaffolds/notis-database/app/page.tsx +38 -41
  3. package/dist/scaffolds/notis-database/lib/types.ts +5 -7
  4. package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
  5. package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
  6. package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
  7. package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
  8. package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
  9. package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +1839 -0
  10. package/dist/scaffolds/notis-database/notis.config.ts +48 -1
  11. package/dist/scaffolds/notis-database/package-lock.json +3935 -0
  12. package/dist/scaffolds/notis-database/package.json +2 -1
  13. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/package.json +4 -0
  14. package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  15. package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +60 -0
  16. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectActionBar.tsx +7 -2
  17. package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +196 -0
  18. package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +229 -0
  19. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  20. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +78 -0
  21. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +121 -0
  22. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useMultiSelect.ts +38 -2
  23. package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  24. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/index.ts +30 -1
  25. package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/runtime.ts +76 -17
  26. package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +186 -0
  27. package/dist/scaffolds/notis-database/src/mock-runtime.ts +4 -1
  28. package/dist/scaffolds/notis-journal/CHANGELOG.md +4 -0
  29. package/dist/scaffolds/notis-journal/app/page.tsx +1 -0
  30. package/dist/scaffolds/notis-journal/package-lock.json +4615 -0
  31. package/dist/scaffolds/notis-journal/package.json +2 -2
  32. package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +52 -1
  33. package/dist/scaffolds/notis-notes/app/page.tsx +39 -46
  34. package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +596 -0
  35. package/dist/scaffolds/notis-notes/lib/visible-properties.ts +144 -0
  36. package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
  37. package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
  38. package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
  39. package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
  40. package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
  41. package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +752 -0
  42. package/dist/scaffolds/notis-notes/notis.config.ts +48 -3
  43. package/dist/scaffolds/notis-notes/package-lock.json +4636 -0
  44. package/dist/scaffolds/notis-notes/package.json +7 -3
  45. package/dist/scaffolds/notis-notes/packages/sdk/package.json +36 -0
  46. package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  47. package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +60 -0
  48. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  49. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  50. package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  51. package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +196 -0
  52. package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +229 -0
  53. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +41 -0
  54. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  55. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +78 -0
  56. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +121 -0
  57. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  58. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +34 -0
  59. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  60. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +64 -0
  61. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +56 -0
  62. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  63. package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  64. package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +83 -0
  65. package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +43 -0
  66. package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +220 -0
  67. package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +186 -0
  68. package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +15 -0
  69. package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +56 -0
  70. package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +15 -0
  71. package/dist/scaffolds/notis-random/CHANGELOG.md +12 -2
  72. package/dist/scaffolds/notis-random/app/history/page.tsx +5 -4
  73. package/dist/scaffolds/notis-random/app/page.tsx +141 -74
  74. package/dist/scaffolds/notis-random/components/ui/button.tsx +50 -0
  75. package/dist/scaffolds/notis-random/components/ui/card.tsx +16 -0
  76. package/dist/scaffolds/notis-random/components/ui/input.tsx +23 -0
  77. package/dist/scaffolds/notis-random/lib/notis-tools.ts +26 -7
  78. package/dist/scaffolds/notis-random/lib/rng.ts +179 -19
  79. package/dist/scaffolds/notis-random/lib/roll-record.ts +103 -16
  80. package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
  81. package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
  82. package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
  83. package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
  84. package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
  85. package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +753 -0
  86. package/dist/scaffolds/notis-random/notis.config.ts +47 -3
  87. package/dist/scaffolds/notis-random/package-lock.json +4513 -0
  88. package/dist/scaffolds/notis-random/package.json +6 -2
  89. package/dist/scaffolds/notis-random/packages/sdk/package.json +36 -0
  90. package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +93 -0
  91. package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +60 -0
  92. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +278 -0
  93. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +91 -0
  94. package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  95. package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +196 -0
  96. package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +229 -0
  97. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +41 -0
  98. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +85 -0
  99. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +78 -0
  100. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +121 -0
  101. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +539 -0
  102. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +34 -0
  103. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +49 -0
  104. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +64 -0
  105. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +56 -0
  106. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +73 -0
  107. package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  108. package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +83 -0
  109. package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +43 -0
  110. package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +220 -0
  111. package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +186 -0
  112. package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +15 -0
  113. package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +56 -0
  114. package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +15 -0
  115. package/dist/scaffolds/notis-random/tailwind.config.ts +8 -1
  116. package/dist/scaffolds.json +8 -17
  117. package/package.json +2 -1
  118. package/skills/notis-apps/SKILL.md +438 -144
  119. package/skills/notis-apps/cli.md +16 -1
  120. package/skills/notis-cli/SKILL.md +11 -4
  121. package/skills/notis-onboarding/BRIEF.md +93 -0
  122. package/skills/notis-query/cli.md +1 -1
  123. package/src/cli.js +50 -12
  124. package/src/command-specs/apps.js +309 -41
  125. package/src/command-specs/auth.js +107 -0
  126. package/src/command-specs/index.js +4 -0
  127. package/src/command-specs/meta.js +68 -6
  128. package/src/command-specs/onboarding.js +304 -0
  129. package/src/command-specs/tools.js +8 -4
  130. package/src/runtime/app-dev-server.js +3 -2
  131. package/src/runtime/app-dev-sessions.js +14 -40
  132. package/src/runtime/app-platform.js +65 -4
  133. package/src/runtime/desktop-auth.js +86 -17
  134. package/src/runtime/errors.js +1 -0
  135. package/src/runtime/oauth.js +1105 -0
  136. package/src/runtime/output.js +16 -2
  137. package/src/runtime/profiles.js +542 -18
  138. package/src/runtime/telemetry.js +92 -0
  139. package/src/runtime/transport.js +66 -11
  140. package/template/.harness/index.html.tmpl +30 -2
  141. package/template/packages/sdk/src/config.ts +38 -1
  142. package/dist/scaffolds/notis-affiliate-prospects/CHANGELOG.md +0 -7
  143. package/dist/scaffolds/notis-affiliate-prospects/README.md +0 -20
  144. package/dist/scaffolds/notis-affiliate-prospects/app/globals.css +0 -42
  145. package/dist/scaffolds/notis-affiliate-prospects/app/layout.tsx +0 -7
  146. package/dist/scaffolds/notis-affiliate-prospects/app/page.tsx +0 -248
  147. package/dist/scaffolds/notis-affiliate-prospects/app/prospects/page.tsx +0 -134
  148. package/dist/scaffolds/notis-affiliate-prospects/app/segments/page.tsx +0 -108
  149. package/dist/scaffolds/notis-affiliate-prospects/components/ui/card.tsx +0 -35
  150. package/dist/scaffolds/notis-affiliate-prospects/index.html +0 -12
  151. package/dist/scaffolds/notis-affiliate-prospects/lib/affiliate-data.ts +0 -218
  152. package/dist/scaffolds/notis-affiliate-prospects/lib/demo-prospects.ts +0 -52
  153. package/dist/scaffolds/notis-affiliate-prospects/lib/notis-tools.ts +0 -100
  154. package/dist/scaffolds/notis-affiliate-prospects/lib/utils.ts +0 -25
  155. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-1.png +0 -0
  156. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-2.png +0 -0
  157. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-3.png +0 -0
  158. package/dist/scaffolds/notis-affiliate-prospects/metadata/screenshot-fixtures.json +0 -187
  159. package/dist/scaffolds/notis-affiliate-prospects/notis.config.ts +0 -65
  160. package/dist/scaffolds/notis-affiliate-prospects/package.json +0 -32
  161. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/config.ts +0 -90
  162. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useDatabase.ts +0 -76
  163. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/hooks/useUpsertDocument.ts +0 -50
  164. package/dist/scaffolds/notis-affiliate-prospects/packages/sdk/src/styles.css +0 -38
  165. package/dist/scaffolds/notis-affiliate-prospects/postcss.config.mjs +0 -6
  166. package/dist/scaffolds/notis-affiliate-prospects/src/dev-main.tsx +0 -72
  167. package/dist/scaffolds/notis-affiliate-prospects/src/mock-runtime.ts +0 -242
  168. package/dist/scaffolds/notis-affiliate-prospects/tailwind.config.ts +0 -51
  169. package/dist/scaffolds/notis-affiliate-prospects/tsconfig.json +0 -23
  170. package/dist/scaffolds/notis-affiliate-prospects/vite.config.ts +0 -11
  171. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -0
  172. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
  173. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useBackend.ts +0 -0
  174. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotis.ts +0 -0
  175. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useNotisNavigation.ts +0 -0
  176. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTool.ts +0 -0
  177. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTools.ts +0 -0
  178. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/hooks/useTopBarSearch.ts +0 -0
  179. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/provider.tsx +0 -0
  180. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/ui.ts +0 -0
  181. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/src/vite.ts +0 -0
  182. /package/dist/scaffolds/{notis-affiliate-prospects → notis-database}/packages/sdk/tsconfig.json +0 -0
  183. /package/dist/scaffolds/{notis-affiliate-prospects → notis-random}/components.json +0 -0
@@ -0,0 +1,92 @@
1
+ const ERROR_CATEGORIES = new Map([
2
+ [2, 'usage'],
3
+ [3, 'auth'],
4
+ [4, 'network'],
5
+ [5, 'conflict'],
6
+ [6, 'backend'],
7
+ [7, 'unexpected'],
8
+ [8, 'payment'],
9
+ ]);
10
+
11
+ export function cliDurationBucket(durationMs) {
12
+ if (durationMs < 1_000) return 'under_1s';
13
+ if (durationMs < 5_000) return '1s_to_5s';
14
+ if (durationMs < 30_000) return '5s_to_30s';
15
+ return 'over_30s';
16
+ }
17
+
18
+ export function cliBackendKind(spec) {
19
+ const value = String(spec?.backend_call?.type || '').toLowerCase();
20
+ if (value === 'tool') return 'tool';
21
+ if (value === 'http') return 'http';
22
+ if (value === 'local') return 'local';
23
+ if (value.includes('health')) return 'health';
24
+ if (value.includes('local')) return 'local';
25
+ return 'other';
26
+ }
27
+
28
+ export function buildCliTelemetryPayload({
29
+ spec,
30
+ runtime,
31
+ result,
32
+ durationMs,
33
+ error,
34
+ }) {
35
+ return {
36
+ command_id: spec.command_path.join('.'),
37
+ backend_kind: cliBackendKind(spec),
38
+ result,
39
+ error_category: result === 'failed'
40
+ ? (ERROR_CATEGORIES.get(Number(error?.exitCode)) || 'unexpected')
41
+ : 'none',
42
+ duration_bucket: cliDurationBucket(durationMs),
43
+ cli_version: runtime.cliVersion,
44
+ agent_mode: Boolean(runtime.agentMode),
45
+ };
46
+ }
47
+
48
+ export async function reportCliCommand({
49
+ spec,
50
+ runtime,
51
+ result,
52
+ durationMs,
53
+ error = null,
54
+ fetchImpl = globalThis.fetch,
55
+ }) {
56
+ // Backend tool calls emit authoritative surface/tool telemetry server-side.
57
+ // This client event exists only for local commands that otherwise leave no
58
+ // observable runtime boundary.
59
+ if (
60
+ cliBackendKind(spec) !== 'local'
61
+ || !runtime?.jwt
62
+ || !runtime?.apiBase
63
+ || typeof fetchImpl !== 'function'
64
+ ) {
65
+ return false;
66
+ }
67
+ const controller = new AbortController();
68
+ const timeout = setTimeout(() => controller.abort(), 1_500);
69
+ try {
70
+ const response = await fetchImpl(`${runtime.apiBase}/cli_telemetry`, {
71
+ method: 'POST',
72
+ headers: {
73
+ Authorization: `Bearer ${runtime.jwt}`,
74
+ 'Content-Type': 'application/json',
75
+ 'X-Notis-CLI-Version': runtime.cliVersion,
76
+ },
77
+ body: JSON.stringify(buildCliTelemetryPayload({
78
+ spec,
79
+ runtime,
80
+ result,
81
+ durationMs,
82
+ error,
83
+ })),
84
+ signal: controller.signal,
85
+ });
86
+ return response.ok;
87
+ } catch {
88
+ return false;
89
+ } finally {
90
+ clearTimeout(timeout);
91
+ }
92
+ }
@@ -3,11 +3,14 @@ import { createReadStream } from 'node:fs';
3
3
  import { CliError, EXIT_CODES } from './errors.js';
4
4
  import {
5
5
  DEFAULT_PROFILE,
6
+ credentialIsExpired,
6
7
  getProfile,
8
+ getJwtSubject,
7
9
  isJwtExpired,
8
10
  loadConfig,
9
11
  } from './profiles.js';
10
12
  import { createExpiredAuthError, createInvalidAuthHints } from './desktop-auth.js';
13
+ import { refreshOAuthCredential } from './oauth.js';
11
14
 
12
15
  function escapeMultipartHeaderValue(value) {
13
16
  return String(value ?? '')
@@ -122,6 +125,18 @@ function normalizeBackendError(status, payload, runtime) {
122
125
  });
123
126
  }
124
127
 
128
+ if (status === 402) {
129
+ // Distinct from auth on purpose: an agent that sees an auth exit code will
130
+ // loop trying to re-authenticate, when the actual fix is to add credit.
131
+ return new CliError({
132
+ code: 'payment_required',
133
+ message,
134
+ exitCode: EXIT_CODES.payment,
135
+ details: payload || {},
136
+ hints: payload?.hints || [],
137
+ });
138
+ }
139
+
125
140
  if (status >= 400 && status < 500) {
126
141
  return new CliError({
127
142
  code: 'usage_error',
@@ -142,22 +157,37 @@ function normalizeBackendError(status, payload, runtime) {
142
157
  });
143
158
  }
144
159
 
145
- function reloadJwtFromConfig(runtime) {
146
- if (runtime.credentialSource === 'env') {
160
+ function reloadJwtFromConfig(runtime, loadedProfile = null) {
161
+ if (runtime.credentialKind === 'env' || runtime.credentialKind === 'oauth') {
147
162
  return false;
148
163
  }
149
164
  const profileName = runtime.profileName || DEFAULT_PROFILE;
150
- let profile;
151
- try {
152
- profile = getProfile(loadConfig(), profileName);
153
- } catch {
154
- return false;
165
+ let profile = loadedProfile;
166
+ if (!profile) {
167
+ try {
168
+ profile = getProfile(loadConfig(runtime.worktreeRuntime), profileName);
169
+ } catch {
170
+ return false;
171
+ }
155
172
  }
156
173
  const nextJwt = typeof profile.jwt === 'string' && profile.jwt ? profile.jwt : null;
157
174
  if (!nextJwt || nextJwt === runtime.jwt) {
158
175
  return false;
159
176
  }
177
+ const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
178
+ if (expectedUserId && getJwtSubject(nextJwt) !== expectedUserId) {
179
+ throw new CliError({
180
+ code: 'dev_runtime_identity_mismatch',
181
+ message: 'The refreshed scoped dev credential does not belong to this worktree test user',
182
+ exitCode: EXIT_CODES.auth,
183
+ hints: [
184
+ { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
185
+ { message: `Expected user: ${expectedUserId}` },
186
+ ],
187
+ });
188
+ }
160
189
  runtime.jwt = nextJwt;
190
+ runtime.credentialKind = runtime.worktreeRuntime ? 'worktree' : 'desktop';
161
191
  runtime.desktopAppName = profile.desktop_app_name;
162
192
  runtime.desktopPid = profile.desktop_pid;
163
193
  return true;
@@ -175,8 +205,25 @@ export async function httpRequest({
175
205
  // token. Each CLI request only consumes the newest access token it synced to
176
206
  // disk, avoiding refresh-token races between independent CLI processes and
177
207
  // the running desktop session.
178
- reloadJwtFromConfig(runtime);
179
- if (requireAuth && isJwtExpired(runtime.jwt)) {
208
+ let currentProfile;
209
+ if (runtime.credentialKind === 'oauth') {
210
+ if (requireAuth && credentialIsExpired(runtime, {
211
+ oauth_access_expires_at: runtime.oauthAccessExpiresAt,
212
+ })) {
213
+ await refreshOAuthCredential(runtime);
214
+ }
215
+ currentProfile = getProfile(
216
+ loadConfig(runtime.worktreeRuntime),
217
+ runtime.profileName,
218
+ );
219
+ } else {
220
+ currentProfile = getProfile(
221
+ loadConfig(runtime.worktreeRuntime),
222
+ runtime.profileName,
223
+ );
224
+ reloadJwtFromConfig(runtime, currentProfile);
225
+ }
226
+ if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
180
227
  throw createExpiredAuthError(runtime);
181
228
  }
182
229
 
@@ -235,7 +282,9 @@ export async function httpRequest({
235
282
  }
236
283
 
237
284
  if (response.status === 401) {
238
- const refreshed = reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
285
+ const refreshed = runtime.credentialKind === 'oauth'
286
+ ? await refreshOAuthCredential(runtime)
287
+ : reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
239
288
  if (refreshed) {
240
289
  if (requireAuth && runtime.jwt) {
241
290
  headers.Authorization = `Bearer ${runtime.jwt}`;
@@ -260,7 +309,13 @@ export async function httpRequest({
260
309
  clearTimeout(timeout);
261
310
 
262
311
  if (!response.ok) {
263
- if (response.status === 401 && isJwtExpired(runtime.jwt)) {
312
+ if (
313
+ response.status === 401
314
+ && credentialIsExpired(
315
+ runtime,
316
+ getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
317
+ )
318
+ ) {
264
319
  throw createExpiredAuthError(runtime);
265
320
  }
266
321
  throw normalizeBackendError(response.status, payload, runtime);
@@ -107,6 +107,33 @@
107
107
  const apiBase = {{API_BASE}};
108
108
  const jwt = {{JWT}};
109
109
  const fixtures = {{FIXTURES}};
110
+
111
+ /**
112
+ * Resolves the canned response for one tool call.
113
+ *
114
+ * A plain `"TOOL_NAME"` key answers every call to that tool. An app that
115
+ * reaches several databases through a single tool needs to vary the
116
+ * payload per call, so a key may also be scoped to one argument with
117
+ * `"TOOL_NAME#arg=value"` — for example
118
+ * `"LOCAL_NOTIS_DATABASE_QUERY#database_slug=notes"`. Scoped keys are
119
+ * checked first; the unscoped key stays the fallback.
120
+ */
121
+ function lookupToolFixture(name, args) {
122
+ const tools = fixtures && fixtures.tools;
123
+ if (!tools) return undefined;
124
+ for (const key of Object.keys(tools)) {
125
+ const sep = key.indexOf('#');
126
+ if (sep === -1 || key.slice(0, sep) !== name) continue;
127
+ const scope = key.slice(sep + 1);
128
+ const eq = scope.indexOf('=');
129
+ if (eq === -1) continue;
130
+ if (String(args[scope.slice(0, eq)]) === scope.slice(eq + 1)) {
131
+ return tools[key];
132
+ }
133
+ }
134
+ return Object.prototype.hasOwnProperty.call(tools, name) ? tools[name] : undefined;
135
+ }
136
+
110
137
  const status = document.getElementById('harness-status');
111
138
  const setStatus = (msg, color = '#0f0') => {
112
139
  status.textContent = `harness: ${msg}`;
@@ -197,8 +224,9 @@
197
224
  },
198
225
  callTool: async (name, args) => {
199
226
  record('callTool', { name, arguments: args || {} });
200
- if (fixtures && fixtures.tools && Object.prototype.hasOwnProperty.call(fixtures.tools, name)) {
201
- return structuredClone(fixtures.tools[name]);
227
+ const fixture = lookupToolFixture(name, args || {});
228
+ if (fixture !== undefined) {
229
+ return structuredClone(fixture);
202
230
  }
203
231
  return { ok: true, result: null };
204
232
  },
@@ -62,6 +62,20 @@ export const NOTIS_APP_CATEGORIES = [
62
62
 
63
63
  export type NotisAppCategory = typeof NOTIS_APP_CATEGORIES[number];
64
64
 
65
+ export interface NotisAppDatabaseConfig {
66
+ /** Database slug, as declared by the app. */
67
+ slug: string;
68
+ /**
69
+ * Ship this database's rows to everyone who installs the app.
70
+ *
71
+ * Off by default: declaring a database publishes its STRUCTURE, never its
72
+ * content. Turn it on only for starter content that every installer should
73
+ * receive - a default folder tree, a set of templates - and never for a
74
+ * database that accumulates the author's own data.
75
+ */
76
+ seedDocuments?: boolean;
77
+ }
78
+
65
79
  export interface NotisAppAuthor {
66
80
  name: string;
67
81
  handle?: string;
@@ -106,6 +120,19 @@ export const NOTIS_APP_ACCENTS = [
106
120
 
107
121
  export type NotisAppAccent = typeof NOTIS_APP_ACCENTS[number];
108
122
 
123
+ export interface NotisAppCapabilities {
124
+ /**
125
+ * Read every database in the workspace, not only the ones this app declares
126
+ * in `databases` or created itself.
127
+ *
128
+ * An app runtime is otherwise sandboxed to its own databases, so a catalog or
129
+ * explorer app sees an empty list without this. `'read'` is the only accepted
130
+ * value and it never grants writes: `LOCAL_NOTIS_DATABASE_UPSERT_*` stays
131
+ * bound to the app's own databases.
132
+ */
133
+ workspaceDatabases?: 'read';
134
+ }
135
+
109
136
  export interface NotisAppConfig {
110
137
  /** URL-safe app slug. Existing apps may still use a display name here. */
111
138
  name: string;
@@ -128,7 +155,17 @@ export interface NotisAppConfig {
128
155
  /** @deprecated Add release entries to the root CHANGELOG.md instead. */
129
156
  versionNotes?: string;
130
157
  screenshots?: NotisAppScreenshotConfig[];
131
- databases?: string[];
158
+ /**
159
+ * Databases this app owns. A bare string publishes structure only; use the
160
+ * object form to opt a database into shipping its rows to installers.
161
+ */
162
+ databases?: (string | NotisAppDatabaseConfig)[];
163
+ /**
164
+ * Extra permissions the app asks for at install time. Everything here widens
165
+ * what the app can reach beyond its own data, so each one is surfaced to the
166
+ * user before they install.
167
+ */
168
+ capabilities?: NotisAppCapabilities;
132
169
  routes?: NotisRouteConfig[];
133
170
  tools?: string[];
134
171
  skills?: NotisAppSkillConfig[];
@@ -1,7 +0,0 @@
1
- # Changelog
2
-
3
- ## [Affiliate recruiting control center] - 2026-07-23
4
-
5
- - Added the campaign dashboard, measurable affiliate pipeline, and priority queue.
6
- - Added responsive prospect and segment views.
7
- - Added local fixtures for every recruiting segment so the app can be tested before installation.
@@ -1,20 +0,0 @@
1
- # Affiliate Prospects
2
-
3
- Local-first Notis app for monitoring the affiliate recruiting pipeline.
4
-
5
- The app is intentionally a read-only campaign surface. Notis automations and Codex workers own
6
- discovery, enrichment, outreach preparation, analytics reconciliation, and record updates. This
7
- keeps one source of truth while avoiding business logic in the UI.
8
-
9
- ## Routes
10
-
11
- - Dashboard: KPIs, pipeline, priority queue, and segment coverage.
12
- - Prospects: responsive reference table for every lead and next action.
13
- - Segments: scorecards linked to the source-controlled segment playbooks.
14
-
15
- ## Data
16
-
17
- The deployed app references `affiliate_prospects_1` and `affiliate_outreach_events_1`. Their
18
- canonical schema is documented in `campaign/affiliate-network/data-schema.md`. During local
19
- development the app uses deterministic mock prospects so it can be reviewed before any workspace
20
- installation.
@@ -1,42 +0,0 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
4
-
5
- .affiliate-app-shell {
6
- container-type: inline-size;
7
- padding: clamp(1rem, 2.5cqi, 2rem);
8
- max-width: 1440px;
9
- margin-inline: auto;
10
- }
11
-
12
- .affiliate-stat-grid {
13
- display: grid;
14
- grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
15
- }
16
-
17
- .affiliate-dashboard-grid {
18
- display: grid;
19
- grid-template-columns: minmax(0, 1fr);
20
- }
21
-
22
- @container (min-width: 940px) {
23
- .affiliate-dashboard-grid {
24
- grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr);
25
- }
26
- }
27
-
28
- .affiliate-data-table {
29
- min-width: 900px;
30
- }
31
-
32
- @media (max-width: 767px) {
33
- .affiliate-desktop-table {
34
- display: none;
35
- }
36
- }
37
-
38
- @media (min-width: 768px) {
39
- .affiliate-mobile-cards {
40
- display: none;
41
- }
42
- }
@@ -1,7 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import '@notis/sdk/styles.css';
3
- import './globals.css';
4
-
5
- export default function AppShell({ children }: { children: ReactNode }) {
6
- return children;
7
- }
@@ -1,248 +0,0 @@
1
- 'use client';
2
-
3
- import { useEffect, useMemo, useState } from 'react';
4
- import { useNotisNavigation, useTopBarSearch } from '@notis/sdk';
5
- import {
6
- ArrowClockwiseIcon,
7
- ArrowRightIcon,
8
- CheckCircleIcon,
9
- CurrencyDollarIcon,
10
- FunnelIcon,
11
- PaperPlaneTiltIcon,
12
- SparkleIcon,
13
- UsersThreeIcon,
14
- } from '@phosphor-icons/react';
15
-
16
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
17
- import {
18
- PIPELINE,
19
- SEGMENTS,
20
- compactNumber,
21
- money,
22
- statusTone,
23
- type AffiliateProspect,
24
- } from '@/lib/affiliate-data';
25
- import { useAffiliateProspects } from '@/lib/notis-tools';
26
-
27
- export default function DashboardPage() {
28
- const { prospects, loading, error, refetch } = useAffiliateProspects();
29
- const navigation = useNotisNavigation();
30
- const [search, setSearch] = useState('');
31
- const { setLoading } = useTopBarSearch({
32
- value: search,
33
- onChange: setSearch,
34
- placeholder: 'Filter affiliate prospects…',
35
- });
36
- useEffect(() => setLoading(loading), [loading, setLoading]);
37
-
38
- const filtered = useMemo(() => {
39
- const query = search.trim().toLowerCase();
40
- if (!query) return prospects;
41
- return prospects.filter((prospect) =>
42
- [prospect.name, prospect.company, prospect.segment, prospect.status, prospect.source]
43
- .join(' ')
44
- .toLowerCase()
45
- .includes(query),
46
- );
47
- }, [prospects, search]);
48
-
49
- const metrics = useMemo(() => {
50
- const qualified = prospects.filter((p) => p.fitScore >= 75 && p.status !== 'Disqualified').length;
51
- const active = prospects.filter((p) => p.status === 'Activated').length;
52
- const conversations = prospects.filter((p) =>
53
- ['Replied', 'Interested', 'Applied', 'Activated'].includes(p.status),
54
- ).length;
55
- const mrr = prospects.reduce((sum, p) => sum + p.attributedMrr, 0);
56
- return { qualified, active, conversations, mrr };
57
- }, [prospects]);
58
-
59
- const priority = useMemo(
60
- () =>
61
- [...filtered]
62
- .filter((p) => !p.optedOut && !['Activated', 'Disqualified'].includes(p.status))
63
- .sort((a, b) => b.fitScore - a.fitScore)
64
- .slice(0, 6),
65
- [filtered],
66
- );
67
-
68
- const segmentRows = useMemo(
69
- () =>
70
- SEGMENTS.map((segment) => {
71
- const rows = prospects.filter((p) => p.segment === segment.name);
72
- const engaged = rows.filter((p) => ['Replied', 'Interested', 'Applied', 'Activated'].includes(p.status)).length;
73
- return { ...segment, total: rows.length, engaged };
74
- }).sort((a, b) => b.total - a.total),
75
- [prospects],
76
- );
77
-
78
- return (
79
- <main data-store-screenshot="dashboard" className="affiliate-app-shell space-y-6">
80
- <header className="flex flex-wrap items-start justify-between gap-4">
81
- <div>
82
- <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
83
- <SparkleIcon size={14} weight="fill" />
84
- Affiliate recruiting control center
85
- </div>
86
- <h1 className="mt-1 text-2xl font-semibold tracking-tight">Campaign dashboard</h1>
87
- <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
88
- One place to source, qualify, contact, and activate affiliates—while Notis watches the
89
- pipeline and local Codex workers do the heavy research.
90
- </p>
91
- </div>
92
- <button
93
- type="button"
94
- onClick={refetch}
95
- className="inline-flex min-h-11 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground"
96
- >
97
- <ArrowClockwiseIcon className={loading ? 'animate-spin' : ''} size={16} />
98
- Refresh
99
- </button>
100
- </header>
101
-
102
- {error ? (
103
- <div className="rounded-lg border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
104
- {error.message}
105
- </div>
106
- ) : null}
107
-
108
- <section className="affiliate-stat-grid gap-3">
109
- <Metric icon={UsersThreeIcon} label="Prospects" value={compactNumber(prospects.length)} />
110
- <Metric icon={FunnelIcon} label="Qualified" value={compactNumber(metrics.qualified)} />
111
- <Metric icon={PaperPlaneTiltIcon} label="Conversations" value={compactNumber(metrics.conversations)} />
112
- <Metric icon={CheckCircleIcon} label="Activated" value={compactNumber(metrics.active)} />
113
- <Metric icon={CurrencyDollarIcon} label="Attributed MRR" value={money(metrics.mrr)} />
114
- </section>
115
-
116
- <section className="affiliate-dashboard-grid gap-4">
117
- <Card>
118
- <CardHeader className="flex-row items-center justify-between">
119
- <div>
120
- <CardTitle>Priority queue</CardTitle>
121
- <p className="mt-1 text-xs text-muted-foreground">Highest-fit prospects needing a next action.</p>
122
- </div>
123
- <button
124
- type="button"
125
- onClick={() => navigation.toRoute('/prospects')}
126
- className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
127
- >
128
- All prospects <ArrowRightIcon size={13} />
129
- </button>
130
- </CardHeader>
131
- <CardContent>
132
- {loading && !prospects.length ? (
133
- <p className="py-8 text-center text-sm text-muted-foreground">Loading the pipeline…</p>
134
- ) : priority.length ? (
135
- <div className="divide-y divide-border">
136
- {priority.map((prospect) => <PriorityRow key={prospect.id} prospect={prospect} />)}
137
- </div>
138
- ) : (
139
- <p className="rounded-lg border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
140
- No prospects need attention.
141
- </p>
142
- )}
143
- </CardContent>
144
- </Card>
145
-
146
- <Card>
147
- <CardHeader>
148
- <CardTitle>Pipeline</CardTitle>
149
- <p className="text-xs text-muted-foreground">Current distribution across recruiting stages.</p>
150
- </CardHeader>
151
- <CardContent className="space-y-3">
152
- {PIPELINE.slice(0, 9).map((status) => {
153
- const count = prospects.filter((p) => p.status === status).length;
154
- const width = prospects.length ? Math.max((count / prospects.length) * 100, count ? 4 : 0) : 0;
155
- return (
156
- <div key={status}>
157
- <div className="mb-1 flex items-center justify-between text-xs">
158
- <span className="text-muted-foreground">{status}</span>
159
- <span className="font-medium tabular-nums">{count}</span>
160
- </div>
161
- <div className="h-1.5 overflow-hidden rounded-full bg-muted">
162
- <div className="h-full rounded-full bg-primary/70" style={{ width: `${width}%` }} />
163
- </div>
164
- </div>
165
- );
166
- })}
167
- </CardContent>
168
- </Card>
169
- </section>
170
-
171
- <Card>
172
- <CardHeader className="flex-row items-center justify-between">
173
- <div>
174
- <CardTitle>Segment coverage</CardTitle>
175
- <p className="mt-1 text-xs text-muted-foreground">Where the campaign is deep—and where sourcing should run next.</p>
176
- </div>
177
- <button
178
- type="button"
179
- onClick={() => navigation.toRoute('/segments')}
180
- className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
181
- >
182
- Playbooks <ArrowRightIcon size={13} />
183
- </button>
184
- </CardHeader>
185
- <CardContent>
186
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
187
- {segmentRows.map((segment) => (
188
- <div key={segment.name} className="rounded-lg border border-border p-3">
189
- <p className="text-sm font-medium">{segment.short}</p>
190
- <p className="mt-1 text-xs text-muted-foreground">{segment.source}</p>
191
- <div className="mt-3 flex items-end justify-between">
192
- <span className="text-2xl font-semibold tabular-nums">{segment.total}</span>
193
- <span className="text-xs text-muted-foreground">{segment.engaged} engaged</span>
194
- </div>
195
- </div>
196
- ))}
197
- </div>
198
- </CardContent>
199
- </Card>
200
- </main>
201
- );
202
- }
203
-
204
- function Metric({
205
- icon: Icon,
206
- label,
207
- value,
208
- }: {
209
- icon: typeof UsersThreeIcon;
210
- label: string;
211
- value: string;
212
- }) {
213
- return (
214
- <Card className="p-4">
215
- <div className="flex items-center justify-between gap-3">
216
- <div>
217
- <p className="text-xs text-muted-foreground">{label}</p>
218
- <p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
219
- </div>
220
- <div className="rounded-lg bg-muted p-2 text-muted-foreground">
221
- <Icon size={18} />
222
- </div>
223
- </div>
224
- </Card>
225
- );
226
- }
227
-
228
- function PriorityRow({ prospect }: { prospect: AffiliateProspect }) {
229
- return (
230
- <div className="flex flex-wrap items-center gap-3 py-3 first:pt-0 last:pb-0">
231
- <div className="flex min-w-0 flex-1 items-center gap-3">
232
- <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
233
- {prospect.name.split(/\s+/).slice(0, 2).map((part) => part[0]).join('')}
234
- </div>
235
- <div className="min-w-0">
236
- <p className="truncate text-sm font-medium">{prospect.name}</p>
237
- <p className="truncate text-xs text-muted-foreground">
238
- {prospect.company || prospect.segment} · {prospect.nextAction || 'Review qualification'}
239
- </p>
240
- </div>
241
- </div>
242
- <span className={`rounded-full px-2 py-1 text-[11px] font-medium ${statusTone(prospect.status)}`}>
243
- {prospect.status}
244
- </span>
245
- <span className="w-12 text-right text-sm font-semibold tabular-nums">{prospect.fitScore}</span>
246
- </div>
247
- );
248
- }