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.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- 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
|
+
}
|