claudeup 4.29.0 → 4.31.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/README.md +98 -92
- package/package.json +4 -4
- package/src/__tests__/plugin-setup.test.ts +502 -6
- package/src/__tests__/profile-materializer.test.ts +87 -0
- package/src/cli/install.ts +36 -5
- package/src/cli/router.ts +17 -9
- package/src/services/plugin-setup.ts +413 -59
- package/src/services/profile-materializer.ts +55 -4
|
@@ -21,11 +21,26 @@ import { profileDir } from "./symlink-manager.js";
|
|
|
21
21
|
* enabled, merged with the profile's declared settings. The profile's settings
|
|
22
22
|
* win over the derived enabledPlugins only if it explicitly sets that key
|
|
23
23
|
* (it normally won't).
|
|
24
|
+
*
|
|
25
|
+
* `allPluginIds` is the manifest-wide union of plugins across every profile.
|
|
26
|
+
* Plugins in the union that this profile does NOT want are written as an
|
|
27
|
+
* explicit `false`, which is what makes `profile switch` exclusive rather than
|
|
28
|
+
* additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
|
|
29
|
+
* the highest-precedence scope down and taking the first scope that mentions
|
|
30
|
+
* the id — an id a scope omits falls through to the next one. Since
|
|
31
|
+
* `claudeup install` installs the union at user scope, every non-member would
|
|
32
|
+
* otherwise inherit that user-scope `true` and stay enabled while a profile
|
|
33
|
+
* that excludes it is active. Omitting the union (the default) preserves the
|
|
34
|
+
* old additive behavior for callers that have no cross-profile view.
|
|
24
35
|
*/
|
|
25
36
|
export function buildProfileSettings(
|
|
26
37
|
closure: ResolvedClosure,
|
|
38
|
+
allPluginIds: readonly string[] = [],
|
|
27
39
|
): Record<string, unknown> {
|
|
28
40
|
const enabledPlugins: Record<string, boolean> = {};
|
|
41
|
+
for (const pluginId of allPluginIds) {
|
|
42
|
+
enabledPlugins[pluginId] = false;
|
|
43
|
+
}
|
|
29
44
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
30
45
|
enabledPlugins[pluginId] = true;
|
|
31
46
|
}
|
|
@@ -39,6 +54,37 @@ export function buildProfileMcp(
|
|
|
39
54
|
return { mcpServers: { ...closure.mcpServers } };
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
|
|
59
|
+
*
|
|
60
|
+
* Activating a profile replaces `.claude/skills` with a symlink into the profile
|
|
61
|
+
* dir, and replacing means `fs.remove` first. A project that committed its own
|
|
62
|
+
* skills before adopting profiles would lose them at that moment. So the first
|
|
63
|
+
* time a profile's skills/ dir is created, any real (non-symlink) project skills
|
|
64
|
+
* are copied in — every profile inherits what the project already had, and the
|
|
65
|
+
* link swap destroys nothing.
|
|
66
|
+
*
|
|
67
|
+
* Only runs when the profile's skills/ dir does not exist yet, so it never
|
|
68
|
+
* fights a later `skills-manager` install or re-adds a skill the user removed.
|
|
69
|
+
*/
|
|
70
|
+
async function seedSkillsFromProject(
|
|
71
|
+
skillsDir: string,
|
|
72
|
+
projectPath?: string,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (await fs.pathExists(skillsDir)) return;
|
|
75
|
+
|
|
76
|
+
const projectSkills = path.join(projectPath ?? process.cwd(), ".claude", "skills");
|
|
77
|
+
try {
|
|
78
|
+
// lstat, not pathExists: an existing profile symlink is not a source.
|
|
79
|
+
const stat = await fs.lstat(projectSkills);
|
|
80
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
81
|
+
if ((await fs.readdir(projectSkills)).length === 0) return;
|
|
82
|
+
await fs.copy(projectSkills, skillsDir, { dereference: true });
|
|
83
|
+
} catch {
|
|
84
|
+
// No project skills dir (or unreadable) — nothing to carry over.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
/**
|
|
43
89
|
* Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
|
|
44
90
|
* settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
|
|
@@ -47,15 +93,20 @@ export async function materializeProfile(
|
|
|
47
93
|
name: string,
|
|
48
94
|
closure: ResolvedClosure,
|
|
49
95
|
projectPath?: string,
|
|
96
|
+
allPluginIds: readonly string[] = [],
|
|
50
97
|
): Promise<string> {
|
|
51
98
|
const dir = profileDir(name, projectPath);
|
|
52
99
|
await fs.ensureDir(dir);
|
|
53
|
-
await fs.writeJson(
|
|
54
|
-
|
|
55
|
-
|
|
100
|
+
await fs.writeJson(
|
|
101
|
+
path.join(dir, "settings.json"),
|
|
102
|
+
buildProfileSettings(closure, allPluginIds),
|
|
103
|
+
{ spaces: 2 },
|
|
104
|
+
);
|
|
56
105
|
await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
|
|
57
106
|
spaces: 2,
|
|
58
107
|
});
|
|
59
|
-
|
|
108
|
+
const skillsDir = path.join(dir, "skills");
|
|
109
|
+
await seedSkillsFromProject(skillsDir, projectPath);
|
|
110
|
+
await fs.ensureDir(skillsDir);
|
|
60
111
|
return dir;
|
|
61
112
|
}
|