arkaos 2.87.0 → 2.89.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.87.0
1
+ 2.89.0
@@ -324,6 +324,90 @@ async function handleSearch() {
324
324
  function formatScore(score: number): string {
325
325
  return `${(score * 100).toFixed(0)}%`
326
326
  }
327
+
328
+ // PR71 v2.88.0 — delete all chunks from a given source.
329
+
330
+ const deletingSource = ref<string | null>(null)
331
+
332
+ async function askDeleteSource(source: string) {
333
+ if (!source) return
334
+ if (typeof window === 'undefined') return
335
+ const ok = window.confirm(
336
+ `Delete every indexed chunk from this source?\n\n${source}\n\n`
337
+ + 'This removes the source from search results but does not delete the original file. '
338
+ + 'You can re-ingest the source later if needed.',
339
+ )
340
+ if (!ok) return
341
+ await deleteSource(source)
342
+ }
343
+
344
+ async function deleteSource(source: string) {
345
+ deletingSource.value = source
346
+ try {
347
+ const res = await $fetch<{ deleted?: number, source?: string, error?: string }>(
348
+ `${apiBase}/api/knowledge/sources`,
349
+ { method: 'DELETE', query: { source } },
350
+ )
351
+ if (res.error) {
352
+ toast.add({
353
+ title: 'Delete failed',
354
+ description: res.error,
355
+ color: 'error',
356
+ })
357
+ return
358
+ }
359
+ const deleted = res.deleted ?? 0
360
+ // Drop the matching rows from the in-memory list without a full re-fetch.
361
+ searchResults.value = searchResults.value.filter((r) => r.source !== source)
362
+ searchTotal.value = searchResults.value.length
363
+ // Refresh stats so the chunk count in the header updates.
364
+ if (typeof refresh === 'function') {
365
+ await refresh()
366
+ }
367
+ toast.add({
368
+ title: deleted > 0
369
+ ? `Deleted ${deleted} chunk${deleted === 1 ? '' : 's'}`
370
+ : 'Nothing to delete',
371
+ description: source,
372
+ color: 'success',
373
+ })
374
+ } catch (err) {
375
+ toast.add({
376
+ title: 'Delete failed',
377
+ description: err instanceof Error ? err.message : 'unknown error',
378
+ color: 'error',
379
+ })
380
+ } finally {
381
+ deletingSource.value = null
382
+ }
383
+ }
384
+
385
+ // PR71 — highlight the search query in the preview text.
386
+ // Tolerates malformed regex (escapes special characters) and HTML-
387
+ // escapes the input so v-html'd output is safe from XSS via DB rows.
388
+ function highlightMatches(text: string, query: string): string {
389
+ const safe = escapeHtml(text || '')
390
+ const q = (query || '').trim()
391
+ if (!q) return safe
392
+ const pattern = new RegExp(`(${escapeRegex(q)})`, 'gi')
393
+ return safe.replace(
394
+ pattern,
395
+ '<mark class="bg-primary/20 text-primary rounded px-0.5">$1</mark>',
396
+ )
397
+ }
398
+
399
+ function escapeHtml(value: string): string {
400
+ return value
401
+ .replace(/&/g, '&amp;')
402
+ .replace(/</g, '&lt;')
403
+ .replace(/>/g, '&gt;')
404
+ .replace(/"/g, '&quot;')
405
+ .replace(/'/g, '&#39;')
406
+ }
407
+
408
+ function escapeRegex(value: string): string {
409
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
410
+ }
327
411
  </script>
328
412
 
329
413
  <template>
@@ -771,17 +855,30 @@ function formatScore(score: number): string {
771
855
  {{ result.heading }}
772
856
  </span>
773
857
  </div>
774
- <span class="text-xs text-muted whitespace-nowrap">
775
- Score: {{ formatScore(result.score) }}
776
- </span>
858
+ <div class="flex items-center gap-2 shrink-0">
859
+ <span class="text-xs text-muted whitespace-nowrap">
860
+ Score: {{ formatScore(result.score) }}
861
+ </span>
862
+ <UButton
863
+ v-if="result.source"
864
+ :icon="deletingSource === result.source
865
+ ? 'i-lucide-loader-2'
866
+ : 'i-lucide-trash-2'"
867
+ :loading="deletingSource === result.source"
868
+ variant="ghost"
869
+ color="error"
870
+ size="xs"
871
+ aria-label="Delete all chunks from this source"
872
+ @click.stop="askDeleteSource(result.source)"
873
+ />
874
+ </div>
777
875
  </div>
778
876
  <p v-if="result.source" class="text-xs text-muted mb-1 truncate">
779
877
  <UIcon name="i-lucide-file-text" class="size-3 inline-block mr-1" />
780
878
  {{ result.source }}
781
879
  </p>
782
- <p class="text-sm text-muted line-clamp-3">
783
- {{ result.text || result.content }}
784
- </p>
880
+ <!-- PR71 v2.88.0 — highlight query matches in the preview -->
881
+ <p class="text-sm text-muted line-clamp-3" v-html="highlightMatches(result.text || result.content, searchQuery)" />
785
882
  </div>
786
883
  </div>
787
884
 
@@ -141,14 +141,87 @@ const keyOptions = [
141
141
  { label: 'Custom...', value: 'custom' },
142
142
  ]
143
143
 
144
+ // ─── PR63b v2.89.0 — MCPs / Hooks / Plugins / Theme sections ────────────
145
+
146
+ interface McpRow {
147
+ name: string
148
+ source: 'user-global' | 'arkaos-registry' | string
149
+ transport: string
150
+ command: string
151
+ }
152
+
153
+ interface HookCommand {
154
+ command: string
155
+ type: string
156
+ timeout?: number | null
157
+ }
158
+
159
+ interface HookRow {
160
+ hook: string
161
+ count: number
162
+ commands: HookCommand[]
163
+ }
164
+
165
+ interface PluginRow {
166
+ name: string
167
+ marketplace: string
168
+ version: string
169
+ scope: string
170
+ installed_at: string
171
+ last_updated: string
172
+ }
173
+
174
+ const { data: mcpsData, refresh: refreshMcps } = fetchApi<{ mcps: McpRow[], total: number }>('/api/settings/mcps')
175
+ const { data: hooksData, refresh: refreshHooks } = fetchApi<{
176
+ hooks: HookRow[]
177
+ settings_path: string
178
+ hard_enforcement: boolean
179
+ }>('/api/settings/hooks')
180
+ const { data: pluginsData, refresh: refreshPlugins } = fetchApi<{
181
+ plugins: PluginRow[]
182
+ total: number
183
+ plugins_path: string
184
+ }>('/api/settings/plugins')
185
+
186
+ const mcps = computed(() => mcpsData.value?.mcps ?? [])
187
+ const hooks = computed(() => hooksData.value?.hooks ?? [])
188
+ const plugins = computed(() => pluginsData.value?.plugins ?? [])
189
+
190
+ // Theme — Nuxt UI ships useColorMode; we just expose a picker.
191
+ const colorMode = useColorMode()
192
+ const themeOptions = [
193
+ { label: 'System (auto)', value: 'system' },
194
+ { label: 'Light', value: 'light' },
195
+ { label: 'Dark', value: 'dark' },
196
+ ]
197
+
198
+ function transportColor(transport: string): 'primary' | 'warning' | 'success' | 'neutral' {
199
+ if (transport === 'stdio') return 'primary'
200
+ if (transport === 'http' || transport === 'sse') return 'success'
201
+ return 'neutral'
202
+ }
203
+
204
+ function formatInstalledAt(iso: string): string {
205
+ if (!iso) return ''
206
+ try {
207
+ return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(iso))
208
+ } catch {
209
+ return iso
210
+ }
211
+ }
212
+
144
213
  // ─── Section nav ────────────────────────────────────────────────────────
145
214
 
146
- type SectionId = 'profile' | 'projects' | 'keys'
215
+ type SectionId = 'profile' | 'projects' | 'keys' | 'mcps' | 'hooks' | 'plugins' | 'theme'
147
216
 
148
217
  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' },
218
+ { id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
219
+ { id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
220
+ { id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
221
+ { id: 'mcps', label: 'MCPs', icon: 'i-lucide-plug-2' },
222
+ { id: 'hooks', label: 'Hooks', icon: 'i-lucide-webhook' },
223
+ { id: 'plugins', label: 'Plugins', icon: 'i-lucide-puzzle' },
224
+ { id: 'theme', label: 'Theme', icon: 'i-lucide-palette' },
152
225
  ]
153
226
 
154
227
  const activeSection = ref<SectionId>('profile')
@@ -182,7 +255,8 @@ const activeSection = ref<SectionId>('profile')
182
255
  <span>{{ s.label }}</span>
183
256
  </button>
184
257
  <p class="text-xs text-muted px-3 mt-6">
185
- More sections (MCPs, Hooks, Plugins, Theme) coming in PR63b.
258
+ 7 sections. Profile + Projects edit data; everything else is
259
+ read-only diagnostics until an explicit edit endpoint lands.
186
260
  </p>
187
261
  </nav>
188
262
 
@@ -409,6 +483,183 @@ const activeSection = ref<SectionId>('profile')
409
483
  </div>
410
484
  </DashboardState>
411
485
  </section>
486
+
487
+ <!-- MCPs -->
488
+ <section v-else-if="activeSection === 'mcps'">
489
+ <div class="flex items-baseline justify-between mb-1">
490
+ <h2 class="text-lg font-semibold">MCPs</h2>
491
+ <UButton
492
+ label="Refresh"
493
+ variant="ghost"
494
+ icon="i-lucide-refresh-cw"
495
+ size="xs"
496
+ @click="refreshMcps()"
497
+ />
498
+ </div>
499
+ <p class="text-sm text-muted mb-6">
500
+ MCP servers configured globally for your Claude Code account.
501
+ Sourced from <code class="font-mono text-xs">~/.claude.json</code>
502
+ and the ArkaOS registry. Read-only.
503
+ </p>
504
+ <div v-if="!mcps.length" class="rounded-lg border border-default p-6 text-center">
505
+ <UIcon name="i-lucide-plug-2" class="size-10 text-muted mx-auto mb-2" />
506
+ <p class="text-sm text-muted">No MCP servers configured.</p>
507
+ </div>
508
+ <div v-else class="space-y-2">
509
+ <div
510
+ v-for="m in mcps"
511
+ :key="`${m.source}:${m.name}`"
512
+ class="flex items-center gap-3 rounded-lg border border-default p-3"
513
+ >
514
+ <UIcon name="i-lucide-plug-2" class="size-4 text-muted shrink-0" />
515
+ <div class="flex-1 min-w-0">
516
+ <div class="flex items-center gap-2 mb-0.5">
517
+ <span class="text-sm font-mono font-medium">{{ m.name }}</span>
518
+ <UBadge :label="m.source" variant="outline" size="xs" />
519
+ <UBadge
520
+ :label="m.transport"
521
+ :color="transportColor(m.transport)"
522
+ variant="subtle"
523
+ size="xs"
524
+ />
525
+ </div>
526
+ <p v-if="m.command" class="text-xs font-mono text-muted truncate" :title="m.command">
527
+ {{ m.command }}
528
+ </p>
529
+ </div>
530
+ </div>
531
+ </div>
532
+ </section>
533
+
534
+ <!-- Hooks -->
535
+ <section v-else-if="activeSection === 'hooks'">
536
+ <div class="flex items-baseline justify-between mb-1">
537
+ <h2 class="text-lg font-semibold">Hooks</h2>
538
+ <UButton
539
+ label="Refresh"
540
+ variant="ghost"
541
+ icon="i-lucide-refresh-cw"
542
+ size="xs"
543
+ @click="refreshHooks()"
544
+ />
545
+ </div>
546
+ <p class="text-sm text-muted mb-6">
547
+ Claude Code hooks wired by the ArkaOS installer.
548
+ Sourced from
549
+ <code class="font-mono text-xs">{{ hooksData?.settings_path ?? '~/.claude/settings.json' }}</code>.
550
+ Read-only — re-wire via <code class="font-mono text-xs">npx arkaos@latest update</code>.
551
+ </p>
552
+ <div
553
+ v-if="hooksData?.hard_enforcement"
554
+ class="mb-4 rounded-lg border border-primary/30 bg-primary/5 p-3 text-sm"
555
+ >
556
+ <UIcon name="i-lucide-shield-check" class="size-4 inline text-primary mr-1" />
557
+ Hard enforcement is <strong>ON</strong>. Effect tools require
558
+ <code class="font-mono text-xs">[arka:routing]</code> markers.
559
+ </div>
560
+ <div v-if="!hooks.length" class="rounded-lg border border-default p-6 text-center">
561
+ <UIcon name="i-lucide-webhook" class="size-10 text-muted mx-auto mb-2" />
562
+ <p class="text-sm text-muted">No hooks wired in settings.json.</p>
563
+ </div>
564
+ <div v-else class="space-y-3">
565
+ <div
566
+ v-for="h in hooks"
567
+ :key="h.hook"
568
+ class="rounded-lg border border-default p-3"
569
+ >
570
+ <div class="flex items-center gap-2 mb-2">
571
+ <span class="text-sm font-mono font-semibold">{{ h.hook }}</span>
572
+ <UBadge :label="`${h.count}`" variant="subtle" size="xs" />
573
+ </div>
574
+ <ul class="space-y-1">
575
+ <li
576
+ v-for="(c, idx) in h.commands"
577
+ :key="idx"
578
+ class="flex items-center gap-2 text-xs"
579
+ >
580
+ <UIcon name="i-lucide-terminal" class="size-3 text-muted shrink-0" />
581
+ <code class="font-mono text-xs truncate flex-1" :title="c.command">
582
+ {{ c.command }}
583
+ </code>
584
+ <span v-if="c.timeout" class="text-muted whitespace-nowrap">
585
+ {{ c.timeout }}s
586
+ </span>
587
+ </li>
588
+ </ul>
589
+ </div>
590
+ </div>
591
+ </section>
592
+
593
+ <!-- Plugins -->
594
+ <section v-else-if="activeSection === 'plugins'">
595
+ <div class="flex items-baseline justify-between mb-1">
596
+ <h2 class="text-lg font-semibold">Plugins</h2>
597
+ <UButton
598
+ label="Refresh"
599
+ variant="ghost"
600
+ icon="i-lucide-refresh-cw"
601
+ size="xs"
602
+ @click="refreshPlugins()"
603
+ />
604
+ </div>
605
+ <p class="text-sm text-muted mb-6">
606
+ Claude Code plugins installed via the marketplace system
607
+ (PR43 auto-install + PR55 ArkaOS marketplace). Sourced
608
+ from <code class="font-mono text-xs">{{ pluginsData?.plugins_path ?? '~/.claude/plugins/installed_plugins.json' }}</code>.
609
+ </p>
610
+ <div v-if="!plugins.length" class="rounded-lg border border-default p-6 text-center">
611
+ <UIcon name="i-lucide-puzzle" class="size-10 text-muted mx-auto mb-2" />
612
+ <p class="text-sm text-muted">No plugins installed.</p>
613
+ <p class="text-xs text-muted mt-2">
614
+ Try <code class="font-mono">/plugin marketplace add andreagroferreira/arka-os</code>
615
+ from Claude Code.
616
+ </p>
617
+ </div>
618
+ <div v-else class="space-y-2">
619
+ <div
620
+ v-for="p in plugins"
621
+ :key="`${p.marketplace}:${p.name}:${p.version}`"
622
+ class="flex items-center gap-3 rounded-lg border border-default p-3"
623
+ >
624
+ <UIcon name="i-lucide-puzzle" class="size-4 text-muted shrink-0" />
625
+ <div class="flex-1 min-w-0">
626
+ <div class="flex items-center gap-2 mb-0.5">
627
+ <span class="text-sm font-semibold">{{ p.name }}</span>
628
+ <UBadge :label="p.marketplace" variant="outline" size="xs" />
629
+ <UBadge v-if="p.scope" :label="p.scope" variant="soft" size="xs" />
630
+ <UBadge v-if="p.version" :label="`v${p.version}`" variant="subtle" size="xs" />
631
+ </div>
632
+ <p v-if="p.installed_at" class="text-xs text-muted">
633
+ Installed {{ formatInstalledAt(p.installed_at) }}
634
+ </p>
635
+ </div>
636
+ </div>
637
+ </div>
638
+ </section>
639
+
640
+ <!-- Theme -->
641
+ <section v-else-if="activeSection === 'theme'">
642
+ <h2 class="text-lg font-semibold mb-1">Theme</h2>
643
+ <p class="text-sm text-muted mb-6">
644
+ Light / dark / system (follows OS preference).
645
+ Stored locally by your browser.
646
+ </p>
647
+ <UCard>
648
+ <div class="space-y-4">
649
+ <UFormField label="Appearance">
650
+ <USelect
651
+ v-model="colorMode.preference"
652
+ :items="themeOptions"
653
+ class="w-full max-w-xs"
654
+ />
655
+ </UFormField>
656
+ <p class="text-xs text-muted">
657
+ Currently rendering as
658
+ <UBadge :label="colorMode.value" variant="subtle" size="xs" />
659
+ </p>
660
+ </div>
661
+ </UCard>
662
+ </section>
412
663
  </div>
413
664
  </div>
414
665
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.87.0",
3
+ "version": "2.89.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.87.0"
3
+ version = "2.89.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"}
@@ -612,6 +612,32 @@ def knowledge_search(q: str = Query(...), top_k: int = Query(5)):
612
612
  return {"results": results, "query": q, "total": len(results)}
613
613
 
614
614
 
615
+ @app.delete("/api/knowledge/sources")
616
+ def knowledge_delete_source(source: str = Query(...)):
617
+ """PR71 v2.88.0 — remove all chunks from a given source.
618
+
619
+ Operators sometimes ingest a noisy / wrong source and want to nuke
620
+ every chunk that came from it without rebuilding the whole vector
621
+ DB. The vector store already exposes `remove_file(source)` —
622
+ this endpoint just exposes it on the wire.
623
+
624
+ Returns ``{deleted: N, source: "..."}``. Refuses empty source
625
+ paths so a runaway client doesn't accidentally request "delete
626
+ everything that has no source".
627
+ """
628
+ clean = (source or "").strip()
629
+ if not clean:
630
+ return {"error": "source query param is required"}
631
+ store = _get_vector_store()
632
+ if not store:
633
+ return {"error": "vector store unavailable", "deleted": 0}
634
+ try:
635
+ deleted = store.remove_file(clean)
636
+ except Exception as exc: # noqa: BLE001 — surface as 200+error
637
+ return {"error": f"delete failed: {exc}", "deleted": 0}
638
+ return {"deleted": int(deleted), "source": clean}
639
+
640
+
615
641
  @app.get("/api/health")
616
642
  def health():
617
643
  """PR70 v2.87.0 — per-check severity + response timestamp.
@@ -1084,6 +1110,189 @@ def llm_costs_trend(days: int = 7):
1084
1110
  return {"days": out_days, "period_days": capped_days}
1085
1111
 
1086
1112
 
1113
+ # --- Settings sections (PR63b v2.89.0): MCPs / Hooks / Plugins ---
1114
+
1115
+
1116
+ @app.get("/api/settings/mcps")
1117
+ def settings_mcps():
1118
+ """List MCP servers across user-global config + ArkaOS registry.
1119
+
1120
+ Reads:
1121
+ - ``~/.claude.json::mcpServers`` (Claude Code user-global)
1122
+ - ``~/.claude/skills/arka/mcps/registry.json`` (ArkaOS registry)
1123
+
1124
+ Returns a deduplicated list with each entry's name + source +
1125
+ transport (stdio / http / sse) where the config exposes it.
1126
+ """
1127
+ out: list[dict] = []
1128
+ seen: set[str] = set()
1129
+
1130
+ user_global = Path.home() / ".claude.json"
1131
+ if user_global.exists():
1132
+ try:
1133
+ data = json.loads(user_global.read_text(encoding="utf-8"))
1134
+ except (json.JSONDecodeError, OSError):
1135
+ data = {}
1136
+ for name, cfg in (data.get("mcpServers") or {}).items():
1137
+ if not isinstance(name, str) or name in seen:
1138
+ continue
1139
+ seen.add(name)
1140
+ out.append({
1141
+ "name": name,
1142
+ "source": "user-global",
1143
+ "transport": _detect_mcp_transport(cfg),
1144
+ "command": (cfg or {}).get("command", "") if isinstance(cfg, dict) else "",
1145
+ })
1146
+
1147
+ registry = Path.home() / ".claude" / "skills" / "arka" / "mcps" / "registry.json"
1148
+ if registry.exists():
1149
+ try:
1150
+ data = json.loads(registry.read_text(encoding="utf-8"))
1151
+ except (json.JSONDecodeError, OSError):
1152
+ data = {}
1153
+ servers = data.get("servers") if isinstance(data, dict) else None
1154
+ if isinstance(servers, dict):
1155
+ for name, cfg in servers.items():
1156
+ if not isinstance(name, str) or name in seen:
1157
+ continue
1158
+ seen.add(name)
1159
+ out.append({
1160
+ "name": name,
1161
+ "source": "arkaos-registry",
1162
+ "transport": _detect_mcp_transport(cfg),
1163
+ "command": (cfg or {}).get("command", "") if isinstance(cfg, dict) else "",
1164
+ })
1165
+ elif isinstance(servers, list):
1166
+ for entry in servers:
1167
+ if not isinstance(entry, dict):
1168
+ continue
1169
+ name = str(entry.get("name") or "")
1170
+ if not name or name in seen:
1171
+ continue
1172
+ seen.add(name)
1173
+ out.append({
1174
+ "name": name,
1175
+ "source": "arkaos-registry",
1176
+ "transport": _detect_mcp_transport(entry),
1177
+ "command": entry.get("command", ""),
1178
+ })
1179
+
1180
+ out.sort(key=lambda r: r["name"])
1181
+ return {"mcps": out, "total": len(out)}
1182
+
1183
+
1184
+ def _detect_mcp_transport(cfg: object) -> str:
1185
+ """Best-effort transport sniff from an MCP server config dict."""
1186
+ if not isinstance(cfg, dict):
1187
+ return "unknown"
1188
+ if cfg.get("url"):
1189
+ return "http"
1190
+ if cfg.get("transport"):
1191
+ return str(cfg["transport"])
1192
+ if cfg.get("command"):
1193
+ return "stdio"
1194
+ return "unknown"
1195
+
1196
+
1197
+ @app.get("/api/settings/hooks")
1198
+ def settings_hooks():
1199
+ """Inspect the hooks block of ~/.claude/settings.json.
1200
+
1201
+ Returns one row per hook type with command paths + timeouts so the
1202
+ operator can see at a glance which hooks are wired and which are
1203
+ missing. We never edit the file from here (Hooks ship from the
1204
+ ArkaOS installer); this is purely read-only diagnostics.
1205
+ """
1206
+ settings_file = Path.home() / ".claude" / "settings.json"
1207
+ if not settings_file.exists():
1208
+ return {"hooks": [], "settings_path": str(settings_file)}
1209
+ try:
1210
+ data = json.loads(settings_file.read_text(encoding="utf-8"))
1211
+ except (json.JSONDecodeError, OSError):
1212
+ return {"hooks": [], "settings_path": str(settings_file)}
1213
+
1214
+ hooks_block = data.get("hooks") if isinstance(data, dict) else None
1215
+ if not isinstance(hooks_block, dict):
1216
+ return {"hooks": [], "settings_path": str(settings_file)}
1217
+
1218
+ rows: list[dict] = []
1219
+ for hook_type, entries in hooks_block.items():
1220
+ if not isinstance(entries, list):
1221
+ continue
1222
+ commands: list[dict] = []
1223
+ for entry in entries:
1224
+ if not isinstance(entry, dict):
1225
+ continue
1226
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
1227
+ if not isinstance(inner, list):
1228
+ continue
1229
+ for h in inner:
1230
+ if not isinstance(h, dict):
1231
+ continue
1232
+ commands.append({
1233
+ "command": str(h.get("command", ""))[:200],
1234
+ "type": str(h.get("type", "command")),
1235
+ "timeout": h.get("timeout"),
1236
+ })
1237
+ rows.append({
1238
+ "hook": hook_type,
1239
+ "count": len(commands),
1240
+ "commands": commands,
1241
+ })
1242
+ rows.sort(key=lambda r: r["hook"])
1243
+ hard_enforcement = bool(
1244
+ isinstance(data.get("hooks"), dict)
1245
+ and data["hooks"].get("hardEnforcement")
1246
+ )
1247
+ return {
1248
+ "hooks": rows,
1249
+ "settings_path": str(settings_file),
1250
+ "hard_enforcement": hard_enforcement,
1251
+ }
1252
+
1253
+
1254
+ @app.get("/api/settings/plugins")
1255
+ def settings_plugins():
1256
+ """List Claude Code plugins installed via ~/.claude/plugins/installed_plugins.json.
1257
+
1258
+ The PR43 auto-installer + PR55 marketplace flow both touch this
1259
+ file. Format is ``{"plugins": {"<name>@<marketplace>": [entry,...]}}``.
1260
+ We flatten to one row per (name, marketplace, version).
1261
+ """
1262
+ plugins_file = Path.home() / ".claude" / "plugins" / "installed_plugins.json"
1263
+ if not plugins_file.exists():
1264
+ return {"plugins": [], "total": 0, "plugins_path": str(plugins_file)}
1265
+ try:
1266
+ data = json.loads(plugins_file.read_text(encoding="utf-8"))
1267
+ except (json.JSONDecodeError, OSError):
1268
+ return {"plugins": [], "total": 0, "plugins_path": str(plugins_file)}
1269
+
1270
+ rows: list[dict] = []
1271
+ plugins_map = data.get("plugins") if isinstance(data, dict) else None
1272
+ if isinstance(plugins_map, dict):
1273
+ for key, entries in plugins_map.items():
1274
+ if not isinstance(entries, list):
1275
+ continue
1276
+ name, _, marketplace = str(key).partition("@")
1277
+ for entry in entries:
1278
+ if not isinstance(entry, dict):
1279
+ continue
1280
+ rows.append({
1281
+ "name": name,
1282
+ "marketplace": marketplace,
1283
+ "version": entry.get("version", ""),
1284
+ "scope": entry.get("scope", ""),
1285
+ "installed_at": entry.get("installedAt", ""),
1286
+ "last_updated": entry.get("lastUpdated", ""),
1287
+ })
1288
+ rows.sort(key=lambda r: (r["marketplace"], r["name"]))
1289
+ return {
1290
+ "plugins": rows,
1291
+ "total": len(rows),
1292
+ "plugins_path": str(plugins_file),
1293
+ }
1294
+
1295
+
1087
1296
  # --- Profile (PR63 v2.81.0) ---
1088
1297
 
1089
1298
  @app.get("/api/profile")