claudeup 4.42.1 → 5.0.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.
@@ -1,3 +1,5 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
1
3
  /**
2
4
  * Marketplace sync service
3
5
  *
@@ -13,17 +15,16 @@
13
15
  * deleted. See ai-docs/plugin-marketplace-bug-investigation.md.
14
16
  */
15
17
  import fs from "fs-extra";
16
- import path from "node:path";
17
- import os from "node:os";
18
+ import { defaultMarketplaces } from "../data/marketplaces.js";
19
+ import type { ProfileMarketplaceRef } from "../types/index.js";
20
+ import { parsePluginId } from "../utils/string-utils.js";
21
+ import { addMarketplace } from "./claude-cli.js";
18
22
  import {
19
- getGlobalEnabledPlugins,
20
23
  getEnabledPlugins,
24
+ getGlobalEnabledPlugins,
21
25
  getLocalEnabledPlugins,
22
26
  isMarketplaceRegistered,
23
27
  } from "./claude-settings.js";
24
- import { parsePluginId } from "../utils/string-utils.js";
25
- import { defaultMarketplaces } from "../data/marketplaces.js";
26
- import { addMarketplace } from "./claude-cli.js";
27
28
 
28
29
  const MARKETPLACES_DIR = path.join(
29
30
  os.homedir(),
@@ -77,6 +78,36 @@ export async function getReferencedMarketplaces(
77
78
  /** Re-exported for convenience — canonical implementation lives in claude-settings. */
78
79
  export { isMarketplaceRegistered };
79
80
 
81
+ /**
82
+ * Register every marketplace a resolved profile closure declares, skipping any
83
+ * already known.
84
+ *
85
+ * Shared by `install` and `update`. It lived inside cli/install.ts until
86
+ * `update` shipped without it and could not install a plugin from a marketplace
87
+ * this machine had never registered: `installPlugin`'s own recovery path only
88
+ * knows claudeup's built-in `defaultMarketplaces`, so a manifest naming a
89
+ * private or third-party marketplace failed with "not found in marketplace" and
90
+ * no way forward.
91
+ *
92
+ * Failures are warned about, never thrown — one unreachable marketplace must not
93
+ * abort the whole run.
94
+ */
95
+ export async function registerClosureMarketplaces(
96
+ marketplaces: Record<string, ProfileMarketplaceRef>,
97
+ ): Promise<void> {
98
+ for (const [name, ref] of Object.entries(marketplaces)) {
99
+ try {
100
+ if (await isMarketplaceRegistered(name)) continue;
101
+ const source = ref.source === "github" ? ref.repo : ref.path;
102
+ if (!source) continue;
103
+ console.log(`+ marketplace ${name} (${source})`);
104
+ await addMarketplace(source);
105
+ } catch (e) {
106
+ console.warn(`⚠ marketplace ${name}: ${(e as Error).message}`);
107
+ }
108
+ }
109
+ }
110
+
80
111
  /**
81
112
  * Register any referenced marketplaces that are missing locally.
82
113
  * Looks up the GitHub repo in `defaultMarketplaces` and calls
@@ -13,9 +13,12 @@ import {
13
13
  getProjectInstalledPluginVersions,
14
14
  pickRegistryEntry,
15
15
  readInstalledPluginsRegistry,
16
+ saveGlobalInstalledPluginVersion,
17
+ saveLocalInstalledPluginVersion,
16
18
  updateInstalledPluginsRegistry,
17
19
  removeFromInstalledPluginsRegistry,
18
20
  } from "./claude-settings.js";
21
+ import type { PluginScope } from "./claude-cli.js";
19
22
  import { hasContentDrift } from "./content-drift.js";
20
23
  import { inheritablePaths } from "./git-worktree.js";
21
24
  import { defaultMarketplaces } from "../data/marketplaces.js";
@@ -800,6 +803,53 @@ export async function saveInstalledPluginVersion(
800
803
  );
801
804
  }
802
805
 
806
+ /**
807
+ * The version Claude Code's own registry records for a plugin at a scope, read
808
+ * fresh from disk.
809
+ *
810
+ * Exists because `claude plugin install` takes NO version argument — it installs
811
+ * whatever the marketplace currently publishes. The version we asked for and the
812
+ * version we got are therefore different questions, and only this one can answer
813
+ * the second. Callers must record what this returns, never what they intended:
814
+ * writing the intent fabricates state that the drift gate then trusts.
815
+ */
816
+ export async function readInstalledVersionForScope(
817
+ pluginId: string,
818
+ scope: PluginScope,
819
+ projectPath: string = process.cwd(),
820
+ ): Promise<string | null> {
821
+ const registry = await readInstalledPluginsRegistry();
822
+ const entry = pickRegistryEntry(
823
+ registry.plugins?.[pluginId],
824
+ scope,
825
+ projectPath,
826
+ );
827
+ return entry?.version ?? null;
828
+ }
829
+
830
+ /**
831
+ * Record an installed version in the settings file that owns the given scope.
832
+ *
833
+ * Claude Code's CLI does not maintain `installedPluginVersions`, so claudeup
834
+ * keeps its own copy. Writing it at the WRONG scope is worse than not writing
835
+ * it: it makes a scope claim a version it does not have installed. Writing the
836
+ * wrong VERSION is worse still — see {@link readInstalledVersionForScope}.
837
+ */
838
+ export async function saveInstalledPluginVersionForScope(
839
+ pluginId: string,
840
+ version: string,
841
+ scope: PluginScope,
842
+ projectPath: string = process.cwd(),
843
+ ): Promise<void> {
844
+ if (scope === "user") {
845
+ await saveGlobalInstalledPluginVersion(pluginId, version);
846
+ } else if (scope === "local") {
847
+ await saveLocalInstalledPluginVersion(pluginId, version, projectPath);
848
+ } else {
849
+ await saveInstalledPluginVersion(pluginId, version, projectPath);
850
+ }
851
+ }
852
+
803
853
  // Remove installed plugin version from settings.json
804
854
  export async function removeInstalledPluginVersion(
805
855
  pluginId: string,
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Adopt a project into profile management.
3
+ *
4
+ * `install` and `update` both speak the language of `.claude/profiles.json`,
5
+ * and most repos do not have one. The answer is to write one from what the
6
+ * project already has — enabled plugins, MCP servers, settings — rather than
7
+ * to run a second, manifest-free code path beside it. One command shape, one
8
+ * source of truth, and the generated file is a reviewable diff the user can
9
+ * commit and hand to the rest of the team.
10
+ *
11
+ * Plugins are adopted as "latest", never as the version that happens to be
12
+ * installed today. Pinning what a machine drifted to would turn one developer's
13
+ * accident into the team's contract.
14
+ */
15
+
16
+ import type {
17
+ McpServerConfig,
18
+ ProfileManifest,
19
+ ProfileManifestEntry,
20
+ } from "../types/index.js";
21
+ import {
22
+ getEnabledPlugins,
23
+ getGlobalEnabledPlugins,
24
+ readMcpConfig,
25
+ readSettings,
26
+ } from "./claude-settings.js";
27
+ import { emptyManifest } from "./manifest.js";
28
+ import { withDerivedMarketplaces } from "./resolver.js";
29
+
30
+ /** The profile id used when adopting a project that has no manifest. */
31
+ export const ADOPTED_PROFILE_ID = "default";
32
+
33
+ export interface AdoptInput {
34
+ /** Union of enabled plugins across every scope: id -> enabled. */
35
+ enabledPlugins: Record<string, boolean>;
36
+ /** Project `.claude/settings.json`, verbatim. */
37
+ settings: Record<string, unknown>;
38
+ /** Project `.mcp.json` servers. */
39
+ mcpServers: Record<string, McpServerConfig>;
40
+ /** Display name for the generated profile. */
41
+ name?: string;
42
+ /** Injectable clock so the output is deterministic in tests. */
43
+ now?: string;
44
+ }
45
+
46
+ /**
47
+ * Build a manifest entry from captured project state. Pure.
48
+ *
49
+ * `enabledPlugins` is dropped from `settings` deliberately: it is expressed as
50
+ * the entry's `plugins` map, and materialization regenerates it. Carrying both
51
+ * would let them disagree.
52
+ */
53
+ /**
54
+ * Settings keys that describe THIS MACHINE, not the project, and must never be
55
+ * copied into a manifest meant to be committed.
56
+ *
57
+ * - `enabledPlugins` is expressed as the entry's `plugins` map and regenerated
58
+ * by materialization; carrying both lets them disagree.
59
+ * - `installedPluginVersions` is claudeup's private record of what this machine
60
+ * has on disk. Committing it publishes one developer's install state as if it
61
+ * were the team's contract, and it goes stale the moment anyone updates.
62
+ */
63
+ const MACHINE_ONLY_SETTINGS_KEYS = [
64
+ "enabledPlugins",
65
+ "installedPluginVersions",
66
+ ] as const;
67
+
68
+ /**
69
+ * Settings values that mention an absolute path under the user's home
70
+ * directory. Not stripped — a `permissions.additionalDirectories` list is often
71
+ * genuinely wanted — but surfaced so the user reviews them before committing.
72
+ */
73
+ export function findMachineSpecificPaths(
74
+ settings: Record<string, unknown>,
75
+ home: string = process.env.HOME ?? "",
76
+ ): string[] {
77
+ if (!home) return [];
78
+ const hits: string[] = [];
79
+ const walk = (value: unknown, path: string): void => {
80
+ if (typeof value === "string") {
81
+ if (value.includes(home)) hits.push(`${path} = ${value}`);
82
+ return;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ value.forEach((v, i) => walk(v, `${path}[${i}]`));
86
+ return;
87
+ }
88
+ if (value && typeof value === "object") {
89
+ for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
90
+ }
91
+ };
92
+ for (const [k, v] of Object.entries(settings)) walk(v, k);
93
+ return hits;
94
+ }
95
+
96
+ export function buildAdoptedProfile(input: AdoptInput): ProfileManifestEntry {
97
+ const plugins: Record<string, string> = {};
98
+ for (const [id, enabled] of Object.entries(input.enabledPlugins)) {
99
+ // A plugin id must name its marketplace; anything else cannot be resolved
100
+ // or reinstalled on another machine, so it has no place in a team manifest.
101
+ if (enabled && id.includes("@")) plugins[id] = "latest";
102
+ }
103
+
104
+ const settings = { ...input.settings };
105
+ for (const key of MACHINE_ONLY_SETTINGS_KEYS) delete settings[key];
106
+
107
+ const entry: ProfileManifestEntry = {
108
+ name: input.name ?? "Default",
109
+ marketplaces: withDerivedMarketplaces(undefined, plugins),
110
+ plugins,
111
+ createdAt: input.now ?? new Date().toISOString(),
112
+ };
113
+ if (Object.keys(input.mcpServers).length > 0) {
114
+ entry.mcpServers = input.mcpServers;
115
+ }
116
+ if (Object.keys(settings).length > 0) entry.settings = settings;
117
+ return entry;
118
+ }
119
+
120
+ export interface AdoptResult {
121
+ manifest: ProfileManifest;
122
+ profileId: string;
123
+ entry: ProfileManifestEntry;
124
+ counts: { plugins: number; mcpServers: number; settingsKeys: number };
125
+ /** Which settings file the plugin list came from. */
126
+ source: AdoptSource;
127
+ /** Enabled plugin ids skipped because they name no marketplace. */
128
+ skipped: string[];
129
+ /** Carried-over settings values naming an absolute path under $HOME. */
130
+ machinePaths: string[];
131
+ }
132
+
133
+ /** Which settings file the adopted plugin list came from. */
134
+ export type AdoptSource = "project" | "user";
135
+
136
+ /**
137
+ * Find the plugins to adopt, preferring PROJECT scope.
138
+ *
139
+ * The generated manifest is meant to be committed, so its plugin list must
140
+ * describe the repo, not the machine. `.claude/settings.json` is exactly that:
141
+ * the repo's own committed choice. User scope (`~/.claude/settings.json`) is
142
+ * one developer's global set and local scope is gitignored personal state —
143
+ * adopting either silently would publish a personal setup as the team's
144
+ * contract.
145
+ *
146
+ * User scope is still offered as a fallback, because a developer who enables
147
+ * everything globally would otherwise have nothing to adopt. The caller is told
148
+ * which source was used and says so, so the choice is never invisible.
149
+ */
150
+ async function collectEnabledPlugins(
151
+ projectPath: string,
152
+ ): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
153
+ const project = await getEnabledPlugins(projectPath).catch(() => ({}));
154
+ if (Object.values(project).some(Boolean)) {
155
+ return { plugins: project, source: "project" };
156
+ }
157
+ const user = await getGlobalEnabledPlugins().catch(() => ({}));
158
+ return { plugins: user, source: "user" };
159
+ }
160
+
161
+ /**
162
+ * Read this project's current state and produce the manifest that describes it.
163
+ * Does not write — the caller confirms first.
164
+ */
165
+ export async function adoptCurrentProject(
166
+ projectPath: string,
167
+ name?: string,
168
+ ): Promise<AdoptResult> {
169
+ const { plugins: enabledPlugins, source } =
170
+ await collectEnabledPlugins(projectPath);
171
+ const settings = (await readSettings(projectPath)) as unknown as Record<
172
+ string,
173
+ unknown
174
+ >;
175
+ const mcp = await readMcpConfig(projectPath);
176
+
177
+ const entry = buildAdoptedProfile({
178
+ enabledPlugins,
179
+ settings,
180
+ mcpServers: mcp.mcpServers ?? {},
181
+ name,
182
+ });
183
+
184
+ const manifest = emptyManifest();
185
+ manifest.profiles[ADOPTED_PROFILE_ID] = entry;
186
+
187
+ const skipped = Object.entries(enabledPlugins)
188
+ .filter(([id, on]) => on && !id.includes("@"))
189
+ .map(([id]) => id);
190
+
191
+ return {
192
+ manifest,
193
+ profileId: ADOPTED_PROFILE_ID,
194
+ entry,
195
+ source,
196
+ counts: {
197
+ plugins: Object.keys(entry.plugins ?? {}).length,
198
+ mcpServers: Object.keys(entry.mcpServers ?? {}).length,
199
+ settingsKeys: Object.keys(entry.settings ?? {}).length,
200
+ },
201
+ skipped,
202
+ machinePaths: findMachineSpecificPaths(
203
+ (entry.settings ?? {}) as Record<string, unknown>,
204
+ ),
205
+ };
206
+ }
@@ -161,7 +161,7 @@ async function resolveBins(
161
161
  * to register one. An unknown suffix is left alone for the manifest to declare.
162
162
  * An explicit entry always wins: deriving must never override a pin.
163
163
  */
164
- function withDerivedMarketplaces(
164
+ export function withDerivedMarketplaces(
165
165
  declared: ProfileManifestEntry["marketplaces"],
166
166
  plugins: Record<string, string>,
167
167
  ): NonNullable<ProfileManifestEntry["marketplaces"]> {
@@ -95,3 +95,38 @@ export function binInstallCommand(bin: {
95
95
  return `go install ${bin.module ?? bin.name}@${bin.version ?? "latest"}`;
96
96
  }
97
97
  }
98
+
99
+ /**
100
+ * The command that advances an ALREADY-INSTALLED binary to the newest version.
101
+ *
102
+ * Distinct from {@link binInstallCommand} because two of the five installers
103
+ * treat "install" as a no-op once the package is present: `brew install` on an
104
+ * installed formula prints "already installed" and changes nothing, and `pip
105
+ * install` without `--upgrade` does the same. Reusing the install command for
106
+ * `claudeup update` would therefore report success while advancing nothing.
107
+ *
108
+ * A pinned version has nothing to advance to, so callers must only reach this
109
+ * for unpinned requirements; `@latest` is stated explicitly where the installer
110
+ * accepts it, so the intent survives in the printed command.
111
+ */
112
+ export function binUpgradeCommand(bin: {
113
+ name: string;
114
+ via: BinInstaller;
115
+ package?: string;
116
+ formula?: string;
117
+ module?: string;
118
+ }): string {
119
+ const pkg = bin.package ?? bin.name;
120
+ switch (bin.via) {
121
+ case "bun":
122
+ return `bun install -g ${pkg}@latest`;
123
+ case "npm":
124
+ return `npm install -g ${pkg}@latest`;
125
+ case "pip":
126
+ return `pip install --upgrade ${pkg}`;
127
+ case "brew":
128
+ return `brew upgrade ${bin.formula ?? bin.name}`;
129
+ case "go":
130
+ return `go install ${bin.module ?? bin.name}@latest`;
131
+ }
132
+ }