arkaos 2.79.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 +1 -1
- package/core/profile/__init__.py +9 -0
- package/core/profile/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/profile/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/profile/manager.py +165 -0
- package/dashboard/app/components/DashboardState.vue +112 -0
- package/dashboard/app/pages/agents/index.vue +10 -21
- package/dashboard/app/pages/budget.vue +8 -11
- package/dashboard/app/pages/health.vue +10 -21
- package/dashboard/app/pages/index.vue +7 -15
- package/dashboard/app/pages/settings.vue +349 -79
- package/dashboard/app/pages/tasks.vue +7 -15
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +37 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.81.0
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR64 v2.80.0 — shared loading / error / empty wrapper.
|
|
3
|
+
//
|
|
4
|
+
// Every page in dashboard/app/pages/ used to duplicate the same triple:
|
|
5
|
+
//
|
|
6
|
+
// <div v-if="status === 'pending'"><spinner/></div>
|
|
7
|
+
// <div v-else-if="error"><alert + Retry/></div>
|
|
8
|
+
// <template v-else>... content ...</template>
|
|
9
|
+
//
|
|
10
|
+
// Five copies of that pattern with subtle drift (different icon sizes,
|
|
11
|
+
// different empty-state shapes, inconsistent ARIA roles, some with
|
|
12
|
+
// retry buttons missing). PR64 extracts it into one component so the
|
|
13
|
+
// rest of the dashboard work (PR63 Settings, PR65 Budget, PR66 Index
|
|
14
|
+
// rebuild, etc.) inherits a consistent shell.
|
|
15
|
+
//
|
|
16
|
+
// Slots:
|
|
17
|
+
// default — the success/content block (only rendered on 'success')
|
|
18
|
+
// empty — optional override for the empty state (defaults to
|
|
19
|
+
// generic "no data" with the empty-* props below)
|
|
20
|
+
// loading — optional override for the spinner (rarely needed)
|
|
21
|
+
// error — optional override for the error state (rarely needed)
|
|
22
|
+
//
|
|
23
|
+
// The component never owns the data — pages still call useFetch /
|
|
24
|
+
// fetchApi and pass `status` + `error` + an `empty` boolean in.
|
|
25
|
+
|
|
26
|
+
import type { AsyncDataRequestStatus } from 'nuxt/app'
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
/** useFetch/useAsyncData status. 'pending' shows spinner. */
|
|
30
|
+
status: AsyncDataRequestStatus
|
|
31
|
+
/** Error from useFetch — present means render the error block. */
|
|
32
|
+
error?: Error | null
|
|
33
|
+
/** True when the request succeeded but returned no rows.
|
|
34
|
+
* Pages compute this from their data shape (e.g. `!list.length`). */
|
|
35
|
+
empty?: boolean
|
|
36
|
+
/** Heading for the default empty state. */
|
|
37
|
+
emptyTitle?: string
|
|
38
|
+
/** Helper text for the default empty state. */
|
|
39
|
+
emptyDescription?: string
|
|
40
|
+
/** Icon for the default empty state. Defaults to inbox. */
|
|
41
|
+
emptyIcon?: string
|
|
42
|
+
/** Optional retry handler — when provided, the error block shows a button. */
|
|
43
|
+
onRetry?: () => void | Promise<void>
|
|
44
|
+
/** Optional ARIA label for the loading region. */
|
|
45
|
+
loadingLabel?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
49
|
+
error: null,
|
|
50
|
+
empty: false,
|
|
51
|
+
emptyTitle: 'No data',
|
|
52
|
+
emptyDescription: '',
|
|
53
|
+
emptyIcon: 'i-lucide-inbox',
|
|
54
|
+
loadingLabel: 'Loading',
|
|
55
|
+
})
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<!-- Loading -->
|
|
60
|
+
<div
|
|
61
|
+
v-if="status === 'pending'"
|
|
62
|
+
class="flex items-center justify-center py-12"
|
|
63
|
+
:aria-label="loadingLabel"
|
|
64
|
+
role="status"
|
|
65
|
+
>
|
|
66
|
+
<slot name="loading">
|
|
67
|
+
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
|
|
68
|
+
</slot>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<!-- Error -->
|
|
72
|
+
<div
|
|
73
|
+
v-else-if="error"
|
|
74
|
+
class="flex flex-col items-center justify-center gap-4 py-12"
|
|
75
|
+
role="alert"
|
|
76
|
+
>
|
|
77
|
+
<slot name="error" :error="error">
|
|
78
|
+
<UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
|
|
79
|
+
<p class="text-sm text-muted">
|
|
80
|
+
{{ error.message || 'Failed to load data.' }}
|
|
81
|
+
</p>
|
|
82
|
+
<UButton
|
|
83
|
+
v-if="onRetry"
|
|
84
|
+
label="Retry"
|
|
85
|
+
variant="outline"
|
|
86
|
+
color="primary"
|
|
87
|
+
icon="i-lucide-refresh-cw"
|
|
88
|
+
@click="onRetry"
|
|
89
|
+
/>
|
|
90
|
+
</slot>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Empty -->
|
|
94
|
+
<div
|
|
95
|
+
v-else-if="empty"
|
|
96
|
+
class="flex flex-col items-center justify-center gap-4 py-16"
|
|
97
|
+
>
|
|
98
|
+
<slot name="empty">
|
|
99
|
+
<UIcon :name="emptyIcon" class="size-16 text-muted" />
|
|
100
|
+
<h3 class="text-lg font-semibold text-highlighted">{{ emptyTitle }}</h3>
|
|
101
|
+
<p
|
|
102
|
+
v-if="emptyDescription"
|
|
103
|
+
class="text-sm text-muted text-center max-w-md"
|
|
104
|
+
>
|
|
105
|
+
{{ emptyDescription }}
|
|
106
|
+
</p>
|
|
107
|
+
</slot>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<!-- Content -->
|
|
111
|
+
<slot v-else />
|
|
112
|
+
</template>
|
|
@@ -127,26 +127,15 @@ function goToAgent(id: string) {
|
|
|
127
127
|
</template>
|
|
128
128
|
|
|
129
129
|
<template #body>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
|
|
140
|
-
</div>
|
|
141
|
-
|
|
142
|
-
<!-- Empty -->
|
|
143
|
-
<div v-else-if="!agents.length" class="flex flex-col items-center justify-center gap-4 py-12">
|
|
144
|
-
<UIcon name="i-lucide-users" class="size-12 text-muted" />
|
|
145
|
-
<p class="text-sm text-muted">No agents found.</p>
|
|
146
|
-
</div>
|
|
147
|
-
|
|
148
|
-
<!-- Content -->
|
|
149
|
-
<template v-else>
|
|
130
|
+
<DashboardState
|
|
131
|
+
:status="status"
|
|
132
|
+
:error="error"
|
|
133
|
+
:empty="!agents.length"
|
|
134
|
+
empty-title="No agents found"
|
|
135
|
+
empty-icon="i-lucide-users"
|
|
136
|
+
loading-label="Loading agents"
|
|
137
|
+
:on-retry="() => refresh()"
|
|
138
|
+
>
|
|
150
139
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
|
151
140
|
<UInput
|
|
152
141
|
v-model="search"
|
|
@@ -219,7 +208,7 @@ function goToAgent(id: string) {
|
|
|
219
208
|
@update:page="(val) => page = val"
|
|
220
209
|
/>
|
|
221
210
|
</div>
|
|
222
|
-
</
|
|
211
|
+
</DashboardState>
|
|
223
212
|
</template>
|
|
224
213
|
</UDashboardPanel>
|
|
225
214
|
</template>
|
|
@@ -37,17 +37,13 @@ const tierLabels: Record<number, string> = {
|
|
|
37
37
|
</template>
|
|
38
38
|
|
|
39
39
|
<template #body>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<UButton label="Retry" variant="outline" icon="i-lucide-refresh-cw" @click="refresh()" />
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div v-else class="space-y-6">
|
|
40
|
+
<DashboardState
|
|
41
|
+
:status="status"
|
|
42
|
+
:error="error"
|
|
43
|
+
loading-label="Loading budget"
|
|
44
|
+
:on-retry="() => refresh()"
|
|
45
|
+
>
|
|
46
|
+
<div class="space-y-6">
|
|
51
47
|
<!-- Monthly Summary -->
|
|
52
48
|
<UCard>
|
|
53
49
|
<div class="space-y-3">
|
|
@@ -127,6 +123,7 @@ const tierLabels: Record<number, string> = {
|
|
|
127
123
|
</div>
|
|
128
124
|
</div>
|
|
129
125
|
</div>
|
|
126
|
+
</DashboardState>
|
|
130
127
|
</template>
|
|
131
128
|
</UDashboardPanel>
|
|
132
129
|
</template>
|
|
@@ -30,26 +30,15 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
|
|
|
30
30
|
</template>
|
|
31
31
|
|
|
32
32
|
<template #body>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<!-- Empty -->
|
|
46
|
-
<div v-else-if="!checks.length" class="flex flex-col items-center justify-center gap-4 py-12">
|
|
47
|
-
<UIcon name="i-lucide-heart-pulse" class="size-12 text-muted" />
|
|
48
|
-
<p class="text-sm text-muted">No health checks available.</p>
|
|
49
|
-
</div>
|
|
50
|
-
|
|
51
|
-
<!-- Content -->
|
|
52
|
-
<template v-else>
|
|
33
|
+
<DashboardState
|
|
34
|
+
:status="status"
|
|
35
|
+
:error="error"
|
|
36
|
+
:empty="!checks.length"
|
|
37
|
+
empty-title="No health checks available"
|
|
38
|
+
empty-icon="i-lucide-heart-pulse"
|
|
39
|
+
loading-label="Loading health checks"
|
|
40
|
+
:on-retry="() => refresh()"
|
|
41
|
+
>
|
|
53
42
|
<!-- Overall Status -->
|
|
54
43
|
<div
|
|
55
44
|
class="mb-6 rounded-lg border p-6 text-center"
|
|
@@ -92,7 +81,7 @@ const allPassed = computed(() => passed.value === total.value && total.value > 0
|
|
|
92
81
|
/>
|
|
93
82
|
</div>
|
|
94
83
|
</div>
|
|
95
|
-
</
|
|
84
|
+
</DashboardState>
|
|
96
85
|
</template>
|
|
97
86
|
</UDashboardPanel>
|
|
98
87
|
</template>
|
|
@@ -39,20 +39,12 @@ function formatCurrency(value: number): string {
|
|
|
39
39
|
</template>
|
|
40
40
|
|
|
41
41
|
<template #body>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
|
|
49
|
-
<UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
|
|
50
|
-
<p class="text-sm text-muted">Failed to load overview data.</p>
|
|
51
|
-
<UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<!-- Content -->
|
|
55
|
-
<template v-else>
|
|
42
|
+
<DashboardState
|
|
43
|
+
:status="status"
|
|
44
|
+
:error="error"
|
|
45
|
+
loading-label="Loading overview"
|
|
46
|
+
:on-retry="() => refresh()"
|
|
47
|
+
>
|
|
56
48
|
<!-- Stats Grid -->
|
|
57
49
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
|
58
50
|
<div
|
|
@@ -120,7 +112,7 @@ function formatCurrency(value: number): string {
|
|
|
120
112
|
</div>
|
|
121
113
|
</div>
|
|
122
114
|
</div>
|
|
123
|
-
</
|
|
115
|
+
</DashboardState>
|
|
124
116
|
</template>
|
|
125
117
|
</UDashboardPanel>
|
|
126
118
|
</template>
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
118
|
+
body: { key: keyName, value: newValue.value },
|
|
20
119
|
})
|
|
21
120
|
newKey.value = ''
|
|
22
121
|
newValue.value = ''
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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="
|
|
61
|
-
<!--
|
|
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
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
316
|
+
|
|
317
|
+
<div class="flex justify-end pt-2">
|
|
84
318
|
<UButton
|
|
85
|
-
label="Save
|
|
86
|
-
icon="i-lucide-
|
|
87
|
-
:loading="
|
|
88
|
-
|
|
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
|
-
</
|
|
95
|
-
</
|
|
96
|
-
|
|
97
|
-
<!-- Keys
|
|
98
|
-
<
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class="
|
|
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="
|
|
109
|
-
<div
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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>
|
|
@@ -94,20 +94,12 @@ const columns: TableColumn<Task>[] = [
|
|
|
94
94
|
</template>
|
|
95
95
|
|
|
96
96
|
<template #body>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<div v-else-if="error" class="flex flex-col items-center justify-center gap-4 py-12" role="alert">
|
|
104
|
-
<UIcon name="i-lucide-alert-triangle" class="size-12 text-red-500" />
|
|
105
|
-
<p class="text-sm text-muted">Failed to load tasks.</p>
|
|
106
|
-
<UButton label="Retry" variant="outline" color="primary" icon="i-lucide-refresh-cw" @click="refresh()" />
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
<!-- Content -->
|
|
110
|
-
<template v-else>
|
|
97
|
+
<DashboardState
|
|
98
|
+
:status="status"
|
|
99
|
+
:error="error"
|
|
100
|
+
loading-label="Loading tasks"
|
|
101
|
+
:on-retry="() => refresh()"
|
|
102
|
+
>
|
|
111
103
|
<!-- Summary Cards -->
|
|
112
104
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
113
105
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
@@ -197,7 +189,7 @@ const columns: TableColumn<Task>[] = [
|
|
|
197
189
|
</template>
|
|
198
190
|
</UTable>
|
|
199
191
|
</div>
|
|
200
|
-
</
|
|
192
|
+
</DashboardState>
|
|
201
193
|
</template>
|
|
202
194
|
</UDashboardPanel>
|
|
203
195
|
</template>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -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")
|