loopat 0.1.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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Tier metadata + settings read/write for the five-tier composition model.
3
+ * Team / profile / personal tiers are loopat-managed; project / local are
4
+ * SDK-managed (read-only from Settings page perspective).
5
+ */
6
+ import { existsSync } from "node:fs"
7
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
8
+ import { join } from "node:path"
9
+ import { homedir } from "node:os"
10
+ import {
11
+ personalClaudeDir,
12
+ personalClaudeMdPath,
13
+ personalSettingsPath,
14
+ personalSkillsDir,
15
+ personalAgentsDir,
16
+ workspaceProfilesDir,
17
+ workspaceProfileClaudeDir,
18
+ workspaceProfileDir,
19
+ workspaceProfileSettingsPath,
20
+ workspaceProfileClaudeMdPath,
21
+ workspaceProfileSkillsDir,
22
+ workspaceProfileAgentsDir,
23
+ workspaceTeamClaudeDir,
24
+ workspaceTeamSettingsPath,
25
+ workspaceTeamClaudeMdPath,
26
+ workspaceTeamSkillsDir,
27
+ workspaceTeamAgentsDir,
28
+ } from "./paths"
29
+ import { countToolchainTools } from "./loop-stats"
30
+
31
+ // ── types ──
32
+
33
+ export type TierId = "team" | `profile:${string}` | "personal" | "project" | "local"
34
+
35
+ export type TierInfo = {
36
+ id: TierId
37
+ label: string
38
+ path: string
39
+ exists: boolean
40
+ editable: boolean
41
+ managedBy: "admin" | "user" | "sdk"
42
+ /** Parsed settings.json — null if tier doesn't exist or has no settings.json. */
43
+ settings: Record<string, any> | null
44
+ claudeMd: string | null
45
+ pluginCount: number
46
+ mcpServerCount: number
47
+ marketplaceCount: number
48
+ hookCount: number
49
+ skillCount: number
50
+ agentCount: number
51
+ /** Toolchain tools declared in this tier's mise.toml. */
52
+ toolchainCount: number
53
+ /** Keys in this tier that shadow same-name keys from a lower tier. */
54
+ overrides: Record<string, { overrides: string; value: any }>
55
+ }
56
+
57
+ export type TiersResponse = {
58
+ tiers: TierInfo[]
59
+ /** Merged settings (team + profiles + personal), for preview. */
60
+ mergedSettings: Record<string, any>
61
+ /** User role for permission gating. */
62
+ isAdmin: boolean
63
+ }
64
+
65
+ export type PluginEntry = {
66
+ name: string
67
+ marketplace: string
68
+ displayName: string
69
+ description?: string
70
+ }
71
+
72
+ // ── helpers ──
73
+
74
+ async function readJsonOrNull(path: string): Promise<Record<string, any> | null> {
75
+ if (!existsSync(path)) return null
76
+ try {
77
+ return JSON.parse(await readFile(path, "utf8"))
78
+ } catch { return null }
79
+ }
80
+
81
+ async function countDir(path: string): Promise<number> {
82
+ if (!existsSync(path)) return 0
83
+ try {
84
+ const entries = await readdir(path)
85
+ return entries.filter((e) => !e.startsWith(".")).length
86
+ } catch { return 0 }
87
+ }
88
+
89
+ /**
90
+ * Pull a one-line description from a profile's CLAUDE.md. Priority:
91
+ * 1. YAML frontmatter `description:` field (mirrors SKILL.md / agent.md
92
+ * idiom — CC SDK already parses this for tool routing)
93
+ * 2. First non-empty heading (`# ...` line), with `#` stripped — legacy
94
+ * convention, kept as fallback so older profiles "just work"
95
+ *
96
+ * Returns null when neither is present. Pure text op; no I/O.
97
+ */
98
+ export function extractProfileDescription(md: string | null): string | null {
99
+ if (!md) return null
100
+ // 1. Frontmatter (YAML) — between leading `---\n` and `---\n`
101
+ const fm = md.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/)
102
+ if (fm) {
103
+ const desc = fm[1].match(/^description:\s*(.+?)\s*$/m)
104
+ if (desc) {
105
+ // Strip optional surrounding quotes (YAML allows "..." / '...')
106
+ const raw = desc[1].trim()
107
+ const stripped = raw.replace(/^["'](.*)["']$/, "$1").trim()
108
+ if (stripped) return stripped
109
+ }
110
+ }
111
+ // 2. First heading — legacy fallback
112
+ const body = fm ? md.slice(fm[0].length) : md
113
+ for (const line of body.split("\n")) {
114
+ const t = line.trim()
115
+ if (!t) continue
116
+ if (t.startsWith("#")) return t.replace(/^#+\s*/, "").trim() || null
117
+ // First non-empty non-heading line ends the search — description is "missing"
118
+ return null
119
+ }
120
+ return null
121
+ }
122
+
123
+ function computeOverrides(
124
+ settings: Record<string, any> | null,
125
+ lowerSettings: Record<string, any>,
126
+ ): Record<string, { overrides: string; value: any }> {
127
+ if (!settings) return {}
128
+ const out: Record<string, { overrides: string; value: any }> = {}
129
+ for (const [k, v] of Object.entries(settings)) {
130
+ if (k === "_comment") continue
131
+ if (k === "enabledPlugins" && v && typeof v === "object") {
132
+ for (const [pn, pv] of Object.entries(v as Record<string, any>)) {
133
+ const lv = (lowerSettings?.enabledPlugins as Record<string, any>)?.[pn]
134
+ if (lv !== undefined) out[`enabledPlugins.${pn}`] = { overrides: "team", value: pv }
135
+ }
136
+ } else if (k === "mcpServers" && v && typeof v === "object") {
137
+ for (const [sn] of Object.entries(v as Record<string, any>)) {
138
+ if ((lowerSettings?.mcpServers as Record<string, any>)?.[sn] !== undefined) {
139
+ out[`mcpServers.${sn}`] = { overrides: "team", value: true }
140
+ }
141
+ }
142
+ } else if (k === "extraKnownMarketplaces" && v && typeof v === "object") {
143
+ for (const [mn] of Object.entries(v as Record<string, any>)) {
144
+ if ((lowerSettings?.extraKnownMarketplaces as Record<string, any>)?.[mn] !== undefined) {
145
+ out[`extraKnownMarketplaces.${mn}`] = { overrides: "team", value: true }
146
+ }
147
+ }
148
+ } else if (k === "hooks" && v && typeof v === "object") {
149
+ for (const [hn] of Object.entries(v as Record<string, any>)) {
150
+ if ((lowerSettings?.hooks as Record<string, any>)?.[hn] !== undefined) {
151
+ out[`hooks.${hn}`] = { overrides: "team", value: true }
152
+ }
153
+ }
154
+ } else {
155
+ if (lowerSettings?.[k] !== undefined) {
156
+ out[k] = { overrides: "team", value: v }
157
+ }
158
+ }
159
+ }
160
+ return out
161
+ }
162
+
163
+ /** Shallow union merge (later wins) — simpler than compose.ts deep merge
164
+ * but sufficient for override detection in the settings UI. */
165
+ function shallowUnion(a: Record<string, any>, b: Record<string, any>): Record<string, any> {
166
+ const out = { ...a }
167
+ for (const [k, v] of Object.entries(b)) {
168
+ if (k === "_comment") continue
169
+ if (
170
+ typeof v === "object" && v !== null && !Array.isArray(v) &&
171
+ typeof out[k] === "object" && out[k] !== null && !Array.isArray(out[k])
172
+ ) {
173
+ out[k] = { ...out[k], ...v }
174
+ } else {
175
+ out[k] = v
176
+ }
177
+ }
178
+ return out
179
+ }
180
+
181
+ function settingsSummary(s: Record<string, any> | null) {
182
+ return {
183
+ pluginCount: s?.enabledPlugins ? Object.keys(s.enabledPlugins).filter((k: string) => s.enabledPlugins[k]).length : 0,
184
+ mcpServerCount: s?.mcpServers ? Object.keys(s.mcpServers).length : 0,
185
+ marketplaceCount: s?.extraKnownMarketplaces ? Object.keys(s.extraKnownMarketplaces).length : 0,
186
+ hookCount: s?.hooks ? Object.keys(s.hooks).length : 0,
187
+ }
188
+ }
189
+
190
+ // ── main tier listing ──
191
+
192
+ export async function getTiers(user: string, isAdmin: boolean): Promise<TiersResponse> {
193
+ const tiers: TierInfo[] = []
194
+ let merged: Record<string, any> = {}
195
+
196
+ // 1. Team tier
197
+ const teamDir = workspaceTeamClaudeDir()
198
+ const teamSettings = await readJsonOrNull(workspaceTeamSettingsPath())
199
+ tiers.push({
200
+ id: "team",
201
+ label: "Team",
202
+ path: teamDir,
203
+ exists: existsSync(teamDir),
204
+ editable: isAdmin,
205
+ managedBy: "admin",
206
+ settings: teamSettings,
207
+ claudeMd: await readMdOrNull(workspaceTeamClaudeMdPath()),
208
+ ...settingsSummary(teamSettings),
209
+ skillCount: await countDir(workspaceTeamSkillsDir()),
210
+ agentCount: await countDir(workspaceTeamAgentsDir()),
211
+ toolchainCount: countToolchainTools(teamDir).length,
212
+ overrides: {},
213
+ })
214
+ if (teamSettings) merged = shallowUnion(merged, teamSettings)
215
+
216
+ // 2. Profile tiers (all existing profiles)
217
+ const profilesDir = workspaceProfilesDir()
218
+ if (existsSync(profilesDir)) {
219
+ const entries = await readdir(profilesDir, { withFileTypes: true })
220
+ for (const e of entries) {
221
+ if (!e.isDirectory() || e.name.startsWith(".")) continue
222
+ const claudeDir = workspaceProfileClaudeDir(e.name)
223
+ if (!existsSync(claudeDir)) continue
224
+ const ps = await readJsonOrNull(workspaceProfileSettingsPath(e.name))
225
+ const overrides = computeOverrides(ps, merged)
226
+ tiers.push({
227
+ id: `profile:${e.name}`,
228
+ label: `Profile: ${e.name}`,
229
+ path: claudeDir,
230
+ exists: true,
231
+ editable: isAdmin,
232
+ managedBy: "admin",
233
+ settings: ps,
234
+ claudeMd: await readMdOrNull(workspaceProfileClaudeMdPath(e.name)),
235
+ ...settingsSummary(ps),
236
+ skillCount: await countDir(workspaceProfileSkillsDir(e.name)),
237
+ agentCount: await countDir(workspaceProfileAgentsDir(e.name)),
238
+ toolchainCount: countToolchainTools(claudeDir).length,
239
+ overrides,
240
+ })
241
+ if (ps) merged = shallowUnion(merged, ps)
242
+ }
243
+ }
244
+
245
+ // 3. Personal tier
246
+ const personalCdir = personalClaudeDir(user)
247
+ const personalSettings = await readJsonOrNull(personalSettingsPath(user))
248
+ const personalOverrides = computeOverrides(personalSettings, merged)
249
+ tiers.push({
250
+ id: "personal",
251
+ label: "Personal",
252
+ path: personalCdir,
253
+ exists: existsSync(personalCdir),
254
+ editable: true,
255
+ managedBy: "user",
256
+ settings: personalSettings,
257
+ claudeMd: await readMdOrNull(personalClaudeMdPath(user)),
258
+ ...settingsSummary(personalSettings),
259
+ skillCount: await countDir(personalSkillsDir(user)),
260
+ agentCount: await countDir(personalAgentsDir(user)),
261
+ toolchainCount: countToolchainTools(personalCdir).length,
262
+ overrides: personalOverrides,
263
+ })
264
+ const finalMerged = personalSettings ? shallowUnion(merged, personalSettings) : merged
265
+
266
+ // 4. Project tier (SDK-managed, informational)
267
+ tiers.push({
268
+ id: "project",
269
+ label: "Project",
270
+ path: "<workdir>/.claude/",
271
+ exists: false,
272
+ editable: false,
273
+ managedBy: "sdk",
274
+ settings: null,
275
+ claudeMd: null,
276
+ pluginCount: 0,
277
+ mcpServerCount: 0,
278
+ marketplaceCount: 0,
279
+ hookCount: 0,
280
+ skillCount: 0,
281
+ agentCount: 0,
282
+ toolchainCount: 0,
283
+ overrides: {},
284
+ })
285
+
286
+ // 5. Local tier (SDK-managed, informational)
287
+ tiers.push({
288
+ id: "local",
289
+ label: "Local",
290
+ path: "<workdir>/.claude/*.local.*",
291
+ exists: false,
292
+ editable: false,
293
+ managedBy: "sdk",
294
+ settings: null,
295
+ claudeMd: null,
296
+ pluginCount: 0,
297
+ mcpServerCount: 0,
298
+ marketplaceCount: 0,
299
+ hookCount: 0,
300
+ skillCount: 0,
301
+ agentCount: 0,
302
+ toolchainCount: 0,
303
+ overrides: {},
304
+ })
305
+
306
+ return { tiers, mergedSettings: finalMerged, isAdmin }
307
+ }
308
+
309
+ async function readMdOrNull(path: string): Promise<string | null> {
310
+ if (!existsSync(path)) return null
311
+ try { return await readFile(path, "utf8") } catch { return null }
312
+ }
313
+
314
+ // ── per-tier settings read/write ──
315
+
316
+ function resolveTierClaudeDir(tierId: string, user: string): string | null {
317
+ if (tierId === "team") return workspaceTeamClaudeDir()
318
+ if (tierId === "personal") return personalClaudeDir(user)
319
+ if (tierId.startsWith("profile:")) {
320
+ const name = tierId.slice("profile:".length)
321
+ return workspaceProfileClaudeDir(name)
322
+ }
323
+ return null
324
+ }
325
+
326
+ function resolveTierPath(tierId: string, user: string): { settingsPath: string; exists: boolean } | null {
327
+ if (tierId === "team") {
328
+ const p = workspaceTeamSettingsPath()
329
+ return { settingsPath: p, exists: existsSync(p) }
330
+ }
331
+ if (tierId === "personal") {
332
+ const p = personalSettingsPath(user)
333
+ return { settingsPath: p, exists: existsSync(p) }
334
+ }
335
+ if (tierId.startsWith("profile:")) {
336
+ const name = tierId.slice("profile:".length)
337
+ const p = workspaceProfileSettingsPath(name)
338
+ return { settingsPath: p, exists: existsSync(p) }
339
+ }
340
+ return null
341
+ }
342
+
343
+ export async function getTierSettings(
344
+ tierId: string,
345
+ user: string,
346
+ ): Promise<Record<string, any>> {
347
+ const res = resolveTierPath(tierId, user)
348
+ if (!res) return {}
349
+ if (!res.exists) return {}
350
+ return (await readJsonOrNull(res.settingsPath)) ?? {}
351
+ }
352
+
353
+ export async function saveTierSettings(
354
+ tierId: string,
355
+ settings: Record<string, any>,
356
+ user: string,
357
+ ): Promise<{ ok: boolean; error?: string }> {
358
+ const res = resolveTierPath(tierId, user)
359
+ if (!res) return { ok: false, error: `unknown tier: ${tierId}` }
360
+ try {
361
+ await mkdir(join(res.settingsPath, ".."), { recursive: true })
362
+ await writeFile(res.settingsPath, JSON.stringify(settings, null, 2) + "\n")
363
+ return { ok: true }
364
+ } catch (e: any) {
365
+ return { ok: false, error: e?.message ?? "write failed" }
366
+ }
367
+ }
368
+
369
+ // ── mise.toml config per tier ──
370
+
371
+ export async function getTierMiseConfig(
372
+ tierId: string,
373
+ user: string,
374
+ ): Promise<{ content: string; exists: boolean; error?: string }> {
375
+ const claudeDir = resolveTierClaudeDir(tierId, user)
376
+ if (!claudeDir) return { content: "", exists: false, error: `unknown tier: ${tierId}` }
377
+ const misePath = join(claudeDir, "mise.toml")
378
+ if (!existsSync(misePath)) return { content: "", exists: false }
379
+ try {
380
+ const content = await readFile(misePath, "utf8")
381
+ return { content, exists: true }
382
+ } catch (e: any) {
383
+ return { content: "", exists: false, error: e?.message ?? "read failed" }
384
+ }
385
+ }
386
+
387
+ export async function saveTierMiseConfig(
388
+ tierId: string,
389
+ content: string,
390
+ user: string,
391
+ ): Promise<{ ok: boolean; error?: string }> {
392
+ const claudeDir = resolveTierClaudeDir(tierId, user)
393
+ if (!claudeDir) return { ok: false, error: `unknown tier: ${tierId}` }
394
+ try {
395
+ await mkdir(claudeDir, { recursive: true })
396
+ const misePath = join(claudeDir, "mise.toml")
397
+ await writeFile(misePath, content)
398
+ return { ok: true }
399
+ } catch (e: any) {
400
+ return { ok: false, error: e?.message ?? "write failed" }
401
+ }
402
+ }
403
+
404
+ /** Read a single plugin.json from an installPath to get displayName + description. */
405
+ async function readPluginMeta(installPath: string): Promise<{ displayName?: string; description?: string }> {
406
+ const pj = join(installPath, ".claude-plugin", "plugin.json")
407
+ if (!existsSync(pj)) return {}
408
+ try {
409
+ const j = JSON.parse(await readFile(pj, "utf8"))
410
+ return {
411
+ displayName: typeof j.displayName === "string" ? j.displayName : undefined,
412
+ description: typeof j.description === "string" ? j.description : undefined,
413
+ }
414
+ } catch { return {} }
415
+ }
416
+
417
+ // ── plugin inventory ──
418
+
419
+ export type MarketplaceSource = {
420
+ name: string
421
+ source: any
422
+ installLocation?: string
423
+ }
424
+
425
+ export type PluginWithStatus = PluginEntry & {
426
+ installed: boolean
427
+ marketplaceName: string
428
+ }
429
+
430
+ export async function listAvailablePlugins(): Promise<PluginEntry[]> {
431
+ // Read installed plugins from host CC cache.
432
+ const cacheDir = join(homedir(), ".claude", "plugins")
433
+ const installedPath = join(cacheDir, "installed_plugins.json")
434
+
435
+ const out: PluginEntry[] = []
436
+ if (existsSync(installedPath)) {
437
+ try {
438
+ const installed = JSON.parse(await readFile(installedPath, "utf8"))
439
+ const plugins = installed?.plugins ?? installed
440
+ for (const [key, entries] of Object.entries(plugins as Record<string, any>)) {
441
+ const atIdx = key.lastIndexOf("@")
442
+ const name = atIdx >= 0 ? key.slice(0, atIdx) : key
443
+ const marketplace = atIdx >= 0 ? key.slice(atIdx + 1) : ""
444
+ // Get installPath from first entry, read plugin.json for metadata
445
+ const installPath = Array.isArray(entries) && entries.length > 0
446
+ ? (entries[0] as any)?.installPath
447
+ : undefined
448
+ const meta = installPath ? await readPluginMeta(installPath) : {}
449
+ out.push({
450
+ name,
451
+ marketplace,
452
+ displayName: meta.displayName ?? name,
453
+ description: meta.description ?? undefined,
454
+ })
455
+ }
456
+ } catch {}
457
+ }
458
+ return out
459
+ }
460
+
461
+ /** List known marketplaces from CC's cache. */
462
+ export async function listMarketplaces(): Promise<MarketplaceSource[]> {
463
+ const cacheDir = join(homedir(), ".claude", "plugins")
464
+ const knownMpPath = join(cacheDir, "known_marketplaces.json")
465
+ if (!existsSync(knownMpPath)) return []
466
+ try {
467
+ const kf = JSON.parse(await readFile(knownMpPath, "utf8")) as Record<string, any>
468
+ return Object.entries(kf).map(([name, info]) => ({
469
+ name,
470
+ source: info?.source ?? null,
471
+ installLocation: info?.installLocation ?? undefined,
472
+ }))
473
+ } catch {
474
+ return []
475
+ }
476
+ }
477
+
478
+ /** Browse plugins from marketplace catalogs (not just installed ones).
479
+ * Scans known_marketplaces.json for each marketplace's install location,
480
+ * then reads .claude-plugin/marketplace.json for the plugin catalog. */
481
+ export async function browseMarketplacePlugins(): Promise<PluginWithStatus[]> {
482
+ const cacheDir = join(homedir(), ".claude", "plugins")
483
+ const knownMpPath = join(cacheDir, "known_marketplaces.json")
484
+ const installedPath = join(cacheDir, "installed_plugins.json")
485
+
486
+ // Build set of installed plugin keys
487
+ const installedSet = new Set<string>()
488
+ if (existsSync(installedPath)) {
489
+ try {
490
+ const installed = JSON.parse(await readFile(installedPath, "utf8")) as Record<string, any>
491
+ for (const key of Object.keys(installed)) installedSet.add(key)
492
+ } catch {}
493
+ }
494
+
495
+ const out: PluginWithStatus[] = []
496
+
497
+ if (!existsSync(knownMpPath)) return out
498
+
499
+ try {
500
+ const kf = JSON.parse(await readFile(knownMpPath, "utf8")) as Record<string, any>
501
+ for (const [mpName, mpInfo] of Object.entries(kf)) {
502
+ const loc = mpInfo?.installLocation
503
+ if (!loc || typeof loc !== "string") continue
504
+ const catalogPath = join(loc, ".claude-plugin", "marketplace.json")
505
+ if (!existsSync(catalogPath)) continue
506
+ try {
507
+ const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as { plugins?: Array<{ name: string; source: any }> }
508
+ for (const p of catalog.plugins ?? []) {
509
+ const key = `${p.name}@${mpName}`
510
+ // For local marketplaces, try reading plugin.json from the plugin subdir
511
+ let desc: string | undefined
512
+ let dname: string | undefined
513
+ const pluginDir = join(loc, p.name)
514
+ if (existsSync(pluginDir)) {
515
+ const meta = await readPluginMeta(pluginDir)
516
+ dname = meta.displayName
517
+ desc = meta.description
518
+ }
519
+ out.push({
520
+ name: p.name,
521
+ marketplace: mpName,
522
+ marketplaceName: mpName,
523
+ displayName: dname ?? p.name,
524
+ description: desc ?? undefined,
525
+ installed: installedSet.has(key),
526
+ })
527
+ }
528
+ } catch {}
529
+ }
530
+ } catch {}
531
+
532
+ return out
533
+ }
534
+
535
+ /** Refresh marketplace registrations: scan all tiers' extraKnownMarketplaces,
536
+ * register any new ones with the host CC, and update existing ones with
537
+ * source/branch drift. This ensures browseMarketplacePlugins can see them. */
538
+ export async function refreshMarketplaces(user: string): Promise<{ ok: boolean; added: string[]; error?: string }> {
539
+ try {
540
+ const { execFile } = await import("node:child_process")
541
+ const { promisify } = await import("node:util")
542
+ const execFileP = promisify(execFile)
543
+ const runClaude = async (args: string[]) => {
544
+ try {
545
+ await execFileP("claude", args)
546
+ return { ok: true }
547
+ } catch (e: any) {
548
+ return { ok: false, err: e?.stderr?.toString?.() ?? e?.message ?? String(e) }
549
+ }
550
+ }
551
+
552
+ // 1. Read existing known_marketplaces.json
553
+ const kmPath = join(homedir(), ".claude", "plugins", "known_marketplaces.json")
554
+ let knownMarketplaces: Record<string, any> = {}
555
+ if (existsSync(kmPath)) {
556
+ try { knownMarketplaces = JSON.parse(await readFile(kmPath, "utf8")) } catch {}
557
+ }
558
+
559
+ // 2. Collect all extraKnownMarketplaces from all tiers
560
+ const extras: Record<string, any> = {}
561
+
562
+ // Team tier
563
+ const teamSettings = await readJsonOrNull(workspaceTeamSettingsPath())
564
+ if (teamSettings?.extraKnownMarketplaces) {
565
+ Object.assign(extras, teamSettings.extraKnownMarketplaces)
566
+ }
567
+
568
+ // All profiles
569
+ const profilesDir = workspaceProfilesDir()
570
+ if (existsSync(profilesDir)) {
571
+ const entries = await readdir(profilesDir, { withFileTypes: true })
572
+ for (const e of entries) {
573
+ if (!e.isDirectory() || e.name.startsWith(".")) continue
574
+ const ps = await readJsonOrNull(workspaceProfileSettingsPath(e.name))
575
+ if (ps?.extraKnownMarketplaces) {
576
+ Object.assign(extras, ps.extraKnownMarketplaces)
577
+ }
578
+ }
579
+ }
580
+
581
+ // Personal tier
582
+ const personalSettings = await readJsonOrNull(personalSettingsPath(user))
583
+ if (personalSettings?.extraKnownMarketplaces) {
584
+ Object.assign(extras, personalSettings.extraKnownMarketplaces)
585
+ }
586
+
587
+ // 3. For each marketplace, register it with CC if missing or drifted
588
+ const added: string[] = []
589
+ for (const [name, entry] of Object.entries(extras)) {
590
+ const src = (entry as any)?.source
591
+ if (!src) continue
592
+
593
+ // Determine the add path. CC auto-detects source type from the path
594
+ // (URL → git, owner/repo → github, absolute path → directory).
595
+ let addPath: string | undefined
596
+ if (src.source === "directory" && typeof src.path === "string") {
597
+ addPath = src.path
598
+ } else if (src.source === "github" && typeof src.repo === "string") {
599
+ addPath = src.repo
600
+ } else if ((src.source === "git" || src.source === "url") && typeof src.url === "string") {
601
+ addPath = src.url
602
+ }
603
+
604
+ if (!addPath) continue
605
+
606
+ // Check if marketplace already registered — search by source match
607
+ const existing = knownMarketplaces[name]
608
+ ?? Object.entries(knownMarketplaces).find(([, v]: [string, any]) => {
609
+ const es = v?.source
610
+ if (!es) return false
611
+ if (es.source === "directory" && es.path === src.path) return true
612
+ if (es.source === "github" && es.repo === src.repo) return true
613
+ if ((es.source === "git" || es.source === "url") && es.url === src.url) return true
614
+ return false
615
+ })?.[0]
616
+
617
+ if (existing) {
618
+ const existEntry = knownMarketplaces[existing]
619
+ const existSrc = existEntry?.source
620
+ const needUpdate = !existSrc ||
621
+ existSrc.source !== src.source ||
622
+ (src.source === "git" && existSrc.url !== src.url) ||
623
+ (src.source === "github" && existSrc.repo !== src.repo) ||
624
+ (src.source === "directory" && existSrc.path !== src.path) ||
625
+ (typeof src.branch === "string" && existSrc.branch !== src.branch)
626
+
627
+ if (!needUpdate) continue
628
+
629
+ // Source or branch changed — remove old and re-add
630
+ console.warn(`[tiers] marketplace "${existing}" source/branch drift, re-registering`)
631
+ await runClaude(["plugin", "marketplace", "remove", existing])
632
+ }
633
+
634
+ // Build add command: claude plugin marketplace add <path> [--branch <b>]
635
+ // CC auto-detects source type (and derives name) from the path.
636
+ const args = ["plugin", "marketplace", "add", addPath]
637
+ if (typeof src.branch === "string" && src.branch) {
638
+ args.push("--branch", src.branch)
639
+ }
640
+
641
+ const r = await runClaude(args)
642
+ if (r.ok) {
643
+ added.push(name)
644
+ } else {
645
+ console.warn(`[tiers] failed to register marketplace "${name}": ${r.err}`)
646
+ }
647
+ }
648
+
649
+ return { ok: true, added }
650
+ } catch (e: any) {
651
+ return { ok: false, added: [], error: e?.message ?? "refresh failed" }
652
+ }
653
+ }
654
+
655
+ // ── profile CRUD (admin) ──
656
+
657
+ export type ProfileDetail = {
658
+ name: string
659
+ path: string
660
+ description: string | null
661
+ settings: Record<string, any> | null
662
+ claudeMd: string | null
663
+ pluginCount: number
664
+ mcpServerCount: number
665
+ marketplaceCount: number
666
+ hookCount: number
667
+ skillCount: number
668
+ agentCount: number
669
+ /** Toolchain tools declared in this profile's mise.toml. */
670
+ toolchainCount: number
671
+ }
672
+
673
+ export async function listProfilesRich(): Promise<ProfileDetail[]> {
674
+ const root = workspaceProfilesDir()
675
+ if (!existsSync(root)) return []
676
+ const entries = await readdir(root, { withFileTypes: true })
677
+ const out: ProfileDetail[] = []
678
+ for (const e of entries) {
679
+ if (!e.isDirectory() || e.name.startsWith(".")) continue
680
+ const cd = workspaceProfileClaudeDir(e.name)
681
+ if (!existsSync(cd)) continue
682
+ const settings = await readJsonOrNull(workspaceProfileSettingsPath(e.name))
683
+ const md = await readMdOrNull(workspaceProfileClaudeMdPath(e.name))
684
+ const desc = extractProfileDescription(md)
685
+ out.push({
686
+ name: e.name,
687
+ path: cd,
688
+ description: desc || null,
689
+ settings,
690
+ claudeMd: md,
691
+ ...settingsSummary(settings),
692
+ skillCount: await countDir(workspaceProfileSkillsDir(e.name)),
693
+ agentCount: await countDir(workspaceProfileAgentsDir(e.name)),
694
+ toolchainCount: countToolchainTools(cd).length,
695
+ })
696
+ }
697
+ return out.sort((a, b) => a.name.localeCompare(b.name))
698
+ }
699
+
700
+ export async function createProfile(name: string): Promise<{ ok: boolean; error?: string }> {
701
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return { ok: false, error: "name must be alphanumeric, dash, or underscore" }
702
+ const dir = workspaceProfileDir(name)
703
+ if (existsSync(dir)) return { ok: false, error: `profile "${name}" already exists` }
704
+ try {
705
+ await mkdir(workspaceProfileClaudeDir(name), { recursive: true })
706
+ // Seed with empty settings.json and stub CLAUDE.md
707
+ await writeFile(workspaceProfileSettingsPath(name), "{}\n")
708
+ await writeFile(workspaceProfileClaudeMdPath(name), `# ${name} profile\n\nAdd instructions for this profile here.\n`)
709
+ return { ok: true }
710
+ } catch (e: any) {
711
+ return { ok: false, error: e?.message ?? "create failed" }
712
+ }
713
+ }
714
+
715
+ export async function getProfile(name: string): Promise<ProfileDetail | null> {
716
+ const cd = workspaceProfileClaudeDir(name)
717
+ if (!existsSync(cd)) return null
718
+ const settings = await readJsonOrNull(workspaceProfileSettingsPath(name))
719
+ const md = await readMdOrNull(workspaceProfileClaudeMdPath(name))
720
+ const desc = extractProfileDescription(md)
721
+ return {
722
+ name,
723
+ path: cd,
724
+ description: desc || null,
725
+ settings,
726
+ claudeMd: md,
727
+ ...settingsSummary(settings),
728
+ skillCount: await countDir(workspaceProfileSkillsDir(name)),
729
+ agentCount: await countDir(workspaceProfileAgentsDir(name)),
730
+ toolchainCount: countToolchainTools(cd).length,
731
+ }
732
+ }
733
+
734
+ export async function updateProfile(
735
+ name: string,
736
+ data: { settings?: Record<string, any>; claudeMd?: string },
737
+ ): Promise<{ ok: boolean; error?: string }> {
738
+ const cd = workspaceProfileClaudeDir(name)
739
+ if (!existsSync(cd)) return { ok: false, error: `profile "${name}" not found` }
740
+ try {
741
+ if (data.settings !== undefined) {
742
+ await writeFile(workspaceProfileSettingsPath(name), JSON.stringify(data.settings, null, 2) + "\n")
743
+ }
744
+ if (data.claudeMd !== undefined) {
745
+ await writeFile(workspaceProfileClaudeMdPath(name), data.claudeMd)
746
+ }
747
+ return { ok: true }
748
+ } catch (e: any) {
749
+ return { ok: false, error: e?.message ?? "update failed" }
750
+ }
751
+ }
752
+
753
+ export async function deleteProfile(name: string): Promise<{ ok: boolean; error?: string }> {
754
+ const dir = workspaceProfileDir(name)
755
+ if (!existsSync(dir)) return { ok: false, error: `profile "${name}" not found` }
756
+ try {
757
+ await rm(dir, { recursive: true, force: true })
758
+ return { ok: true }
759
+ } catch (e: any) {
760
+ return { ok: false, error: e?.message ?? "delete failed" }
761
+ }
762
+ }