magus-cli 7.12.2 → 7.14.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.
Files changed (111) hide show
  1. package/README.md +21 -0
  2. package/package.json +4 -4
  3. package/src/__tests__/market-bases.test.ts +112 -0
  4. package/src/__tests__/market-config.test.ts +84 -0
  5. package/src/__tests__/market-conflicts.test.ts +175 -0
  6. package/src/__tests__/market-consent.test.ts +185 -0
  7. package/src/__tests__/market-content.test.ts +183 -0
  8. package/src/__tests__/market-delivery.test.ts +308 -0
  9. package/src/__tests__/market-fixture.ts +1167 -0
  10. package/src/__tests__/market-git.test.ts +130 -0
  11. package/src/__tests__/market-identity.test.ts +127 -0
  12. package/src/__tests__/market-install.test.ts +143 -0
  13. package/src/__tests__/market-layout.test.ts +206 -0
  14. package/src/__tests__/market-publish.test.ts +263 -0
  15. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  16. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  17. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  18. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  19. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  20. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  21. package/src/__tests__/market-r2-routing.test.ts +127 -0
  22. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  23. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  24. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  25. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  26. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  27. package/src/__tests__/market-readonly.test.ts +162 -0
  28. package/src/__tests__/market-repositories.test.ts +263 -0
  29. package/src/__tests__/market-results.test.ts +198 -0
  30. package/src/__tests__/market-router.test.ts +65 -0
  31. package/src/__tests__/market-safety.test.ts +357 -0
  32. package/src/__tests__/market-teammates.test.ts +304 -0
  33. package/src/cli/install.ts +64 -5
  34. package/src/cli/market-view.ts +329 -0
  35. package/src/cli/market.ts +664 -0
  36. package/src/cli/router.ts +69 -3
  37. package/src/cli/update-view.ts +56 -0
  38. package/src/cli/update.ts +111 -9
  39. package/src/data/marketplaces.ts +8 -2
  40. package/src/main.tsx +20 -2
  41. package/src/market-tui.tsx +21 -0
  42. package/src/services/active-closure.ts +71 -9
  43. package/src/services/claude-cli.ts +140 -10
  44. package/src/services/claude-settings.ts +19 -25
  45. package/src/services/doctor.ts +5 -5
  46. package/src/services/git-write.ts +615 -0
  47. package/src/services/github-cli.ts +309 -0
  48. package/src/services/local-marketplace.ts +112 -50
  49. package/src/services/market-bases.ts +191 -0
  50. package/src/services/market-change.ts +242 -0
  51. package/src/services/market-clone.ts +572 -0
  52. package/src/services/market-fs.ts +138 -0
  53. package/src/services/market-install.ts +172 -0
  54. package/src/services/market-layout.ts +311 -0
  55. package/src/services/market-local.ts +93 -0
  56. package/src/services/market-marker.ts +49 -0
  57. package/src/services/market-ops-content.ts +277 -0
  58. package/src/services/market-ops-plugin.ts +655 -0
  59. package/src/services/market-ops-publish.ts +1227 -0
  60. package/src/services/market-ops-repo.ts +916 -0
  61. package/src/services/market-ops-skill.ts +788 -0
  62. package/src/services/market-ops.ts +686 -0
  63. package/src/services/market-publish.ts +468 -0
  64. package/src/services/market-registry.ts +382 -0
  65. package/src/services/market-types.ts +268 -0
  66. package/src/services/marketplace-conflicts.ts +127 -0
  67. package/src/services/marketplace-refresh.ts +33 -3
  68. package/src/services/marketplace-sync.ts +253 -22
  69. package/src/services/plugin-dependencies.ts +17 -0
  70. package/src/services/plugin-manager.ts +1 -1
  71. package/src/services/plugin-mcp-config.ts +6 -9
  72. package/src/services/plugin-requires.ts +20 -8
  73. package/src/services/profile-absorb.ts +88 -3
  74. package/src/services/profile-adopt.ts +10 -1
  75. package/src/services/profiles.ts +64 -1
  76. package/src/services/resolver.ts +17 -5
  77. package/src/services/settings-manager.ts +2 -2
  78. package/src/services/skills-manager.ts +2 -2
  79. package/src/services/update-plan.ts +67 -8
  80. package/src/tui-runtime.tsx +142 -0
  81. package/src/tui.tsx +7 -124
  82. package/src/types/index.ts +7 -0
  83. package/src/ui/adapters/marketAdapter.ts +366 -0
  84. package/src/ui/adapters/pluginsAdapter.ts +45 -3
  85. package/src/ui/components/layout/ScreenLayout.tsx +43 -17
  86. package/src/ui/components/modals/ConfirmModal.tsx +145 -8
  87. package/src/ui/components/modals/HelpOverlay.tsx +10 -3
  88. package/src/ui/components/modals/ModalContainer.tsx +47 -5
  89. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  90. package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
  91. package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
  92. package/src/ui/components/primitives/SelectableRow.tsx +14 -1
  93. package/src/ui/components/primitives/ToneLine.tsx +25 -0
  94. package/src/ui/components/primitives/index.ts +5 -0
  95. package/src/ui/keymap.ts +21 -5
  96. package/src/ui/market/MarketApp.tsx +59 -0
  97. package/src/ui/market/MarketplaceScreen.tsx +280 -0
  98. package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
  99. package/src/ui/market/MarketplacesScreen.tsx +264 -0
  100. package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
  101. package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
  102. package/src/ui/market/marketKeymap.ts +243 -0
  103. package/src/ui/market/useMarketState.ts +694 -0
  104. package/src/ui/renderers/pluginRenderers.tsx +31 -3
  105. package/src/ui/renderers/pluginStatusText.ts +6 -0
  106. package/src/ui/screens/PluginsScreen.tsx +49 -5
  107. package/src/ui/screens/SkillsScreen.tsx +2 -2
  108. package/src/ui/state/AppContext.tsx +14 -2
  109. package/src/ui/state/types.ts +9 -2
  110. package/src/utils/config-dir.ts +29 -9
  111. package/src/utils/display-path.ts +33 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * market-bases.ts — where each local copy of a company skill came from.
