magus-cli 7.9.1 → 7.10.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.
@@ -34,6 +34,10 @@ import {
34
34
  type UpdatePlan,
35
35
  isKeptDeprecated,
36
36
  } from "../services/update-plan.js";
37
+ import type {
38
+ UnsetWorktree,
39
+ WorktreeSetupResult,
40
+ } from "../services/worktree-profiles.js";
37
41
  import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
38
42
  import { brand } from "../ui/theme.js";
39
43
  import { formatCommand } from "../utils/command-utils.js";
@@ -903,3 +907,72 @@ export function pendingHeadline(plan: UpdatePlan): string {
903
907
  !plan.skills.some((s) => s.action === "install");
904
908
  return onlyRemovals ? "Changes pending" : "Updates available";
905
909
  }
910
+
911
+ // -- other worktrees -----------------------------------------------------------
912
+ //
913
+ // The other working trees of this repository that have no profile set up
914
+ // (`findUnsetWorktrees`). `magus update` sets up the ones the project already
915
+ // answered for, exactly as it does its own folder, and says so in one line.
916
+ // No section and no question: there is nothing to decide. Read-only runs
917
+ // (`magus doctor`, `--check`, `--dry-run`) get the same one line as a warning.
918
+
919
+ /** `Worktree a` or `Worktrees a, b, c + 2 more`, so the row stays one line. */
920
+ function worktreeLabel(names: readonly string[]): string {
921
+ const shown = names.slice(0, 3).join(", ");
922
+ const list = names.length > 3 ? `${shown} + ${names.length - 3} more` : shown;
923
+ return `${names.length === 1 ? "Worktree" : "Worktrees"} ${bold(list)}`;
924
+ }
925
+
926
+ /**
927
+ * The worktree rows: after a setup (`results`), one `✓` line for every
928
+ * worktree set up and a `✗` line for each failure; on a read-only run
929
+ * (`results` null), one `⚠` line for the ones `magus update` would set up.
930
+ * A worktree that needs a choice gets its own `⚠` line either way. Empty for
931
+ * an empty list: a repository with nothing to say prints nothing.
932
+ */
933
+ export function worktreeLines(
934
+ worktrees: readonly UnsetWorktree[],
935
+ results: readonly WorktreeSetupResult[] | null,
936
+ ): string[] {
937
+ if (worktrees.length === 0) return [];
938
+ const lines = [""];
939
+ if (results === null) {
940
+ const auto = worktrees.filter((w) => w.autoActivate).map((w) => w.name);
941
+ if (auto.length > 0) {
942
+ const one = auto.length === 1;
943
+ lines.push(
944
+ warn(
945
+ `${worktreeLabel(auto)} ${one ? "is" : "are"} missing this project's plugins. magus update sets ${one ? "it" : "them"} up.`,
946
+ ),
947
+ );
948
+ }
949
+ } else {
950
+ const done = results.filter((r) => r.ok).map((r) => r.name);
951
+ if (done.length > 0) {
952
+ lines.push(
953
+ ok(
954
+ `${worktreeLabel(done)} now ${done.length === 1 ? "has" : "have"} this project's plugins.`,
955
+ ),
956
+ );
957
+ }
958
+ for (const r of results) {
959
+ if (!r.ok) {
960
+ lines.push(
961
+ fail(
962
+ `Worktree ${bold(r.name)} was not set up: ${r.reason.replace(/\.+$/, "")}.`,
963
+ ),
964
+ );
965
+ }
966
+ }
967
+ }
968
+ for (const w of worktrees) {
969
+ if (!w.autoActivate) {
970
+ lines.push(
971
+ warn(
972
+ `Worktree ${bold(w.name)} is missing this project's plugins. Run magus update there to choose a profile.`,
973
+ ),
974
+ );
975
+ }
976
+ }
977
+ return lines;
978
+ }
package/src/cli/update.ts CHANGED
@@ -84,6 +84,7 @@ import {
84
84
  planSkillUpdates,
85
85
  summarizePlan,
86
86
  } from "../services/update-plan.js";
87
+ import type { ProfileManifest } from "../types/index.js";
87
88
  import { brand } from "../ui/theme.js";
88
89
  import { bold, dim, fg, width } from "./ansi.js";
89
90
  import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
@@ -108,6 +109,7 @@ import {
108
109
  unknownToolsNotice,
109
110
  warn as warnLine,
110
111
  } from "./update-view.js";
