arkaos 2.80.0 → 2.81.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.80.0
1
+ 2.81.0
@@ -0,0 +1,9 @@
1
+ """Profile management for ArkaOS (~/.arkaos/profile.json)."""
2
+
3
+ from core.profile.manager import (
4
+ Profile,
5
+ ProfileManager,
6
+ DEFAULT_PROFILE_PATH,
7
+ )
8
+
9
+ __all__ = ["Profile", "ProfileManager", "DEFAULT_PROFILE_PATH"]
@@ -0,0 +1,165 @@
1
+ """Profile manager — safe read/write of ~/.arkaos/profile.json (PR63 v2.81.0).
2
+
3
+ The profile is operator-local user data:
4
+ - identity (name, company, role)
5
+ - market context (language, market)
6
+ - filesystem context (projectsDir, vaultPath)
7
+ - timestamps (created, updated)
8
+
9
+ Used by:
10
+ - Sync engine (`core/sync/engine.py`) to discover project directories
11
+ from `projectsDir`
12
+ - Dashboard Settings page (PR63) for editing
13
+ - Various skills to greet by name and route by market
14
+
15
+ Lives at ``~/.arkaos/profile.json`` per ADR
16
+ `docs/adr/2026-04-17-user-data-separation.md`. The manager NEVER
17
+ raises on disk errors — read returns a default ``Profile``, write
18
+ swallows OSError so a failed save is logged but doesn't break the
19
+ caller.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from dataclasses import dataclass, field, asdict
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+
31
+ DEFAULT_PROFILE_PATH = Path.home() / ".arkaos" / "profile.json"
32
+
33
+ # Fields the API accepts in a POST payload — anything else is ignored
34
+ # so callers can't sneak in arbitrary JSON.
35
+ _WRITABLE_FIELDS = frozenset({
36
+ "name", "language", "market", "role", "company",
37
+ "projectsDir", "vaultPath",
38
+ })
39
+
40
+
41
+ @dataclass
42
+ class Profile:
43
+ """Operator profile stored at ``~/.arkaos/profile.json``."""
44
+
45
+ version: str = "2"
46
+ name: str = ""
47
+ language: str = "en"
48
+ market: str = ""
49
+ role: str = ""
50
+ company: str = ""
51
+ projectsDir: str = ""
52
+ vaultPath: str = ""
53
+ created: str = ""
54
+ updated: str = ""
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict) -> "Profile":
58
+ """Build a Profile from a JSON dict, dropping unknown keys."""
59
+ if not isinstance(data, dict):
60
+ return cls()
61
+ known = {
62
+ f.name: data[f.name]
63
+ for f in cls.__dataclass_fields__.values() # type: ignore[attr-defined]
64
+ if f.name in data and data[f.name] is not None
65
+ }
66
+ # Pydantic-free defensive conversion: every field must be a string.
67
+ for key, value in list(known.items()):
68
+ if not isinstance(value, str):
69
+ known[key] = str(value)
70
+ return cls(**known)
71
+
72
+ def to_dict(self) -> dict:
73
+ return asdict(self)
74
+
75
+
76
+ class ProfileManager:
77
+ """Read / patch / write the operator profile.
78
+
79
+ Always returns a ``Profile`` (the dataclass) — even when the file
80
+ doesn't exist or is unparseable. The default Profile carries empty
81
+ strings everywhere; the dashboard renders that as a setup CTA.
82
+ """
83
+
84
+ def __init__(self, path: Path | None = None) -> None:
85
+ self._path = path or DEFAULT_PROFILE_PATH
86
+
87
+ @property
88
+ def path(self) -> Path:
89
+ return self._path
90
+
91
+ def read(self) -> Profile:
92
+ """Return the current profile, or a default Profile on any error."""
93
+ if not self._path.exists():
94
+ return Profile()
95
+ try:
96
+ data = json.loads(self._path.read_text(encoding="utf-8"))
97
+ except (json.JSONDecodeError, OSError):
98
+ return Profile()
99
+ return Profile.from_dict(data)
100
+
101
+ def patch(self, updates: dict[str, Any]) -> Profile:
102
+ """Merge ``updates`` into the stored profile and persist.
103
+
104
+ - Drops any key not in ``_WRITABLE_FIELDS``.
105
+ - Coerces values to strings (the schema is all-string).
106
+ - Bumps ``updated`` to the current UTC timestamp.
107
+ - Initialises ``created`` if absent.
108
+ - Atomic write (.tmp + os.replace).
109
+ - Returns the new Profile.
110
+ """
111
+ current = self.read()
112
+ sanitized = {
113
+ k: ("" if v is None else str(v))
114
+ for k, v in updates.items()
115
+ if k in _WRITABLE_FIELDS
116
+ }
117
+ merged = {**current.to_dict(), **sanitized}
118
+ now = datetime.now(timezone.utc).isoformat()
119
+ merged["updated"] = now
120
+ if not merged.get("created"):
121
+ merged["created"] = now
122
+ merged["version"] = "2"
123
+ self._write(merged)
124
+ return Profile.from_dict(merged)
125
+
126
+ def _write(self, data: dict) -> None:
127
+ try:
128
+ self._path.parent.mkdir(parents=True, exist_ok=True)
129
+ tmp = self._path.with_suffix(self._path.suffix + ".tmp")
130
+ tmp.write_text(
131
+ json.dumps(data, indent=2, ensure_ascii=False),
132
+ encoding="utf-8",
133
+ )
134
+ tmp.replace(self._path)
135
+ except OSError:
136
+ # Caller still gets a Profile back from patch(); persistence
137
+ # failure is logged via stderr by upstream callers when
138
+ # appropriate. We never raise.
139
+ return
140
+
141
+
142
+ # ─── Helpers ────────────────────────────────────────────────────────────
143
+
144
+
145
+ def parse_projects_dirs(value: str) -> list[str]:
146
+ """Split the free-text ``projectsDir`` field into individual paths.
147
+
148
+ The historical schema stored e.g.
149
+ "/Users/foo/Herd para Laravel, /Users/foo/Work para Nuxt"
150
+ so the parser walks the comma-separated segments and keeps anything
151
+ that starts with ``/`` (POSIX absolute) or ``~/`` (home-relative).
152
+ """
153
+ if not value:
154
+ return []
155
+ out: list[str] = []
156
+ for raw in value.split(","):
157
+ token = raw.strip()
158
+ if not token:
159
+ continue
160
+ # First whitespace-delimited word that looks like a path wins.
161
+ for word in token.split():
162
+ if word.startswith("/") or word.startswith("~/"):
163
+ out.append(word)
164
+ break
165
+ return out
@@ -1,26 +1,126 @@
1
1
  <script setup lang="ts">
2
+ interface ProfileResponse {
3
+ version: string
4
+ name: string
5
+ language: string
6
+ market: string
7
+ role: string
8
+ company: string
9
+ projectsDir: string
10
+ vaultPath: string
11
+ created: string
12
+ updated: string
13
+ projects_dirs_list: string[]
14
+ }
15
+
2
16
  const { fetchApi, apiBase } = useApi()
17
+ const toast = useToast()
18
+
19
+ // ─── Profile (PR63 v2.81.0) ─────────────────────────────────────────────
20
+
21
+ const {
22
+ data: profile,
23
+ status: profileStatus,
24
+ error: profileError,
25
+ refresh: refreshProfile,
26
+ } = await fetchApi<ProfileResponse>('/api/profile')
27
+
28
+ const profileDraft = ref({
29
+ name: profile.value?.name ?? '',
30
+ company: profile.value?.company ?? '',
31
+ role: profile.value?.role ?? '',
32
+ market: profile.value?.market ?? '',
33
+ language: profile.value?.language ?? 'en',
34
+ vaultPath: profile.value?.vaultPath ?? '',
35
+ projectsDir: profile.value?.projectsDir ?? '',
36
+ })
37
+
38
+ watch(profile, (p) => {
39
+ if (!p) return
40
+ profileDraft.value = {
41
+ name: p.name,
42
+ company: p.company,
43
+ role: p.role,
44
+ market: p.market,
45
+ language: p.language,
46
+ vaultPath: p.vaultPath,
47
+ projectsDir: p.projectsDir,
48
+ }
49
+ }, { immediate: true })
50
+
51
+ const savingProfile = ref(false)
52
+
53
+ async function saveProfile() {
54
+ savingProfile.value = true
55
+ try {
56
+ await $fetch<ProfileResponse>(`${apiBase}/api/profile`, {
57
+ method: 'POST',
58
+ body: profileDraft.value,
59
+ })
60
+ await refreshProfile()
61
+ toast.add({
62
+ title: 'Profile saved',
63
+ description: 'Settings written to ~/.arkaos/profile.json',
64
+ color: 'success',
65
+ })
66
+ } catch (err) {
67
+ toast.add({
68
+ title: 'Save failed',
69
+ description: err instanceof Error ? err.message : 'unknown error',
70
+ color: 'error',
71
+ })
72
+ } finally {
73
+ savingProfile.value = false
74
+ }
75
+ }
76
+
77
+ const languageOptions = [
78
+ { label: 'English', value: 'en' },
79
+ { label: 'Português', value: 'pt' },
80
+ ]
3
81
 
4
- const { data, status, error, refresh } = fetchApi<any>('/api/keys')
82
+ const roleOptions = [
83
+ { label: 'Founder', value: 'founder' },
84
+ { label: 'CTO', value: 'cto' },
85
+ { label: 'CEO', value: 'ceo' },
86
+ { label: 'Engineer', value: 'engineer' },
87
+ { label: 'Designer', value: 'designer' },
88
+ { label: 'Operator', value: 'operator' },
89
+ { label: 'Consultant', value: 'consultant' },
90
+ ]
5
91
 
6
- const keys = computed(() => data.value?.keys ?? [])
92
+ // ─── API Keys (preserved from earlier) ──────────────────────────────────
93
+
94
+ const {
95
+ data: keysData,
96
+ status: keysStatus,
97
+ refresh: refreshKeys,
98
+ } = fetchApi<any>('/api/keys')
99
+
100
+ const keys = computed(() => keysData.value?.keys ?? [])
7
101
 
8
102
  const newKey = ref('')
9
103
  const newValue = ref('')
104
+ const customKeyName = ref('')
10
105
  const saving = ref(false)
11
106
  const deletingKey = ref<string | null>(null)
12
107
 
108
+ const isCustom = computed(() => newKey.value === 'custom')
109
+ const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : newKey.value)
110
+
13
111
  async function saveKey() {
14
- if (!newKey.value || !newValue.value) return
112
+ const keyName = effectiveKeyName.value
113
+ if (!keyName || !newValue.value) return
15
114
  saving.value = true
16
115
  try {
17
116
  await $fetch(`${apiBase}/api/keys`, {
18
117
  method: 'POST',
19
- body: { key: newKey.value, value: newValue.value },
118
+ body: { key: keyName, value: newValue.value },
20
119
  })
21
120
  newKey.value = ''
22
121
  newValue.value = ''
23
- await refresh()
122
+ customKeyName.value = ''
123
+ await refreshKeys()
24
124
  } catch {}
25
125
  saving.value = false
26
126
  }
@@ -29,7 +129,7 @@ async function deleteKey(keyName: string) {
29
129
  deletingKey.value = keyName
30
130
  try {
31
131
  await $fetch(`${apiBase}/api/keys/${keyName}`, { method: 'DELETE' })
32
- await refresh()
132
+ await refreshKeys()
33
133
  } catch {}
34
134
  deletingKey.value = null
35
135
  }
@@ -41,9 +141,17 @@ const keyOptions = [
41
141
  { label: 'Custom...', value: 'custom' },
42
142
  ]
43
143
 
44
- const isCustom = computed(() => newKey.value === 'custom')
45
- const customKeyName = ref('')
46
- const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : newKey.value)
144
+ // ─── Section nav ────────────────────────────────────────────────────────
145
+
146
+ type SectionId = 'profile' | 'projects' | 'keys'
147
+
148
+ const sections: { id: SectionId; label: string; icon: string }[] = [
149
+ { id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
150
+ { id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
151
+ { id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
152
+ ]
153
+
154
+ const activeSection = ref<SectionId>('profile')
47
155
  </script>
48
156
 
49
157
  <template>
@@ -57,88 +165,250 @@ const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : n
57
165
  </template>
58
166
 
59
167
  <template #body>
60
- <div class="space-y-8">
61
- <!-- API Keys Section -->
168
+ <div class="grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-6">
169
+ <!-- Section nav -->
170
+ <nav class="space-y-1" aria-label="Settings sections">
171
+ <button
172
+ v-for="s in sections"
173
+ :key="s.id"
174
+ type="button"
175
+ class="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors"
176
+ :class="activeSection === s.id
177
+ ? 'bg-primary/10 text-primary font-medium'
178
+ : 'text-muted hover:bg-elevated/50'"
179
+ @click="activeSection = s.id"
180
+ >
181
+ <UIcon :name="s.icon" class="size-4" />
182
+ <span>{{ s.label }}</span>
183
+ </button>
184
+ <p class="text-xs text-muted px-3 mt-6">
185
+ More sections (MCPs, Hooks, Plugins, Theme) coming in PR63b.
186
+ </p>
187
+ </nav>
188
+
189
+ <!-- Section content -->
62
190
  <div>
63
- <h2 class="text-lg font-semibold mb-1">API Keys</h2>
64
- <p class="text-sm text-muted mb-6">Configure API keys for external services. Keys are stored locally at ~/.arkaos/keys.json.</p>
65
-
66
- <!-- Add Key Form -->
67
- <UCard class="mb-6">
68
- <div class="space-y-4">
69
- <p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
70
- <div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
71
- <div>
72
- <label class="text-xs text-muted mb-1 block">Provider</label>
73
- <USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
74
- </div>
75
- <div v-if="isCustom">
76
- <label class="text-xs text-muted mb-1 block">Key Name</label>
77
- <UInput v-model="customKeyName" class="w-full" placeholder="MY_CUSTOM_KEY" />
191
+ <!-- Profile -->
192
+ <section v-if="activeSection === 'profile'">
193
+ <h2 class="text-lg font-semibold mb-1">Profile</h2>
194
+ <p class="text-sm text-muted mb-6">
195
+ Your identity, role, and language. Stored locally at
196
+ <code class="font-mono text-xs">~/.arkaos/profile.json</code>.
197
+ </p>
198
+
199
+ <DashboardState
200
+ :status="profileStatus"
201
+ :error="profileError"
202
+ loading-label="Loading profile"
203
+ :on-retry="() => refreshProfile()"
204
+ >
205
+ <UCard>
206
+ <div class="space-y-4">
207
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
208
+ <UFormField label="Name">
209
+ <UInput
210
+ v-model="profileDraft.name"
211
+ placeholder="André Agro Ferreira"
212
+ class="w-full"
213
+ />
214
+ </UFormField>
215
+ <UFormField label="Company">
216
+ <UInput
217
+ v-model="profileDraft.company"
218
+ placeholder="WizardingCode"
219
+ class="w-full"
220
+ />
221
+ </UFormField>
222
+ </div>
223
+
224
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
225
+ <UFormField label="Role">
226
+ <USelect
227
+ v-model="profileDraft.role"
228
+ :items="roleOptions"
229
+ placeholder="Select role"
230
+ class="w-full"
231
+ />
232
+ </UFormField>
233
+ <UFormField label="Language">
234
+ <USelect
235
+ v-model="profileDraft.language"
236
+ :items="languageOptions"
237
+ class="w-full"
238
+ />
239
+ </UFormField>
240
+ </div>
241
+
242
+ <UFormField
243
+ label="Market"
244
+ help="Comma-separated list of markets you operate in (free text)."
245
+ >
246
+ <UInput
247
+ v-model="profileDraft.market"
248
+ placeholder="Portugal, Europa, Emirados Árabes Unidos"
249
+ class="w-full"
250
+ />
251
+ </UFormField>
252
+
253
+ <UFormField
254
+ label="Vault path"
255
+ help="Where your Obsidian vault lives. Used by the KB-first hook."
256
+ >
257
+ <UInput
258
+ v-model="profileDraft.vaultPath"
259
+ placeholder="/Users/you/Documents/Vault"
260
+ class="w-full font-mono text-sm"
261
+ />
262
+ </UFormField>
263
+
264
+ <div class="flex justify-end pt-2">
265
+ <UButton
266
+ label="Save profile"
267
+ icon="i-lucide-check"
268
+ :loading="savingProfile"
269
+ @click="saveProfile"
270
+ />
271
+ </div>
78
272
  </div>
79
- <div :class="isCustom ? '' : 'md:col-span-1'">
80
- <label class="text-xs text-muted mb-1 block">Value</label>
81
- <UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
273
+ </UCard>
274
+ </DashboardState>
275
+ </section>
276
+
277
+ <!-- Projects -->
278
+ <section v-else-if="activeSection === 'projects'">
279
+ <h2 class="text-lg font-semibold mb-1">Project directories</h2>
280
+ <p class="text-sm text-muted mb-6">
281
+ Directories the sync engine scans for projects.
282
+ Comma-separated absolute paths (e.g.
283
+ <code class="font-mono text-xs">~/Herd</code>,
284
+ <code class="font-mono text-xs">~/Work</code>).
285
+ </p>
286
+
287
+ <UCard>
288
+ <div class="space-y-4">
289
+ <UFormField
290
+ label="projectsDir"
291
+ help="Free text. Each comma-separated segment's leading absolute path is consumed by the sync engine."
292
+ >
293
+ <UTextarea
294
+ v-model="profileDraft.projectsDir"
295
+ :rows="3"
296
+ placeholder="/Users/you/Herd para projectos laravel, /Users/you/Work para projectos Nuxt e Python"
297
+ class="w-full font-mono text-sm"
298
+ />
299
+ </UFormField>
300
+
301
+ <div v-if="profile?.projects_dirs_list?.length" class="rounded-lg border border-default p-3">
302
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">
303
+ Currently parsed
304
+ </p>
305
+ <ul class="space-y-1">
306
+ <li
307
+ v-for="dir in profile.projects_dirs_list"
308
+ :key="dir"
309
+ class="flex items-center gap-2 text-sm"
310
+ >
311
+ <UIcon name="i-lucide-folder" class="size-4 text-muted" />
312
+ <code class="font-mono text-xs">{{ dir }}</code>
313
+ </li>
314
+ </ul>
82
315
  </div>
83
- <div>
316
+
317
+ <div class="flex justify-end pt-2">
84
318
  <UButton
85
- label="Save Key"
86
- icon="i-lucide-key"
87
- :loading="saving"
88
- :disabled="!effectiveKeyName || !newValue"
89
- @click="() => { newKey = effectiveKeyName; saveKey() }"
90
- block
319
+ label="Save directories"
320
+ icon="i-lucide-check"
321
+ :loading="savingProfile"
322
+ @click="saveProfile"
91
323
  />
92
324
  </div>
93
325
  </div>
94
- </div>
95
- </UCard>
96
-
97
- <!-- Keys List -->
98
- <div v-if="status === 'pending'" class="flex items-center justify-center py-8">
99
- <UIcon name="i-lucide-loader-2" class="size-6 animate-spin text-muted" />
100
- </div>
101
-
102
- <div v-else class="space-y-2">
103
- <div
104
- v-for="k in keys"
105
- :key="k.key"
106
- class="flex items-center gap-4 p-3 rounded-lg border border-default"
326
+ </UCard>
327
+ </section>
328
+
329
+ <!-- API Keys -->
330
+ <section v-else-if="activeSection === 'keys'">
331
+ <h2 class="text-lg font-semibold mb-1">API Keys</h2>
332
+ <p class="text-sm text-muted mb-6">
333
+ Configure API keys for external services. Keys are stored
334
+ locally at <code class="font-mono text-xs">~/.arkaos/keys.json</code>.
335
+ </p>
336
+
337
+ <UCard class="mb-6">
338
+ <div class="space-y-4">
339
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
340
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
341
+ <div>
342
+ <label class="text-xs text-muted mb-1 block">Provider</label>
343
+ <USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
344
+ </div>
345
+ <div v-if="isCustom">
346
+ <label class="text-xs text-muted mb-1 block">Key Name</label>
347
+ <UInput v-model="customKeyName" class="w-full" placeholder="MY_CUSTOM_KEY" />
348
+ </div>
349
+ <div :class="isCustom ? '' : 'md:col-span-1'">
350
+ <label class="text-xs text-muted mb-1 block">Value</label>
351
+ <UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
352
+ </div>
353
+ <div>
354
+ <UButton
355
+ label="Save Key"
356
+ icon="i-lucide-key"
357
+ :loading="saving"
358
+ :disabled="!effectiveKeyName || !newValue"
359
+ block
360
+ @click="saveKey"
361
+ />
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </UCard>
366
+
367
+ <DashboardState
368
+ :status="keysStatus"
369
+ :empty="!keys.length"
370
+ empty-title="No keys configured"
371
+ empty-icon="i-lucide-key"
372
+ loading-label="Loading API keys"
107
373
  >
108
- <div class="flex-1 min-w-0">
109
- <div class="flex items-center gap-2">
110
- <span class="text-sm font-mono font-medium">{{ k.key }}</span>
111
- <UBadge :label="k.provider" variant="subtle" size="xs" />
112
- <UBadge
113
- v-if="k.configured"
114
- label="Configured"
115
- color="success"
116
- variant="subtle"
117
- size="xs"
118
- />
119
- <UBadge
120
- v-else
121
- label="Not Set"
122
- color="neutral"
123
- variant="outline"
374
+ <div class="space-y-2">
375
+ <div
376
+ v-for="k in keys"
377
+ :key="k.key"
378
+ class="flex items-center gap-4 p-3 rounded-lg border border-default"
379
+ >
380
+ <div class="flex-1 min-w-0">
381
+ <div class="flex items-center gap-2">
382
+ <span class="text-sm font-mono font-medium">{{ k.key }}</span>
383
+ <UBadge :label="k.provider" variant="subtle" size="xs" />
384
+ <UBadge
385
+ v-if="k.configured"
386
+ label="Configured"
387
+ color="success"
388
+ variant="subtle"
389
+ size="xs"
390
+ />
391
+ <UBadge v-else label="Not Set" color="neutral" variant="outline" size="xs" />
392
+ </div>
393
+ <p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
394
+ <p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">
395
+ {{ k.masked_value }}
396
+ </p>
397
+ </div>
398
+ <UButton
399
+ v-if="k.configured && k.masked_value !== '(from environment)'"
400
+ icon="i-lucide-trash-2"
401
+ variant="ghost"
402
+ color="error"
124
403
  size="xs"
404
+ :loading="deletingKey === k.key"
405
+ aria-label="Delete key"
406
+ @click="deleteKey(k.key)"
125
407
  />
126
408
  </div>
127
- <p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
128
- <p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">{{ k.masked_value }}</p>
129
409
  </div>
130
- <UButton
131
- v-if="k.configured && k.masked_value !== '(from environment)'"
132
- icon="i-lucide-trash-2"
133
- variant="ghost"
134
- color="error"
135
- size="xs"
136
- :loading="deletingKey === k.key"
137
- @click="deleteKey(k.key)"
138
- aria-label="Delete key"
139
- />
140
- </div>
141
- </div>
410
+ </DashboardState>
411
+ </section>
142
412
  </div>
143
413
  </div>
144
414
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.80.0",
3
+ "version": "2.81.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.80.0"
3
+ version = "2.81.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -729,6 +729,43 @@ def persona_build(body: dict):
729
729
  }
730
730
 
731
731
 
732
+ # --- Profile (PR63 v2.81.0) ---
733
+
734
+ @app.get("/api/profile")
735
+ def profile_get():
736
+ """Return the operator profile from ~/.arkaos/profile.json.
737
+
738
+ Always returns a profile object (default empty strings when the
739
+ file doesn't exist yet) so the dashboard can render a setup form
740
+ instead of an error.
741
+ """
742
+ from core.profile import ProfileManager
743
+ from core.profile.manager import parse_projects_dirs
744
+ profile = ProfileManager().read()
745
+ payload = profile.to_dict()
746
+ # Convenience: split projectsDir into a list for the UI.
747
+ payload["projects_dirs_list"] = parse_projects_dirs(profile.projectsDir)
748
+ return payload
749
+
750
+
751
+ @app.post("/api/profile")
752
+ def profile_post(body: dict):
753
+ """Patch the operator profile.
754
+
755
+ Only the writable fields are honoured (name, language, market,
756
+ role, company, projectsDir, vaultPath). Unknown keys are silently
757
+ dropped. Returns the updated profile.
758
+ """
759
+ if not isinstance(body, dict):
760
+ return {"error": "body must be an object"}
761
+ from core.profile import ProfileManager
762
+ from core.profile.manager import parse_projects_dirs
763
+ updated = ProfileManager().patch(body)
764
+ payload = updated.to_dict()
765
+ payload["projects_dirs_list"] = parse_projects_dirs(updated.projectsDir)
766
+ return payload
767
+
768
+
732
769
  # --- API Keys ---
733
770
 
734
771
  @app.get("/api/keys")