@cat-factory/app 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,273 @@
1
+ <script setup lang="ts">
2
+ // Direct-provider API keys: connect a vendor API key (OpenAI/Anthropic/Qwen/DeepSeek/
3
+ // Moonshot) so agent steps and inline calls run on that provider. Keys are stored
4
+ // encrypted in the DB, pooled and rotated by usage — replacing deployment env vars.
5
+ //
6
+ // Two modes:
7
+ // - Default (no `accountId`): manage WORKSPACE keys (shared by the team) and YOUR own
8
+ // keys (your personal pool, usable in any workspace), toggled by the Scope select.
9
+ // - With `accountId`: manage ACCOUNT-wide keys (shared by every workspace in the
10
+ // account); admin-only, enforced server-side. Surfaced from account/team settings.
11
+ import { computed, ref, watch } from 'vue'
12
+ import type { ApiKey, ApiKeyProvider } from '~/types/domain'
13
+
14
+ const props = defineProps<{ accountId?: string }>()
15
+
16
+ const workspace = useWorkspaceStore()
17
+ const keys = useApiKeysStore()
18
+ const models = useModelsStore()
19
+ const toast = useToast()
20
+
21
+ /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
22
+ const isAccount = computed(() => !!props.accountId)
23
+
24
+ /** Where to obtain each provider's API key + a short note. */
25
+ const PROVIDERS: {
26
+ value: ApiKeyProvider
27
+ label: string
28
+ url: string
29
+ steps: string[]
30
+ }[] = [
31
+ {
32
+ value: 'openai',
33
+ label: 'OpenAI',
34
+ url: 'https://platform.openai.com/api-keys',
35
+ steps: [
36
+ 'Open platform.openai.com → API keys and create a new secret key.',
37
+ 'Copy the key (starts with sk-…); it is shown only once.',
38
+ ],
39
+ },
40
+ {
41
+ value: 'anthropic',
42
+ label: 'Anthropic (Claude API)',
43
+ url: 'https://console.anthropic.com/settings/keys',
44
+ steps: [
45
+ 'Open console.anthropic.com → Settings → API Keys and create a key.',
46
+ 'Copy the key (starts with sk-ant-…).',
47
+ ],
48
+ },
49
+ {
50
+ value: 'qwen',
51
+ label: 'Qwen (Alibaba DashScope)',
52
+ url: 'https://dashscope.console.aliyun.com/apiKey',
53
+ steps: [
54
+ 'Open the DashScope console (international) → API-KEY and create a key.',
55
+ 'Copy the key; it authenticates the OpenAI-compatible Qwen endpoint.',
56
+ ],
57
+ },
58
+ {
59
+ value: 'deepseek',
60
+ label: 'DeepSeek',
61
+ url: 'https://platform.deepseek.com/api_keys',
62
+ steps: [
63
+ 'Open platform.deepseek.com → API keys and create a key.',
64
+ 'Copy the key (starts with sk-…).',
65
+ ],
66
+ },
67
+ {
68
+ value: 'moonshot',
69
+ label: 'Moonshot (Kimi API)',
70
+ url: 'https://platform.moonshot.ai/console/api-keys',
71
+ steps: [
72
+ 'Open platform.moonshot.ai → API Keys and create a key.',
73
+ 'Copy the key; it authenticates the OpenAI-compatible Moonshot endpoint.',
74
+ ],
75
+ },
76
+ {
77
+ value: 'openrouter',
78
+ label: 'OpenRouter',
79
+ url: 'https://openrouter.ai/keys',
80
+ steps: [
81
+ 'Open openrouter.ai → Keys and create an API key.',
82
+ 'Copy the key (starts with sk-or-…); it reaches 300+ models through one gateway.',
83
+ ],
84
+ },
85
+ {
86
+ value: 'litellm',
87
+ label: 'LiteLLM (self-hosted gateway)',
88
+ url: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
89
+ steps: [
90
+ 'Generate a virtual key on your LiteLLM gateway (or use its master key).',
91
+ "The gateway's base URL is set by your deployment operator (LITELLM_BASE_URL), not here.",
92
+ ],
93
+ },
94
+ ]
95
+
96
+ const scope = ref<'workspace' | 'user'>('workspace')
97
+ const provider = ref<ApiKeyProvider>('openai')
98
+ const label = ref('')
99
+ const key = ref('')
100
+ const busy = ref(false)
101
+
102
+ watch(
103
+ () => props.accountId,
104
+ (acc) => {
105
+ if (acc) void keys.loadAccountKeys(acc)
106
+ },
107
+ { immediate: true },
108
+ )
109
+
110
+ watch(
111
+ () => workspace.workspaceId,
112
+ (ws) => {
113
+ if (!isAccount.value && ws) void keys.load(ws)
114
+ },
115
+ { immediate: true },
116
+ )
117
+
118
+ const selected = computed(() => PROVIDERS.find((p) => p.value === provider.value)!)
119
+ const connected = computed<ApiKey[]>(() =>
120
+ isAccount.value
121
+ ? keys.accountKeys
122
+ : scope.value === 'workspace'
123
+ ? keys.workspaceKeys
124
+ : keys.userKeys,
125
+ )
126
+
127
+ function providerLabel(p: ApiKeyProvider): string {
128
+ return PROVIDERS.find((x) => x.value === p)?.label ?? p
129
+ }
130
+
131
+ async function add() {
132
+ if (!key.value.trim()) return
133
+ busy.value = true
134
+ try {
135
+ const input = {
136
+ provider: provider.value,
137
+ label: label.value.trim() || `${provider.value} key`,
138
+ key: key.value.trim(),
139
+ }
140
+ if (isAccount.value) await keys.addAccountKey(input)
141
+ else if (scope.value === 'workspace') await keys.addWorkspaceKey(input)
142
+ else await keys.addUserKey(input)
143
+ key.value = ''
144
+ label.value = ''
145
+ // The picker's selectability depends on configured keys — refresh it.
146
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
147
+ toast.add({ title: 'API key connected', icon: 'i-lucide-check', color: 'success' })
148
+ } catch (e) {
149
+ toast.add({
150
+ title: 'Could not connect key',
151
+ description: e instanceof Error ? e.message : String(e),
152
+ color: 'error',
153
+ })
154
+ } finally {
155
+ busy.value = false
156
+ }
157
+ }
158
+
159
+ async function remove(k: ApiKey) {
160
+ try {
161
+ if (k.scope === 'account') await keys.removeAccountKey(k.id)
162
+ else if (k.scope === 'workspace') await keys.removeWorkspaceKey(k.id)
163
+ else await keys.removeUserKey(k.id)
164
+ if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
165
+ } catch (e) {
166
+ toast.add({
167
+ title: 'Could not remove key',
168
+ description: e instanceof Error ? e.message : String(e),
169
+ color: 'error',
170
+ })
171
+ }
172
+ }
173
+ </script>
174
+
175
+ <template>
176
+ <div class="space-y-4">
177
+ <div>
178
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
179
+ Direct provider API keys
180
+ </h4>
181
+ <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
182
+ Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
183
+ are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
184
+ </p>
185
+ <p v-else class="mt-1 text-sm text-slate-400">
186
+ Connect a vendor API key so models run directly on that provider. Keys are stored encrypted,
187
+ pooled, and rotated by usage. Scope a key to this <strong>workspace</strong> (shared with
188
+ the team) or to <strong>you</strong> (your own pool, usable anywhere).
189
+ </p>
190
+ </div>
191
+
192
+ <!-- scope + provider -->
193
+ <div class="flex flex-wrap items-end gap-3">
194
+ <UFormField v-if="!isAccount" label="Scope">
195
+ <USelect
196
+ v-model="scope"
197
+ :items="[
198
+ { label: 'This workspace', value: 'workspace' },
199
+ { label: 'My keys (only me)', value: 'user' },
200
+ ]"
201
+ class="w-48"
202
+ />
203
+ </UFormField>
204
+ <UFormField label="Provider">
205
+ <USelect
206
+ v-model="provider"
207
+ :items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
208
+ class="w-64"
209
+ />
210
+ </UFormField>
211
+ </div>
212
+
213
+ <!-- where to get the key -->
214
+ <ol
215
+ class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
216
+ >
217
+ <li v-for="(step, i) in selected.steps" :key="i">{{ step }}</li>
218
+ <li>
219
+ <a
220
+ :href="selected.url"
221
+ target="_blank"
222
+ rel="noopener noreferrer"
223
+ class="text-primary-400 underline"
224
+ >
225
+ Open {{ selected.label }} keys ↗
226
+ </a>
227
+ </li>
228
+ </ol>
229
+
230
+ <!-- add form -->
231
+ <div class="space-y-2">
232
+ <UFormField label="Label (optional)">
233
+ <UInput v-model="label" placeholder="e.g. team key" />
234
+ </UFormField>
235
+ <UFormField label="API key">
236
+ <UTextarea v-model="key" :rows="2" placeholder="paste the API key" class="font-mono" />
237
+ </UFormField>
238
+ <div class="flex justify-end">
239
+ <UButton :loading="busy" :disabled="!key.trim()" icon="i-lucide-plus" @click="add()">
240
+ Connect
241
+ </UButton>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- connected keys for the selected scope -->
246
+ <div v-if="connected.length" class="space-y-2">
247
+ <h5 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
248
+ Connected ({{ connected.length }})
249
+ </h5>
250
+ <div
251
+ v-for="k in connected"
252
+ :key="k.id"
253
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
254
+ >
255
+ <div>
256
+ <span class="font-medium text-slate-200">{{ k.label }}</span>
257
+ <span class="ml-2 text-xs text-slate-500">{{ providerLabel(k.provider) }}</span>
258
+ <div class="text-[11px] tabular-nums text-slate-500">
259
+ {{ (k.inputTokens + k.outputTokens).toLocaleString() }} tok this window ·
260
+ {{ k.requestCount }} call{{ k.requestCount === 1 ? '' : 's' }}
261
+ </div>
262
+ </div>
263
+ <UButton
264
+ icon="i-lucide-trash-2"
265
+ color="error"
266
+ variant="ghost"
267
+ size="xs"
268
+ @click="remove(k)"
269
+ />
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </template>
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ // Prompts for the personal password (or to connect a subscription) the moment a
3
+ // start/retry of an individual-usage-pinned run needs it — driven by the
4
+ // personalSubscriptions store's `pending` state, which is set when the server replies 428
5
+ // credential_required. On submit it transparently retries the gated action and caches the
6
+ // password. The copy follows the pending vendor (Claude / GLM / ChatGPT-Codex).
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { SubscriptionVendor } from '~/types/domain'
9
+
10
+ const personal = usePersonalSubscriptionsStore()
11
+ const ui = useUiStore()
12
+ const toast = useToast()
13
+
14
+ const password = ref('')
15
+ const busy = ref(false)
16
+
17
+ const pending = computed(() => personal.pending)
18
+ const open = computed({
19
+ get: () => pending.value !== null,
20
+ set: (v: boolean) => {
21
+ if (!v) personal.dismissPending()
22
+ },
23
+ })
24
+
25
+ // A missing/expired subscription can't be solved by a password — point the user at the
26
+ // connect form instead. A password_required/wrong_password prompt just needs the field.
27
+ const needsConnect = computed(
28
+ () =>
29
+ pending.value?.reason === 'no_subscription' || pending.value?.reason === 'subscription_expired',
30
+ )
31
+
32
+ const VENDOR_LABELS: Partial<Record<SubscriptionVendor, string>> = {
33
+ claude: 'Claude',
34
+ glm: 'GLM (Z.ai)',
35
+ codex: 'ChatGPT (Codex)',
36
+ }
37
+
38
+ /** Display label for the pending vendor (falls back to the raw vendor string). */
39
+ const vendorLabel = computed(() => {
40
+ const v = pending.value?.vendor
41
+ if (!v) return 'subscription'
42
+ return VENDOR_LABELS[v] ?? v
43
+ })
44
+
45
+ watch(open, (isOpen) => {
46
+ if (isOpen) password.value = ''
47
+ })
48
+
49
+ const title = computed(() => {
50
+ switch (pending.value?.reason) {
51
+ case 'wrong_password':
52
+ return 'Incorrect personal password'
53
+ case 'no_subscription':
54
+ return `Connect your ${vendorLabel.value} subscription`
55
+ case 'subscription_expired':
56
+ return `Your ${vendorLabel.value} subscription expired`
57
+ default:
58
+ return 'Enter your personal password'
59
+ }
60
+ })
61
+
62
+ async function submit() {
63
+ if (!pending.value || password.value.length < 8) return
64
+ busy.value = true
65
+ try {
66
+ await pending.value.retry(password.value)
67
+ toast.add({ title: 'Run started', icon: 'i-lucide-check', color: 'success' })
68
+ } catch (e) {
69
+ // A fresh 428 (e.g. still-wrong password) re-arms `pending`, keeping the modal open.
70
+ toast.add({
71
+ title: 'Could not start the run',
72
+ description: e instanceof Error ? e.message : String(e),
73
+ color: 'error',
74
+ })
75
+ } finally {
76
+ busy.value = false
77
+ }
78
+ }
79
+
80
+ function goConnect() {
81
+ personal.dismissPending()
82
+ ui.openVendorCredentials()
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <UModal v-model:open="open" :title="title" :ui="{ content: 'max-w-md' }">
88
+ <template #body>
89
+ <div class="space-y-4">
90
+ <template v-if="needsConnect">
91
+ <p class="text-sm text-slate-400">
92
+ This task uses a {{ vendorLabel }} model, which runs on <strong>your own</strong>
93
+ {{ vendorLabel }} subscription. Connect (or renew) it first, then start the run again.
94
+ </p>
95
+ <div class="flex justify-end gap-2">
96
+ <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
97
+ <UButton icon="i-lucide-shield-check" @click="goConnect()"
98
+ >Connect subscription</UButton
99
+ >
100
+ </div>
101
+ </template>
102
+
103
+ <template v-else>
104
+ <p class="text-sm text-slate-400">
105
+ Enter the personal password that protects your {{ vendorLabel }} subscription. It
106
+ unlocks your credential for this run only and is cached in this browser so you won’t be
107
+ asked again for a while.
108
+ </p>
109
+ <UFormField label="Personal password">
110
+ <UInput
111
+ v-model="password"
112
+ type="password"
113
+ autofocus
114
+ placeholder="your personal password"
115
+ @keydown.enter="submit()"
116
+ />
117
+ </UFormField>
118
+ <div class="flex justify-end gap-2">
119
+ <UButton color="neutral" variant="ghost" @click="open = false">Cancel</UButton>
120
+ <UButton :loading="busy" :disabled="password.length < 8" @click="submit()">
121
+ Unlock &amp; run
122
+ </UButton>
123
+ </div>
124
+ </template>
125
+ </div>
126
+ </template>
127
+ </UModal>
128
+ </template>
@@ -0,0 +1,225 @@
1
+ <script setup lang="ts">
2
+ // Personal (individual-usage) subscriptions: Claude, GLM (Z.ai Coding Plan) and ChatGPT
3
+ // (Codex) are each licensed for INDIVIDUAL use only, so they are connected per-user here
4
+ // rather than pooled on the workspace. Each token is double-encrypted server-side under a
5
+ // personal PASSWORD (never stored); that password is what you'll enter when you start/retry
6
+ // such a run (cached locally so it's usually transparent). Recurring schedules can't use them.
7
+ import { computed, onMounted, ref } from 'vue'
8
+ import type { SubscriptionVendor } from '~/types/domain'
9
+
10
+ const personal = usePersonalSubscriptionsStore()
11
+ const toast = useToast()
12
+
13
+ /** Per-vendor metadata driving the connect form + connected-row labels. */
14
+ const PERSONAL_VENDORS: {
15
+ value: SubscriptionVendor
16
+ label: string
17
+ tokenLabel: string
18
+ tokenPlaceholder: string
19
+ steps: string[]
20
+ }[] = [
21
+ {
22
+ value: 'claude',
23
+ label: 'Claude (Pro/Max)',
24
+ tokenLabel: 'Claude token',
25
+ tokenPlaceholder: 'sk-ant-oat01-…',
26
+ steps: [
27
+ 'Install Claude Code and sign in with your Claude Pro/Max account: run `claude` once and complete the browser login.',
28
+ 'Generate a long-lived token: run `claude setup-token` and copy it.',
29
+ 'Paste it below and choose a personal password to protect it.',
30
+ ],
31
+ },
32
+ {
33
+ value: 'glm',
34
+ label: 'GLM (Z.ai Coding Plan)',
35
+ tokenLabel: 'Z.ai API key',
36
+ tokenPlaceholder: 'your GLM Coding Plan API key',
37
+ steps: [
38
+ 'Open your Z.ai GLM Coding Plan dashboard and create an API key for the Anthropic-compatible endpoint.',
39
+ 'The GLM Coding Plan is licensed to you as an individual, so it is stored per-user here (not pooled).',
40
+ 'Paste the key below and choose a personal password to protect it.',
41
+ ],
42
+ },
43
+ {
44
+ value: 'codex',
45
+ label: 'ChatGPT (Codex)',
46
+ tokenLabel: 'ChatGPT auth.json',
47
+ tokenPlaceholder: '{ "auth_mode": "chatgpt", "tokens": { … } }',
48
+ steps: [
49
+ 'Install the Codex CLI and sign in with your ChatGPT account: run `codex login` and complete the browser flow.',
50
+ 'Open the credentials file Codex wrote at ~/.codex/auth.json (on Windows %USERPROFILE%\\.codex\\auth.json).',
51
+ 'Copy the entire contents of auth.json, paste it below, and choose a personal password to protect it.',
52
+ ],
53
+ },
54
+ ]
55
+
56
+ function vendorMeta(v: SubscriptionVendor) {
57
+ return PERSONAL_VENDORS.find((m) => m.value === v)
58
+ }
59
+
60
+ function vendorLabel(v: SubscriptionVendor): string {
61
+ return vendorMeta(v)?.label ?? v
62
+ }
63
+
64
+ const vendor = ref<SubscriptionVendor>('claude')
65
+ const label = ref('')
66
+ const token = ref('')
67
+ const password = ref('')
68
+ const expiresOn = ref('') // yyyy-mm-dd (optional)
69
+ const busy = ref(false)
70
+
71
+ onMounted(() => void personal.load())
72
+
73
+ const selectedMeta = computed(() => vendorMeta(vendor.value) ?? PERSONAL_VENDORS[0]!)
74
+ const existing = computed(() => personal.subscriptions.find((s) => s.vendor === vendor.value))
75
+
76
+ /** Renewal nudges for any connected subscription that's near or past expiry. */
77
+ const renewals = computed(() =>
78
+ personal.subscriptions
79
+ .filter((s) => s.expiresAt !== null && (s.expired || s.renewSoon))
80
+ .map((s) => {
81
+ const name = vendorLabel(s.vendor)
82
+ if (s.expired)
83
+ return `Your ${name} subscription has expired — renew it and reconnect to keep running its models.`
84
+ return `Your ${name} subscription renews in ${s.expiresInDays} day${s.expiresInDays === 1 ? '' : 's'} — update it here once renewed.`
85
+ }),
86
+ )
87
+
88
+ async function connect() {
89
+ if (!token.value.trim() || password.value.length < 8) return
90
+ busy.value = true
91
+ try {
92
+ await personal.store({
93
+ vendor: vendor.value,
94
+ label: label.value.trim() || `My ${selectedMeta.value.label} subscription`,
95
+ token: token.value.trim(),
96
+ password: password.value,
97
+ expiresAt: expiresOn.value ? new Date(`${expiresOn.value}T00:00:00Z`).getTime() : null,
98
+ })
99
+ token.value = ''
100
+ password.value = ''
101
+ label.value = ''
102
+ expiresOn.value = ''
103
+ toast.add({
104
+ title: `${selectedMeta.value.label} subscription connected`,
105
+ icon: 'i-lucide-check',
106
+ color: 'success',
107
+ })
108
+ } catch (e) {
109
+ toast.add({
110
+ title: 'Could not connect subscription',
111
+ description: e instanceof Error ? e.message : String(e),
112
+ color: 'error',
113
+ })
114
+ } finally {
115
+ busy.value = false
116
+ }
117
+ }
118
+
119
+ async function disconnect(v: SubscriptionVendor) {
120
+ try {
121
+ await personal.remove(v)
122
+ toast.add({ title: 'Disconnected', icon: 'i-lucide-check' })
123
+ } catch (e) {
124
+ toast.add({
125
+ title: 'Could not disconnect',
126
+ description: e instanceof Error ? e.message : String(e),
127
+ color: 'error',
128
+ })
129
+ }
130
+ }
131
+ </script>
132
+
133
+ <template>
134
+ <div class="space-y-3">
135
+ <div>
136
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
137
+ Personal subscriptions (individual use only)
138
+ </h4>
139
+ <p class="mt-1 text-sm text-slate-400">
140
+ Claude (Pro/Max), GLM (Z.ai Coding Plan) and ChatGPT (Codex) are each licensed to you as an
141
+ individual, so they’re stored <strong>just for you</strong> and only your runs use them.
142
+ Each token is encrypted under a personal password (never stored) that you enter when you
143
+ start such a run — it’s cached in this browser for a while so it stays out of your way. The
144
+ password is a low-friction convenience and a transparency signal that the system won’t
145
+ silently share your credential — it is <strong>not</strong> a wall against a system-key
146
+ holder. These models can’t be used on recurring schedules.
147
+ </p>
148
+ </div>
149
+
150
+ <!-- connected subscriptions -->
151
+ <div
152
+ v-for="sub in personal.subscriptions"
153
+ :key="sub.vendor"
154
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
155
+ >
156
+ <div>
157
+ <span class="font-medium text-slate-200">{{ sub.label }}</span>
158
+ <span class="ml-2 text-xs text-slate-500">{{ vendorLabel(sub.vendor) }}</span>
159
+ <div class="text-[11px] text-slate-500">
160
+ <template v-if="sub.expiresAt">
161
+ Expires {{ new Date(sub.expiresAt).toLocaleDateString() }}
162
+ </template>
163
+ <template v-else>No expiry set</template>
164
+ </div>
165
+ </div>
166
+ <UButton
167
+ icon="i-lucide-trash-2"
168
+ color="error"
169
+ variant="ghost"
170
+ size="xs"
171
+ @click="disconnect(sub.vendor)"
172
+ />
173
+ </div>
174
+
175
+ <p v-for="(line, i) in renewals" :key="i" class="text-sm text-amber-400/90">{{ line }}</p>
176
+
177
+ <!-- vendor picker -->
178
+ <UFormField label="Subscription">
179
+ <USelect
180
+ v-model="vendor"
181
+ :items="PERSONAL_VENDORS.map((m) => ({ label: m.label, value: m.value }))"
182
+ class="w-64"
183
+ />
184
+ </UFormField>
185
+
186
+ <!-- connect / replace form -->
187
+ <ol
188
+ class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
189
+ >
190
+ <li v-for="(step, i) in selectedMeta.steps" :key="i">{{ step }}</li>
191
+ </ol>
192
+
193
+ <div class="space-y-2">
194
+ <UFormField label="Label (optional)">
195
+ <UInput v-model="label" :placeholder="`e.g. my ${selectedMeta.label}`" />
196
+ </UFormField>
197
+ <UFormField :label="selectedMeta.tokenLabel">
198
+ <UTextarea
199
+ v-model="token"
200
+ :rows="2"
201
+ :placeholder="selectedMeta.tokenPlaceholder"
202
+ class="font-mono"
203
+ />
204
+ </UFormField>
205
+ <div class="flex flex-wrap gap-3">
206
+ <UFormField label="Personal password (min 8 chars)" class="flex-1">
207
+ <UInput v-model="password" type="password" placeholder="protects your token" />
208
+ </UFormField>
209
+ <UFormField label="Subscription renews on (optional)">
210
+ <UInput v-model="expiresOn" type="date" />
211
+ </UFormField>
212
+ </div>
213
+ <div class="flex justify-end">
214
+ <UButton
215
+ :loading="busy"
216
+ :disabled="!token.trim() || password.length < 8"
217
+ icon="i-lucide-shield-check"
218
+ @click="connect()"
219
+ >
220
+ {{ existing ? 'Replace' : 'Connect' }}
221
+ </UButton>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </template>