112
+ import { setUpOtherWorktrees } from "./worktrees.js";
111
113
 
112
114
  interface UpdateFlags {
113
115
  /** Report drift and exit 1 if anything is behind. The CI gate. */
@@ -250,6 +252,25 @@ export async function runUpdateCommand(
250
252
  // what it promised. A refused `--check` exits 1, because that is a gate.
251
253
  if (!current) return flags.dryRun ? 0 : 1;
252
254
 
255
+ const code = await updateProfile(flags, projectPath, current, startedAt);
256
+
257
+ // This project is on a profile; now the other worktrees of the repository,
258
+ // set up the same way without asking. After the update, so their line is
259
+ // not lost above the plan. Read-only under `--check` and `--dry-run`, and
260
+ // never a change to the exit code: those are other projects.
261
+ await setUpOtherWorktrees(projectPath, {
262
+ mayWrite: !flags.check && !flags.dryRun,
263
+ });
264
+ return code;
265
+ }
266
+
267
+ /** The update itself, once the project's own profile gate has passed. */
268
+ async function updateProfile(
269
+ flags: UpdateFlags,
270
+ projectPath: string,
271
+ current: ProfileManifest,
272
+ startedAt: number,
273
+ ): Promise<number> {
253
274
  const profileIds = Object.keys(current.profiles);
254
275
  if (flags.profile && !current.profiles[flags.profile]) {
255
276
  console.error(
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The other worktrees of this repository at the end of `magus update`: the ones
3
+ * with no profile set up. Each one whose project already declares a profile,
4
+ * with nothing of the user's to replace, is set up without asking, exactly as
5
+ * `magus update` sets up its own folder. Then one line says so.
6
+ *
7
+ * Detection and apply are `services/worktree-profiles.ts`, shared with
8
+ * `magus doctor` and the TUI; the words are `update-view.ts`.
9
+ */
10
+
11
+ import {
12
+ findUnsetWorktrees,
13
+ setUpWorktrees,
14
+ } from "../services/worktree-profiles.js";
15
+ import { worktreeLines } from "./update-view.js";
16
+
17
+ /**
18
+ * Set up what can be set up when `mayWrite`, and print the rows. Under
19
+ * `--check` and `--dry-run` it only reports. Never changes the command's exit
20
+ * code: these are other projects, and a failure in one is reported on its row.
21
+ */
22
+ export async function setUpOtherWorktrees(
23
+ projectPath: string,
24
+ opts: { mayWrite: boolean },
25
+ ): Promise<void> {
26
+ const worktrees = await findUnsetWorktrees(projectPath);
27
+ if (worktrees.length === 0) return;
28
+ const results = opts.mayWrite
29
+ ? await setUpWorktrees(worktrees.filter((w) => w.autoActivate))
30
+ : null;
31
+ for (const line of worktreeLines(worktrees, results)) console.log(line);
32
+ }
@@ -190,6 +190,47 @@ export async function inheritablePaths(projectPath: string): Promise<string[]> {
190
190
  return paths;
191
191
  }
192
192
 
193
+ /** One working tree of a repository, as `git worktree list` reports it. */
194
+ export interface WorkingTree {
195
+ /** Absolute path, as git recorded it. */
196
+ path: string;
197
+ /** git says its folder is gone (`prunable`); the record outlives it. */
198
+ prunable: boolean;
199
+ }
200
+
201
+ /**
202
+ * Every working tree of `projectPath`'s repository — the main one first, then
203
+ * each linked worktree — and the one `projectPath` itself sits in.
204
+ *
205
+ * Null when git cannot say: not a repository, git missing or not answering.
206
+ * A bare repository's own record has no working tree and is left out. Reads
207
+ * only; never caches, because a worktree added while magus runs is exactly
208
+ * what a caller of this wants to see.
209
+ */
210
+ export async function listWorkingTrees(
211
+ projectPath: string,
212
+ ): Promise<{ self: string; trees: WorkingTree[] } | null> {
213
+ const dir = path.resolve(projectPath);
214
+ const [top, list] = await Promise.all([
215
+ git(dir, ["rev-parse", "--show-toplevel"]),
216
+ git(dir, ["worktree", "list", "--porcelain"]),
217
+ ]);
218
+ if (top.code !== 0 || !top.out || list.code !== 0) return null;
219
+ const trees: WorkingTree[] = [];
220
+ for (const record of list.out.split(/\n\s*\n/)) {
221
+ const lines = record.split("\n").map((l) => l.trim());
222
+ const line = lines.find((l) => l.startsWith("worktree "));
223
+ if (!line || lines.includes("bare")) continue;
224
+ trees.push({
225
+ path: path.resolve(line.slice("worktree ".length)),
226
+ prunable: lines.some(
227
+ (l) => l === "prunable" || l.startsWith("prunable "),
228
+ ),
229
+ });
230
+ }
231
+ return { self: path.resolve(top.out), trees };
232
+ }
233
+
193
234
  async function resolveInheritable(
194
235
  dir: string,
195
236
  ): Promise<{ paths: string[]; answered: boolean }> {
@@ -217,7 +217,9 @@ export async function getPluginSourcePath(
217
217
  const plugin = manifest.plugins.find(
218
218
  (p: { name: string }) => p.name === pluginName,
219
219
  );
220
- return plugin?.source;
220
+ // An object source (git-subdir, github, url) is fetched from its own
221
+ // repo, not read from the marketplace clone, so there is no local path.
222
+ return typeof plugin?.source === "string" ? plugin.source : undefined;
221
223
  }
222
224
  }
223
225
  } catch {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The other working trees of this repository that carry a magus manifest but
3
+ * have no profile in effect — and setting them up.
4
+ *
5
+ * A profiled project's generated files are git-ignored, so git never gives
6
+ * them to a new worktree. Claude Code treats the folder it starts in as the
7
+ * project and reads only that folder's settings, so a session started in such
8
+ * a worktree loads only the plugins turned on at USER scope. magus run in that
9
+ * worktree would fix it (`autoActivate`), but nothing tells the user to.
10
+ *
11
+ * So magus, run anywhere in the repository, looks at the other working trees
12
+ * too. Detection and apply are kept apart, and every surface — `magus update`,
13
+ * `magus doctor` and the TUI — uses these two functions; none of them decides
14
+ * anything of its own beyond how to show the result.
15
+ *
16
+ * Detection writes nothing, anywhere: it is `detectProfileStartupNeed` per
17
+ * working tree, which is detection only. Apply is the activation the normal
18
+ * gate runs for a fresh clone, against THAT worktree's own manifest — nothing
19
+ * is ever copied between worktrees.
20
+ */
21
+
22
+ import { realpath, stat } from "node:fs/promises";
23
+ import path from "node:path";
24
+ import { listWorkingTrees } from "./git-worktree.js";
25
+ import { getManifestPath, readManifest } from "./manifest.js";
26
+ import {
27
+ type ProfileStartupState,
28
+ detectProfileStartupNeed,
29
+ } from "./profile-startup.js";
30
+ import { applyProfileChoice } from "./profile-state.js";
31
+ import { SettingsParseError } from "./settings-file.js";
32
+
33
+ export interface UnsetWorktree {
34
+ /** Absolute path of that working tree. */
35
+ path: string;
36
+ /** Its folder name, how the user knows it. */
37
+ name: string;
38
+ /** From `detectProfileStartupNeed(path)`; never "ok". */
39
+ state: ProfileStartupState;
40
+ declaredDefault: string | null;
41
+ /** The repo declares a profile and nothing would be replaced. */
42
+ autoActivate: boolean;
43
+ }
44
+
45
+ export type WorktreeSetupResult =
46
+ | { path: string; name: string; ok: true; profileId: string }
47
+ | { path: string; name: string; ok: false; reason: string };
48
+
49
+ /** The canonical spelling of a path, so `/tmp` and `/private/tmp` compare equal. */
50
+ async function canonical(p: string): Promise<string> {
51
+ try {
52
+ return await realpath(p);
53
+ } catch {
54
+ return path.resolve(p);
55
+ }
56
+ }
57
+
58
+ async function isDirectory(p: string): Promise<boolean> {
59
+ try {
60
+ return (await stat(p)).isDirectory();
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function isFile(p: string): Promise<boolean> {
67
+ try {
68
+ return (await stat(p)).isFile();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read-only. The other working trees of `projectPath`'s repository that need
76
+ * setup, sorted by name.
77
+ *
78
+ * Left out: `projectPath`'s own working tree (the normal gate handles it), a
79
+ * working tree whose folder is gone, one with no manifest (not a magus
80
+ * project on that branch), and one already on a profile. Not a repository, or
81
+ * git failing, is `[]`.
82
+ */
83
+ export async function findUnsetWorktrees(
84
+ projectPath: string,
85
+ ): Promise<UnsetWorktree[]> {
86
+ let listing: Awaited<ReturnType<typeof listWorkingTrees>>;
87
+ try {
88
+ listing = await listWorkingTrees(projectPath);
89
+ } catch {
90
+ return [];
91
+ }
92
+ if (listing === null) return [];
93
+ const self = await canonical(listing.self);
94
+
95
+ const found = await Promise.all(
96
+ listing.trees.map(async (tree): Promise<UnsetWorktree | null> => {
97
+ if (tree.prunable || !(await isDirectory(tree.path))) return null;
98
+ const at = await canonical(tree.path);
99
+ if (at === self) return null;
100
+ if (!(await isFile(getManifestPath(at)))) return null;
101
+ try {
102
+ const need = await detectProfileStartupNeed(at);
103
+ if (need.state === "ok") return null;
104
+ return {
105
+ path: at,
106
+ name: path.basename(at),
107
+ state: need.state,
108
+ declaredDefault: need.declaredDefault,
109
+ autoActivate: need.autoActivate && need.declaredDefault !== null,
110
+ };
111
+ } catch {
112
+ // One working tree magus cannot read says nothing about the others.
113
+ return null;
114
+ }
115
+ }),
116
+ );
117
+
118
+ const seen = new Set<string>();
119
+ return found
120
+ .filter((w): w is UnsetWorktree => {
121
+ if (w === null || seen.has(w.path)) return false;
122
+ seen.add(w.path);
123
+ return true;
124
+ })
125
+ .sort((a, b) =>
126
+ a.name === b.name
127
+ ? a.path.localeCompare(b.path)
128
+ : a.name.localeCompare(b.name),
129
+ );
130
+ }
131
+
132
+ /** Why an activation failed, in one line that never ends on an absolute path. */
133
+ function failureReason(error: unknown, worktree: string): string {
134
+ if (error instanceof SettingsParseError) {
135
+ return `${path.relative(worktree, error.path)} ${error.problem}`;
136
+ }
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ return message.split("\n")[0] ?? message;
139
+ }
140
+
141
+ /**
142
+ * Activate the declared profile in each worktree whose `autoActivate` is true,
143
+ * one after another. Entries that need a choice are skipped and not returned;
144
+ * one failure does not stop the others.
145
+ */
146
+ export async function setUpWorktrees(
147
+ worktrees: UnsetWorktree[],
148
+ ): Promise<WorktreeSetupResult[]> {
149
+ const results: WorktreeSetupResult[] = [];
150
+ for (const wt of worktrees) {
151
+ if (!wt.autoActivate || wt.declaredDefault === null) continue;
152
+ try {
153
+ // That worktree's own manifest, read now: its branch may declare
154
+ // something other than this one's.
155
+ const manifest = await readManifest(wt.path);
156
+ const result = await applyProfileChoice(
157
+ { kind: "activate", id: wt.declaredDefault },
158
+ manifest,
159
+ wt.path,
160
+ // Nothing to announce: `autoActivate` means nothing of the user's
161
+ // was there to move aside, and printing here would land inside
162
+ // whichever surface called this.
163
+ { onDisplace: () => {} },
164
+ );
165
+ results.push({
166
+ path: wt.path,
167
+ name: wt.name,
168
+ ok: true,
169
+ profileId: result.profileId,
170
+ });
171
+ } catch (error) {
172
+ results.push({
173
+ path: wt.path,
174
+ name: wt.name,
175
+ ok: false,
176
+ reason: failureReason(error, wt.path),
177
+ });
178
+ }
179
+ }
180
+ return results;
181
+ }
package/src/ui/App.tsx CHANGED
@@ -21,10 +21,15 @@ import {
21
21
  checkForUpdates,
22
22
  getCurrentVersion,
23
23
  } from "../services/version-check.js";
24
+ import {
25
+ findUnsetWorktrees,
26
+ setUpWorktrees,
27
+ } from "../services/worktree-profiles.js";
24
28
  import {
25
29
  gitignoreStartupReport,
26
30
  profileStartupReport,
27
31
  staleRepairReport,
32
+ worktreeSetupReport,
28
33
  } from "./adapters/startupReports.js";
29
34
  import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
30
35
  import { TABS } from "./components/TabBar.js";
@@ -296,8 +301,15 @@ function AppContent({ onExit }: AppContentProps) {
296
301
  null,
297
302
  );
298
303
  const staleLoadModal = useStaleLoadModal();
304
+ // This project's own profile question is settled: it needed nothing, or it
305
+ // was answered. Only then are the other worktrees set up — before it, they
306
+ // would be set up from a project that is not set up itself.
307
+ const [profileSettled, setProfileSettled] = useState(false);
299
308
  // Stable, so `profileModal.show` is not rebuilt for it on every render.
300
- const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
309
+ const profileAnswered = React.useCallback(() => {
310
+ setProfileNeed(null);
311
+ setProfileSettled(true);
312
+ }, []);
301
313
  // Activating a profile changes which plugins are on, so the open screen must
302
314
  // reload. The result modal that used to follow sent the user to the Profiles
303
315
  // tab, which remounted it; without that modal the user stays where they are,
@@ -550,12 +562,31 @@ function AppContent({ onExit }: AppContentProps) {
550
562
  );
551
563
  queueReports(profileStartupReport(problems, activations ?? []));
552
564
  if (!needsNothing(need)) setProfileNeed(need);
565
+ else setProfileSettled(true);
553
566
  } catch {
554
567
  // non-fatal: the Profiles tab still works by hand
555
568
  }
556
569
  })();
557
570
  }, [queueReports]);
558
571
 
572
+ // The other worktrees of this repository with no profile set up, once this
573
+ // project's own question is settled. Each one whose project already declares
574
+ // a profile, with nothing of the user's to replace, is set up without
575
+ // asking — the same rule this project's own profile follows above. Success
576
+ // shows nothing; failures go to the one failure popup. A worktree that needs
577
+ // a choice is left to `magus update` run in it. Its own effect, for the
578
+ // same reason as the profile check above.
579
+ const worktreesLooked = useRef(false);
580
+ useEffect(() => {
581
+ if (!profileSettled || worktreesLooked.current) return;
582
+ worktreesLooked.current = true;
583
+ findUnsetWorktrees(process.cwd())
584
+ .then((found) => setUpWorktrees(found.filter((w) => w.autoActivate)))
585
+ .then((results) => queueReports(worktreeSetupReport(results)))
586
+ // non-fatal: `magus update` reports the same worktrees
587
+ .catch(() => {});
588
+ }, [profileSettled, queueReports]);
589
+
559
590
  // Auto-refresh marketplaces on startup
560
591
  useEffect(() => {
561
592
  const noRefresh = process.argv.includes("--no-refresh");
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The failure popup's words for everything outside the Plugins screen's apply
3
3
  * paths: the profile gate at startup, a profile activation, the startup
4
- * repairs (stale plugin loads, marketplace refreshes, gitignore fixes) and the
5
- * Git State screen. Each is a pure builder over what the service returned, on
4
+ * repairs (stale plugin loads, marketplace refreshes, gitignore fixes), the
5
+ * setup of other worktrees and the Git State screen. Each is a pure builder over what the service returned, on
6
6
  * top of {@link failureReport}, so they read like every other failure.
7
7
  *
8
8
  * Each returns null when nothing failed: success says nothing, because the
@@ -13,6 +13,7 @@ import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
13
13
  import type { ActivationResult } from "../../services/profile-activate.js";
14
14
  import { SettingsParseError } from "../../services/settings-file.js";
15
15
  import { pluginName } from "../../services/update-engine.js";
16
+ import type { WorktreeSetupResult } from "../../services/worktree-profiles.js";
16
17
  import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
17
18
  import type { FailureReport, FailureRow } from "../state/types.js";
18
19
  import {
@@ -212,6 +213,25 @@ export function stepFailureReport(
212
213
  };
213
214
  }
214
215
 
216
+ /**
217
+ * Worktrees the startup setup could not put on their declared profile, each
218
+ * by folder name with its reason. Null when every one was set up.
219
+ */
220
+ export function worktreeSetupReport(
221
+ results: readonly WorktreeSetupResult[],
222
+ ): FailureReport | null {
223
+ const failed = results.filter(
224
+ (r): r is Extract<WorktreeSetupResult, { ok: false }> => !r.ok,
225
+ );
226
+ return failureReport(
227
+ `${counted(failed.length, "worktree")} ${failed.length === 1 ? "was" : "were"} not set up`,
228
+ failed.map((r) => ({
229
+ subject: r.name,
230
+ reason: exceptionReason(r.reason).reason,
231
+ })),
232
+ );
233
+ }
234
+
215
235
  /**
216
236
  * M9: generated files the startup gitignore fix could not untrack, each with
217
237
  * git's own reason.