3
+ *
4
+ * A base record says which marketplace commit a local skill folder matched:
5
+ * `{marketplace, repo, plugin, skill, commit, tree, token}`, kept in
6
+ * `<claude config dir>/magus-market/bases.json` and keyed by the folder's
7
+ * absolute path (its parent's real path plus the folder name).
8
+ *
9
+ * A record is bound to the copy it was made for by a provenance token: every
10
+ * {@link recordBase} writes a fresh random token into the folder as
11
+ * `.magus-origin`, then the same token into the record. {@link readBase}
12
+ * answers the record only while the folder still holds that token, so a
13
+ * folder recreated by hand, copied from elsewhere or restored from an older
14
+ * backup has no base. The path alone never gives one.
15
+ *
16
+ * Written only by apply steps; reading never writes.
17
+ */
18
+
19
+ import { randomBytes } from "node:crypto";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import {
23
+ claudeConfigDirOrNull,
24
+ requireClaudeConfigDir,
25
+ } from "../utils/config-dir.js";
26
+ import { writeFileNoFollow } from "./market-fs.js";
27
+
28
+ export const ORIGIN_FILE = ".magus-origin";
29
+ const SCHEMA = 1;
30
+
31
+ export interface BaseRecord {
32
+ marketplace: string;
33
+ repo: string;
34
+ plugin: string;
35
+ skill: string;
36
+ /** The marketplace commit the copy matches. */
37
+ commit: string;
38
+ /** The skill's tree hash at that commit, over the compare domain. */
39
+ tree: string;
40
+ token: string;
41
+ }
42
+
43
+ interface BasesFile {
44
+ schema: number;
45
+ bases: Record<string, BaseRecord>;
46
+ }
47
+
48
+ function isRecord(v: unknown): v is Record<string, unknown> {
49
+ return typeof v === "object" && v !== null && !Array.isArray(v);
50
+ }
51
+
52
+ function basesFile(root: string): string {
53
+ return path.join(root, "magus-market", "bases.json");
54
+ }
55
+
56
+ /** The key for a folder: its parent's real path plus its own name. */
57
+ export function folderKey(folder: string): string {
58
+ const abs = path.resolve(folder);
59
+ let parent = path.dirname(abs);
60
+ try {
61
+ parent = fs.realpathSync(parent);
62
+ } catch {
63
+ // A parent that does not exist keys by its plain absolute path.
64
+ }
65
+ return path.join(parent, path.basename(abs));
66
+ }
67
+
68
+ function parse(raw: unknown): BasesFile {
69
+ const out: BasesFile = { schema: SCHEMA, bases: {} };
70
+ if (!isRecord(raw) || !isRecord(raw.bases)) return out;
71
+ for (const [key, v] of Object.entries(raw.bases)) {
72
+ if (
73
+ isRecord(v) &&
74
+ typeof v.marketplace === "string" &&
75
+ typeof v.repo === "string" &&
76
+ typeof v.plugin === "string" &&
77
+ typeof v.skill === "string" &&
78
+ typeof v.commit === "string" &&
79
+ typeof v.tree === "string" &&
80
+ typeof v.token === "string"
81
+ ) {
82
+ out.bases[key] = {
83
+ marketplace: v.marketplace,
84
+ repo: v.repo,
85
+ plugin: v.plugin,
86
+ skill: v.skill,
87
+ commit: v.commit,
88
+ tree: v.tree,
89
+ token: v.token,
90
+ };
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function readFile(root: string | null): BasesFile {
97
+ if (!root) return { schema: SCHEMA, bases: {} };
98
+ try {
99
+ return parse(JSON.parse(fs.readFileSync(basesFile(root), "utf8")));
100
+ } catch {
101
+ return { schema: SCHEMA, bases: {} };
102
+ }
103
+ }
104
+
105
+ /** The file for a writer: absent is empty, unparseable refuses to be replaced. */
106
+ function readForWrite(file: string): BasesFile {
107
+ if (!fs.existsSync(file)) return { schema: SCHEMA, bases: {} };
108
+ try {
109
+ return parse(JSON.parse(fs.readFileSync(file, "utf8")));
110
+ } catch {
111
+ throw new Error(
112
+ `The record of where local skill copies came from cannot be read (${file}) — fix or delete it, then run the command again`,
113
+ );
114
+ }
115
+ }
116
+
117
+ function write(file: string, data: BasesFile): void {
118
+ fs.mkdirSync(path.dirname(file), { recursive: true });
119
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
120
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
121
+ fs.renameSync(tmp, file);
122
+ }
123
+
124
+ function readToken(folder: string): string | null {
125
+ try {
126
+ return (
127
+ fs.readFileSync(path.join(folder, ORIGIN_FILE), "utf8").trim() || null
128
+ );
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The record for `folder`, or null unless the folder holds `.magus-origin`
136
+ * with the record's token.
137
+ */
138
+ export function readBase(folder: string): BaseRecord | null {
139
+ const record = readFile(claudeConfigDirOrNull()).bases[folderKey(folder)];
140
+ if (!record) return null;
141
+ return readToken(folder) === record.token ? record : null;
142
+ }
143
+
144
+ /** Write a fresh token into `folder`, then the record carrying it. */
145
+ export function recordBase(
146
+ folder: string,
147
+ base: Omit<BaseRecord, "token">,
148
+ ): BaseRecord {
149
+ const token = randomBytes(16).toString("hex");
150
+ // A new file, never written through a link left at that name.
151
+ writeFileNoFollow(path.join(folder, ORIGIN_FILE), `${token}\n`);
152
+ const file = basesFile(requireClaudeConfigDir());
153
+ const data = readForWrite(file);
154
+ const record = { ...base, token };
155
+ data.bases[folderKey(folder)] = record;
156
+ write(file, data);
157
+ return record;
158
+ }
159
+
160
+ /** Drop the record for `folder`. True when there was one. */
161
+ export function forgetBase(folder: string): boolean {
162
+ const root = claudeConfigDirOrNull();
163
+ if (!root) return false;
164
+ const file = basesFile(root);
165
+ const data = readForWrite(file);
166
+ const key = folderKey(folder);
167
+ if (!Object.hasOwn(data.bases, key)) return false;
168
+ delete data.bases[key];
169
+ write(file, data);
170
+ return true;
171
+ }
172
+
173
+ /** Drop every record for `marketplace`, or only those for one plugin's skill. */
174
+ export function forgetBasesFor(
175
+ marketplace: string,
176
+ only?: { plugin: string; skill: string },
177
+ ): number {
178
+ const root = claudeConfigDirOrNull();
179
+ if (!root) return 0;
180
+ const file = basesFile(root);
181
+ const data = readForWrite(file);
182
+ let removed = 0;
183
+ for (const [key, r] of Object.entries(data.bases)) {
184
+ if (r.marketplace !== marketplace) continue;
185
+ if (only && (r.plugin !== only.plugin || r.skill !== only.skill)) continue;
186
+ delete data.bases[key];
187
+ removed++;
188
+ }
189
+ if (removed > 0) write(file, data);
190
+ return removed;
191
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * market-change.ts — the steps every change to a marketplace repository ends
3
+ * with: validate, commit exactly the planned paths, deliver.
4
+ *
5
+ * The commit is a function of the plan, not of whatever the working clone
6
+ * holds: only the plan's paths are staged, the staged changes must equal the
7
+ * plan's file list exactly, and a skill's committed tree is checked against
8
+ * the files the plan listed before anything is pushed.
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { validatePath } from "./claude-cli.js";
14
+ import {
15
+ addPaths,
16
+ commit as gitCommit,
17
+ lsTree,
18
+ stagedChanges,
19
+ } from "./git-write.js";
20
+ import { ghAvailable, repoForkAllowed, repoPermissions } from "./github-cli.js";
21
+ import { deliver, gitError } from "./market-clone.js";
22
+ import { type LinkError, readJsonUnder, writeJsonUnder } from "./market-fs.js";
23
+ import { type SkillFile, sameFiles } from "./market-layout.js";
24
+ import { isGithubSource } from "./market-registry.js";
25
+ import {
26
+ type DeliveryTarget,
27
+ type FileChange,
28
+ MarketError,
29
+ type MarketStep,
30
+ type StepContext,
31
+ } from "./market-types.js";
32
+
33
+ /** The working clone a step runs in; a plan that syncs always has one. */
34
+ export function cloneOf(ctx: StepContext): string {
35
+ if (!ctx.clone) throw new MarketError("other", "no working copy to change");
36
+ return ctx.clone;
37
+ }
38
+
39
+ /**
40
+ * The refusal for a write into `label`'s working clone that would pass a
41
+ * link the repository committed. It stops the step before anything is
42
+ * written outside the clone.
43
+ */
44
+ export function linkRefusal(label: string): LinkError {
45
+ return (link) =>
46
+ new MarketError(
47
+ "other",
48
+ `${label} holds a link at ${link}, and magus does not write through links — nothing was pushed`,
49
+ "replace the link with a folder in the marketplace repository, then run the command again",
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Write JSON to `rel` in a working clone the way marketplace files are
55
+ * written (two spaces, newline), refusing a path that passes a link.
56
+ */
57
+ export function writeJson(
58
+ clone: string,
59
+ rel: string,
60
+ value: unknown,
61
+ label: string,
62
+ ): void {
63
+ writeJsonUnder(clone, rel, value, linkRefusal(label));
64
+ }
65
+
66
+ /** Read JSON from `rel` in a working clone, refusing a path through a link. */
67
+ export function readJson(
68
+ clone: string,
69
+ rel: string,
70
+ label: string,
71
+ ): Record<string, unknown> {
72
+ return readJsonUnder(clone, rel, linkRefusal(label));
73
+ }
74
+
75
+ const LETTER: Record<FileChange, string> = {
76
+ added: "A",
77
+ modified: "M",
78
+ deleted: "D",
79
+ };
80
+
81
+ /**
82
+ * How a change can reach the repository, decided at plan time: push (with a
83
+ * pull request on a protected branch), or a pull request from a fork when
84
+ * this account cannot push. A repository that allows neither is refused here.
85
+ */
86
+ export async function deliveryTarget(
87
+ source: string,
88
+ label: string,
89
+ ): Promise<DeliveryTarget> {
90
+ if (!isGithubSource(source) || !(await ghAvailable())) return "push-or-pr";
91
+ const push = await repoPermissions(source);
92
+ if (!push.ok) {
93
+ if (push.failure === "gh-auth") {
94
+ throw new MarketError(
95
+ "gh-auth",
96
+ "Not signed in to GitHub",
97
+ "run `gh auth login`, then `gh auth setup-git`",
98
+ );
99
+ }
100
+ if (push.failure === "not-found") {
101
+ throw new MarketError(
102
+ "no-access",
103
+ `Your GitHub account cannot see ${label} (${source})`,
104
+ "ask its owner for access",
105
+ );
106
+ }
107
+ if (push.failure === "no-network") {
108
+ throw new MarketError("no-network", "Could not reach GitHub");
109
+ }
110
+ return "push-or-pr";
111
+ }
112
+ if (push.value) return "push-or-pr";
113
+ const fork = await repoForkAllowed(source);
114
+ if (fork.ok && fork.value) return "fork-pr";
115
+ throw new MarketError(
116
+ "fork-not-allowed",
117
+ `Your account cannot write to ${label}, and it does not allow forks`,
118
+ "ask its owner for write access",
119
+ );
120
+ }
121
+
122
+ /** The plan line for a delivery target. */
123
+ export function targetLine(target: DeliveryTarget, branch: string): string {
124
+ return target === "push-or-pr"
125
+ ? `Pushes to ${branch}; if ${branch} is protected, opens a pull request instead`
126
+ : "Opens a pull request from a fork of your own (your account cannot push here)";
127
+ }
128
+
129
+ export interface ChangeSpec {
130
+ /** The marketplace's name, for messages. */
131
+ label: string;
132
+ source: string;
133
+ branch: string;
134
+ target: DeliveryTarget;
135
+ /** Repository paths to stage — nothing else is. */
136
+ stage: string[];
137
+ /** The staged changes the plan expects, exactly. */
138
+ files: Array<{ path: string; change: FileChange }>;
139
+ message: string;
140
+ /** Names a pull-request branch. */
141
+ slug: string;
142
+ prBody: string;
143
+ /** Directories whose committed files must equal these, each. */
144
+ verify?: ReadonlyArray<{ dir: string; files: readonly SkillFile[] }>;
145
+ /** Set when the commit is made, for later steps. */
146
+ state: { commit?: string; pushed?: boolean };
147
+ }
148
+
149
+ /** `validate`, `commit` and `deliver`, in that order. */
150
+ export function changeSteps(spec: ChangeSpec): MarketStep[] {
151
+ return [
152
+ {
153
+ id: "validate",
154
+ label: "checked with claude plugin validate",
155
+ outside: false,
156
+ async run(ctx) {
157
+ const r = await validatePath(cloneOf(ctx));
158
+ if (!r.ok) {
159
+ throw new MarketError(
160
+ "validate-failed",
161
+ `Claude Code does not accept ${spec.label} after this change: ${r.output}`,
162
+ );
163
+ }
164
+ return undefined;
165
+ },
166
+ },
167
+ {
168
+ id: "commit",
169
+ label: "committed",
170
+ outside: false,
171
+ async run(ctx) {
172
+ const dir = cloneOf(ctx);
173
+ const staged = await addPaths(dir, spec.stage);
174
+ if (!staged.ok) throw gitError(staged, spec.label);
175
+ const changes = await stagedChanges(dir);
176
+ if (!changes.ok) throw gitError(changes, spec.label);
177
+ const want = new Map(spec.files.map((f) => [f.path, LETTER[f.change]]));
178
+ const matches =
179
+ changes.value.length === want.size &&
180
+ changes.value.every((c) => want.get(c.path) === c.status);
181
+ if (!matches) {
182
+ throw new MarketError(
183
+ "other",
184
+ "the working copy did not match the plan — nothing was pushed",
185
+ );
186
+ }
187
+ const made = await gitCommit(dir, spec.message);
188
+ if (!made.ok) throw gitError(made, spec.label);
189
+ for (const check of spec.verify ?? []) {
190
+ const tree = await lsTree(dir, made.value, check.dir);
191
+ if (!tree.ok) throw gitError(tree, spec.label);
192
+ const prefix = `${check.dir}/`;
193
+ const committed = tree.value.map((e) => ({
194
+ path: e.path.slice(prefix.length),
195
+ oid: e.oid,
196
+ }));
197
+ if (!sameFiles(committed, check.files)) {
198
+ throw new MarketError(
199
+ "other",
200
+ "the commit did not hold exactly the planned files — nothing was pushed",
201
+ );
202
+ }
203
+ }
204
+ spec.state.commit = made.value;
205
+ return {
206
+ detail: made.value.slice(0, 7),
207
+ patch: { commit: made.value },
208
+ };
209
+ },
210
+ },
211
+ {
212
+ id: "deliver",
213
+ label: `delivered to ${spec.source}`,
214
+ outside: true,
215
+ async run(ctx) {
216
+ const d = await deliver({
217
+ clone: cloneOf(ctx),
218
+ branch: spec.branch,
219
+ source: spec.source,
220
+ target: spec.target,
221
+ slug: spec.slug,
222
+ title: spec.message,
223
+ body: spec.prBody,
224
+ label: spec.label,
225
+ });
226
+ if (d.kind === "pushed") {
227
+ spec.state.pushed = true;
228
+ return { detail: `pushed to ${spec.branch}` };
229
+ }
230
+ return {
231
+ detail: `pull request opened from ${d.branch}`,
232
+ stop: true,
233
+ patch: {
234
+ outcome: "pull-request",
235
+ pullRequest: d.url,
236
+ message: `Opened a pull request — the change lands in ${spec.label} once it is merged: ${d.url}`,
237
+ },
238
+ };
239
+ },
240
+ },
241
+ ];
242
+ }