-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathprofiles.ts
More file actions
178 lines (164 loc) · 6.26 KB
/
Copy pathprofiles.ts
File metadata and controls
178 lines (164 loc) · 6.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
/**
* Profile resolver — CC-native model (post-2026-05 refactor).
*
* A "profile" in loopat is a directory under `.loopat/profiles/<name>/`
* that contains a `.claude/` subdir (the same shape CC's project-tier uses:
* settings.json + CLAUDE.md + skills/ + agents/). No loopat-invented schema.
*
* On loop spawn, loopat:
* 1. Determines active profiles (user defaults + CLI flags)
* 2. Merges team's `.loopat/.claude/` + each active profile's `.claude/`
* + personal layer into loop's `.claude/` (handled by compose.ts)
* 3. Reads merged settings.json's `enabledPlugins` + `extraKnownMarketplaces`
* to drive plugin installation (handled by plugin-installer.ts)
*
* See docs/composition.md.
*/
import { existsSync } from "node:fs"
import { readFile, readdir } from "node:fs/promises"
import { join } from "node:path"
import {
personalClaudeDir,
personalLoopatConfigPath,
personalVaultDir,
personalKnowledgeProfileClaudeDir,
personalKnowledgeProfilesDir,
personalKnowledgeTeamClaudeDir,
} from "./paths"
/** personal/<u>/.loopat/config.json fields relevant to profile resolution. */
export type PersonalProfileConfig = {
default_profiles?: string[]
default_vault?: string
prefs?: Record<string, unknown>
}
/** Output of resolveLoopPlan — describes the materialization sources. */
export type LoopPlan = {
user: string
/** `.claude/` dirs to merge into the loop's .claude/, in load order
* (later sources win on conflicts; team first, profiles in declared order,
* personal last). */
claudeSources: Array<{ source: string; dir: string }>
/** Active profile names (excludes team & personal). */
profiles: string[]
/** Vault selection (from personal config or override). */
vault?: string
/** Resolved vault dir on host (if exists). */
vaultDir?: string
}
export type ResolveInput = {
user: string
/** Profiles added via CLI (+name). */
cliAdded?: string[]
/** Profiles removed via CLI (-name). */
cliRemoved?: string[]
/** Hard override — replaces default_profiles. */
overrideProfiles?: string[]
/** Override vault selection. */
vaultOverride?: string
/** @deprecated Workdir is the SDK's project tier, read directly via
* settingSources='project'. It is NOT merged into the user tier any more
* (otherwise edits to workdir/.claude/ would change a frozen loop's
* user-tier snapshot, violating principle 1). The field is kept for the
* loopat CLI which still bundles workdir into a one-shot compose; remove
* once that path is gone. */
workdir?: string
}
async function readPersonalConfig(user: string): Promise<PersonalProfileConfig> {
const path = personalLoopatConfigPath(user)
if (!existsSync(path)) return {}
try {
return JSON.parse(await readFile(path, "utf8")) as PersonalProfileConfig
} catch {
return {}
}
}
/**
* Compute active profile set: (default_profiles ∪ cliAdded) − cliRemoved,
* with overrideProfiles replacing default_profiles when set. Order preserved:
* defaults first, then cliAdded. Team-tier is always implicit (handled by
* compose); no need to include it here.
*/
function computeActiveProfiles(
cfg: PersonalProfileConfig,
cliAdded: string[],
cliRemoved: string[],
overrideProfiles?: string[],
): string[] {
const base = overrideProfiles ?? cfg.default_profiles ?? []
const removed = new Set(cliRemoved)
const all = [...base, ...cliAdded]
const out: string[] = []
const seen = new Set<string>()
for (const p of all) {
if (removed.has(p) || seen.has(p)) continue
seen.add(p)
out.push(p)
}
return out
}
/**
* Main entry: produce a LoopPlan from inputs. Pure / no side effects.
* Validates that named profiles actually have `.claude/` subdirs (otherwise
* they'd be silently invisible).
*/
export async function resolveLoopPlan(input: ResolveInput): Promise<LoopPlan> {
const { user, cliAdded = [], cliRemoved = [], overrideProfiles, vaultOverride, workdir } = input
const cfg = await readPersonalConfig(user)
const activeNames = computeActiveProfiles(cfg, cliAdded, cliRemoved, overrideProfiles)
// Validate — profiles live in the user's PER-USER knowledge repo.
const profilesRoot = personalKnowledgeProfilesDir(user)
if (activeNames.length > 0 && !existsSync(profilesRoot)) {
throw new Error(`profiles dir not found: ${profilesRoot}`)
}
for (const name of activeNames) {
const cdir = personalKnowledgeProfileClaudeDir(user, name)
if (!existsSync(cdir)) {
throw new Error(`profile "${name}" has no .claude/ dir at ${cdir}`)
}
}
// Build claudeSources in merge order
const claudeSources: LoopPlan["claudeSources"] = []
const teamDir = personalKnowledgeTeamClaudeDir(user)
if (existsSync(teamDir)) {
claudeSources.push({ source: "team", dir: teamDir })
}
for (const name of activeNames) {
claudeSources.push({ source: `profile:${name}`, dir: personalKnowledgeProfileClaudeDir(user, name) })
}
// Personal `.claude/` — 4th layer. Same CC-native shape as workspace + profile.
const personalCdir = personalClaudeDir(user)
if (existsSync(personalCdir)) {
claudeSources.push({ source: `personal:${user}`, dir: personalCdir })
}
// Repo `.claude/` — 5th (highest) layer. CC project-tier from workdir.
// Optional; only if workdir is set AND has a .claude/ subdir.
if (workdir) {
const repoCdir = join(workdir, ".claude")
if (existsSync(repoCdir)) {
claudeSources.push({ source: `repo:${workdir}`, dir: repoCdir })
}
}
const vault = vaultOverride ?? cfg.default_vault
const vaultDir = vault ? personalVaultDir(user, vault) : undefined
return {
user,
claudeSources,
profiles: activeNames,
vault,
vaultDir: vaultDir && existsSync(vaultDir) ? vaultDir : undefined,
}
}
/** List a user's available profile names = direct subdirs of THEIR per-user
* knowledge `profiles/` that contain `.claude/`. */
export async function listProfiles(user: string): Promise<string[]> {
const root = personalKnowledgeProfilesDir(user)
if (!existsSync(root)) return []
const entries = await readdir(root, { withFileTypes: true })
const out: string[] = []
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith(".")) continue
if (!existsSync(personalKnowledgeProfileClaudeDir(user, e.name))) continue
out.push(e.name)
}
return out.sort()
}