boxbox-plugin 0.0.0-stage → 0.2.7

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -2
  3. package/bin/boxbox.mjs +121 -0
  4. package/package.json +17 -5
  5. package/payload/bundle.json +8 -0
  6. package/payload/claude/.claude-plugin/marketplace.json +15 -0
  7. package/payload/claude/bundle.json +9 -0
  8. package/payload/claude/claude-registration.mjs +80 -0
  9. package/payload/claude/codex-registration.mjs +62 -0
  10. package/payload/claude/install-state.mjs +46 -0
  11. package/payload/claude/install.mjs +150 -0
  12. package/payload/claude/packages/boxbox-0.2.7.tgz +0 -0
  13. package/payload/claude/platform.mjs +106 -0
  14. package/payload/claude/plugin/.claude-plugin/plugin.json +18 -0
  15. package/payload/claude/plugin/agents/readability-inspector.md +23 -0
  16. package/payload/claude/plugin/hooks/hooks.json +54 -0
  17. package/payload/claude/plugin/scripts/boxbox.mjs +42 -0
  18. package/payload/claude/plugin/skills/boxbox/SKILL.md +188 -0
  19. package/payload/claude/plugin/skills/boxbox/references/charter.md +72 -0
  20. package/payload/claude/plugin/skills/boxbox/references/grammar.md +147 -0
  21. package/payload/claude/plugin/skills/boxbox/references/readability-contract.md +311 -0
  22. package/payload/claude/plugin/skills/boxbox/references/typescript-grid-template.md +334 -0
  23. package/payload/claude-registration.mjs +80 -0
  24. package/payload/codex/.agents/plugins/marketplace.json +12 -0
  25. package/payload/codex/bundle.json +9 -0
  26. package/payload/codex/claude-registration.mjs +80 -0
  27. package/payload/codex/codex-registration.mjs +62 -0
  28. package/payload/codex/install-state.mjs +46 -0
  29. package/payload/codex/install.mjs +150 -0
  30. package/payload/codex/packages/boxbox-0.2.7.tgz +0 -0
  31. package/payload/codex/platform.mjs +106 -0
  32. package/payload/codex/plugin/.codex-plugin/plugin.json +29 -0
  33. package/payload/codex/plugin/hooks/hooks.json +42 -0
  34. package/payload/codex/plugin/scripts/boxbox.mjs +42 -0
  35. package/payload/codex/plugin/skills/boxbox/SKILL.md +188 -0
  36. package/payload/codex/plugin/skills/boxbox/references/charter.md +72 -0
  37. package/payload/codex/plugin/skills/boxbox/references/grammar.md +147 -0
  38. package/payload/codex/plugin/skills/boxbox/references/readability-contract.md +311 -0
  39. package/payload/codex/plugin/skills/boxbox/references/typescript-grid-template.md +334 -0
  40. package/payload/codex-registration.mjs +62 -0
  41. package/payload/install-state.mjs +46 -0
  42. package/payload/platform.mjs +106 -0
@@ -0,0 +1,334 @@
1
+ # TypeScript Grid Template
2
+
3
+ Except where a section is explicitly authoring-only, examples have the same horizontal form as
4
+ `07_align-tables.ts --write` output. When this document and the checker output differ, **the checker wins**; the
5
+ gap/insert distinction per axis follows the [authoring axis contract in the readability contract](readability-contract.md).
6
+ Each role section is an independent table and does not borrow another section's longest value.
7
+
8
+ ## Private local types
9
+
10
+ The terminating `;` is also a column (rule 35). Align `=` and `;` to the actual longest value and attach a `//` note column.
11
+
12
+ ```ts
13
+ type AppOptions = RunOptions ; // application run input
14
+ type SessionList = RecentSessionSummary[] ; // sessions to display
15
+ type ThreadSelection = string | null ; // null means the user cancelled
16
+ ```
17
+
18
+ Align the `//` column so local type notes read alongside their declarations. Exported types and public optional properties do not use this form; they keep the hover contract with JSDoc before the declaration.
19
+
20
+ ## Method signature table
21
+
22
+ Align `(` after the name width and `)` after the maximum parameter width. Empty parameter lists stay attached as `()` with padding outside the parentheses. The return-type `:` stays attached to `)` and only its column is aligned.
23
+
24
+ ```ts
25
+ export interface MethodContract {
26
+ runFirst (first: string ): Promise<void>;
27
+ runSecond(second: number, extra: string): void;
28
+ listAll () : Promise<string[]>;
29
+ }
30
+ ```
31
+
32
+ ## Declaration table (declaration-equals)
33
+
34
+ `:`, `=` and the terminating `;` are each one column. Declarations without a type align only `=` and `;`.
35
+
36
+ ```ts
37
+ const alpha : string = "a" ;
38
+ const beta : number = 22 ;
39
+ const gamma : boolean = true ;
40
+ ```
41
+
42
+ **Call layer.** When every row's value is the same call (same callee text, same argument count, nothing after the `)`), the call is a nested table: each top-level `,` sits right after the widest argument, the next argument starts one space later, and `)` sits right after the widest last argument. The same applies to the call inside a call statement's subject. Rows with different callees or arities keep one layer, and so does a table where one argument is 20 or more columns wider than the second widest in its column (the import outlier rule B); such a row is a candidate for the vertical form instead.
43
+
44
+ ```ts
45
+ const left = diagnostics(toolchain, before) ;
46
+ const right = diagnostics(toolchain, after ) ;
47
+
48
+ expect(preservesTokens(text , text.replace("c d", "c d"))).toBe(false) ;
49
+ expect(preservesTokens("let x = 1;", "letx = 1;" )).toBe(false) ;
50
+ ```
51
+
52
+ **Outer call envelope.** Different simple callees or arities can share outer parentheses in a declaration table.
53
+ The whole argument list is one cell; inner commas keep their original layout. One trailing `!` stays attached.
54
+ Empty calls keep `()` and share only the opening column. Optional, computed, awaited, constructor and generic calls,
55
+ call-produced receivers, trailing commas and comments on the editable boundaries are outside this form.
56
+ The new `declaration-call-envelope` kind uses its own lookup key, so existing same-call preset approval is not reused.
57
+
58
+ ```ts
59
+ const decision = registry.decisions.get(convention.id )! ;
60
+ const document = resolveDocument (decision.ref, convention.layer) ;
61
+ ```
62
+
63
+ **Nested comparison and conditional arguments.** A declaration table with the same callee and arity may compare a
64
+ nested expression only when it occurs at the same argument position and every participating row has the same AST
65
+ shape. Comparison operators stay indivisible. A compatible conditional expression aligns its comparison operator,
66
+ `?` and `:`; a different callee, arity, position or shape drops this nested layer without dropping the parent table.
67
+ Because the parent is a declaration table, two rows are enough for this layer. Ordinary table kinds still need three.
68
+
69
+ ```ts
70
+ const first = select(input === "a" ? left : right, context) ;
71
+ const second = select(value !== null ? ready : empty, context) ;
72
+ ```
73
+
74
+ **Existing multiline call arguments.** When a call already has one argument per physical line, align only its existing
75
+ top-level argument commas. Preserve its line count, leading indentation, tokens and trailing-comma choice. This
76
+ horizontal form does not turn a compact call vertical or pull a multiline call back onto one line.
77
+
78
+ ```ts
79
+ const value = build(
80
+ short ,
81
+ muchLongerArgument ,
82
+ last
83
+ );
84
+ ```
85
+
86
+ **List layer.** A literal list inside another table is a nested cell: the declaration value itself (`const STATES = [...] ;`), an argument of the layered call, or a matcher argument (`expect(x).toEqual([...])`). `[` / `{` keep one column, each item `,` sits right after the widest item in its column, the next item starts one space later, and `]` / `}` sits right after the widest last item. A slot is kept only when every row has the same list shape there (same arity, or the same object property names) and every item is a string, number or boolean literal; lists with identifiers or calls (argv-like), lists with a trailing comma or a multi-line form, and lists where one item is 20 or more columns wider than the second widest in its column stay compact. Dropping the call layer drops the list slots inside its arguments.
87
+
88
+ A standalone multi-line scalar literal array in a `const` declaration with `as const` is a `literal-array-rows` table instead: align the commas that already exist after its one-item-per-line rows. Do not add a missing final comma. Compact arrays, arrays without `as const`, arrays with mixed expressions and arrays whose brackets share an item line remain outside this kind.
89
+
90
+ ```ts
91
+ const STATES = new Set(["PASS", "FIXABLE", "SUGGEST", "REFUSE" ]) ;
92
+ const PARSES = new Set(["ok" , "changed", "skipped", "not-run"]) ;
93
+
94
+ expect(states(key)).toEqual(["PASS", "FIXABLE"]) ;
95
+ expect(states(key)).toEqual(["ok" , "changed"]) ;
96
+ ```
97
+
98
+ **Authoring-only vertical form.** The raw authoring aligner may take a row vertical when one call argument is 20 or
99
+ more columns wider than the second widest in its column (the import outlier rule B). This legacy authoring transform
100
+ inserts line breaks and is outside public Align, which refuses it because public alignment preserves physical lines.
101
+ The row goes to its own multiline statement — arguments one per line under the opening `(`, each non-last argument
102
+ comma right after the widest argument, and the closing `);` on the statement's indentation — while the remaining rows
103
+ keep the call layer. The vertical row keeps the table's `=` column and leaves the horizontal table on the next run.
104
+
105
+ ```ts
106
+ const open = charTokenAt(
107
+ source ,
108
+ skipTrivia(source.text, node.expression.getEnd()),
109
+ );
110
+ const close = charTokenAt(source, node.getEnd() - 1);
111
+ ```
112
+
113
+ **Subscript cells.** When a declaration's value is a subscript read (`chars[index]!`, `chars[index + 1]` — a leading `!` is fine), the closing `]` sits right after the widest index in the column. The slot is kept only when every row has the subscript on the same object and no index is an outlier (import rule B).
114
+
115
+ ```ts
116
+ const value = chars[index ]! ;
117
+ const next = chars[index + 1] ;
118
+ ```
119
+
120
+ **Guard rows.** Consecutive one-line `if (…) { …; continue; }` statements whose block ends in `continue` and has no `else` form a table: the condition's closing `)` hugs the widest condition, the first statement's `;` hugs the widest first statement, and the trailing `continue` shares one column. The statements in between stay as written.
121
+
122
+ ```ts
123
+ if (value === "/" && next === "/") { state = "line" ; chars[index] = chars[index + 1] = " "; index += 1; continue; }
124
+ if (value === "`" ) { state = "template"; chars[index] = " "; continue; }
125
+ ```
126
+
127
+ **Member calls.** The final property separator is its own column: `receiver | . | method | ( | arguments | )`.
128
+ Nested receiver expressions stay in the receiver cell. Bare function calls have no member separator; punctuation
129
+ inside argument expressions has its own role and is not part of this dot column.
130
+
131
+ ```ts
132
+ const rows = Array.from (items ) ;
133
+ const over = rows .filter(isVisible) ;
134
+ ```
135
+
136
+ **Return guards.** `return-guards` aligns the start of `return`, leaving the condition's spelling unchanged.
137
+ Three or more supported guards in one immediate block form a scoped comparison region; see
138
+ [column-sharing regions](readability-contract.md#column-sharing-regions) for region boundaries. Intervening
139
+ statements stay in place, and a final unconditional return keeps its own role. This is a column boundary,
140
+ not an instruction to insert blank lines or move guards above the values they inspect.
141
+
142
+ ```ts
143
+ function read(value: unknown) {
144
+ if (!value) return undefined;
145
+ const declared = value;
146
+
147
+ if (declared === undefined) return undefined;
148
+ const unwrapped = declared;
149
+ if (typeof unwrapped !== 'string') return undefined;
150
+ return unwrapped;
151
+ }
152
+ ```
153
+
154
+ **Logical chains.** An existing multiline homogeneous chain is read as `operator | one space | optional ! | operand`.
155
+ The `||`/`&&` cell is two characters and the optional negation cell is one character. The first operand has a virtual
156
+ prefix only while authoring the shape; public Align neither inserts that prefix nor moves operators between lines.
157
+ Comparison operators `!=` and `!==` stay indivisible, while double negation (`!!value`) is opaque and unsupported.
158
+
159
+ ```ts
160
+ if (
161
+ first
162
+ || !second
163
+ || third !== fourth
164
+ ) return;
165
+ ```
166
+
167
+ **Adjacent `if` headers.** Three or more adjacent braced headers align their existing `)` and `{` boundaries even when
168
+ a body spans several lines. Three or more adjacent unbraced-return headers align `)` and `return` instead. Blank lines,
169
+ interstitial comments and a change between the two families split the run. No alignment path inserts braces.
170
+
171
+ ```ts
172
+ if (short ) { run(); }
173
+ if (longest) {
174
+ runMore();
175
+ }
176
+ if (third ) { finish(); }
177
+
178
+ if (missing ) return undefined;
179
+ if (typeof value !== "x") return undefined;
180
+ if (invalid ) return undefined;
181
+ ```
182
+
183
+ ## Property headers
184
+
185
+ A same-role property table keeps the header comparison `name | :` when a value continues onto later physical lines.
186
+ Shorthand rows stay in the property run but do not acquire punctuation. A multiline terminal comma
187
+ belongs to that value and does not widen the comma column of unrelated short properties. Consecutive runs of at least
188
+ three ordinary one-line explicit properties compute independent `property-colon`, `property-value` and
189
+ `property-comma` widths. Other explicit and multiline headers share a fallback family; shorthand contributes only an
190
+ existing comma gap. The first source-ordered family uses unsuffixed axes, and later families use `2`, `3`, and higher
191
+ suffixes. Only the ordinary three-or-more family has a value-start axis.
192
+
193
+ ```ts
194
+ const options = {
195
+ short : one,
196
+ expanded: build(
197
+ first,
198
+ second,
199
+ ),
200
+ shorthand,
201
+ last : two,
202
+ };
203
+ ```
204
+
205
+ ## Registration objects · enum members · case rows
206
+
207
+ ```ts
208
+ const rails = {
209
+ dashboard : new DashboardRail(get, synthetic),
210
+ workflow : new WorkflowRail(get, synthetic),
211
+ context : new ContextRail(get, synthetic),
212
+ };
213
+
214
+ enum Level {
215
+ Low = 1 ,
216
+ Middle = 22 ,
217
+ High = 333 ,
218
+ }
219
+ ```
220
+
221
+ ```ts
222
+ switch (input) {
223
+ case "a" : return "first";
224
+ case "bb" : return "second";
225
+ case "ccc": return "third";
226
+ default: return "rest";
227
+ }
228
+ ```
229
+
230
+ ## Dependency interface
231
+
232
+ ```ts
233
+ export interface ExampleDependencies {
234
+ // Run — `string` is the widest Promise content in this section.
235
+ runFirst : ( value : FirstInput ) => Promise< void >; // first run
236
+ runSecond : ( value : SecondInput ) => Promise<string>; // second run
237
+
238
+ // List — `SessionList` is the widest value in this section.
239
+ listSessions : () => Promise<SessionList>; // list sessions
240
+ listThreads : () => Promise<ThreadList >; // list threads
241
+
242
+ // Select — a single row, so its own actual value is the minimum width.
243
+ selectThread : ( thread : ThreadList ) => Promise<ThreadSelection>; // select a thread
244
+ }
245
+ ```
246
+
247
+ `Promise< >` width is computed per role section, but trailing `//` notes share one column across the same interface declaration table, using only the outer space after `;` without widening the type cell.
248
+
249
+ ## Execution dependencies
250
+
251
+ If the longest argument is `threads`, the inner width of `( )` is 7. Only `args` and `value` are centered within it. Empty arguments stay attached as `()`, with the padding up to `=>` outside the parentheses.
252
+
253
+ ```ts
254
+ runApp : async (options) => action(options),
255
+ runAuth : async ( args ) => action(args),
256
+ listSessions : async () => action(),
257
+ selectNativeThread : async (threads) => action(threads),
258
+ writeOut : ( value ) => action(value),
259
+ ```
260
+
261
+ When a one-line body ends with a call, align only the call's start column and do not pad after the value.
262
+
263
+ ```ts
264
+ runShort : async (value) => { const { run } = await import("module"); return run(value); },
265
+ runLong : async (value) => { const { run } = await import("module"); return run(value, "extra"); },
266
+ ```
267
+
268
+ ## Object-array column specification
269
+
270
+ Do not let omitted presentational defaults give rows different cell counts. Keep the column specification and data rows separate.
271
+
272
+ ```ts
273
+ const columns = [
274
+ { heading : "SOURCE" , minWidth : 7, weight : 0, align : "left" },
275
+ { heading : "DISTRIBUTION", minWidth : 4, weight : 1, align : "left" },
276
+ { heading : "SIZE" , minWidth : 6, weight : 0, align : "right" },
277
+ { heading : "SHARE" , minWidth : 6, weight : 0, align : "right" },
278
+ ] as const;
279
+ const rows = sources.map(source => [
280
+ source.code,
281
+ source.distribution,
282
+ source.size,
283
+ source.share,
284
+ ]);
285
+
286
+ return renderTable({ columns, rows }, width);
287
+ ```
288
+
289
+ In this table `{`, the four `:`, the three property-separating `,` and `}` must each share a column. If omitting a property is a real contract difference, do not fill in the default; move that row to a separate table.
290
+
291
+ ## Registration objects and local computations
292
+
293
+ ```ts
294
+ const rails = {
295
+ dashboard : new DashboardRail(get, synthetic),
296
+ workflow : new WorkflowRail (get, synthetic),
297
+ context : new ContextRail (get, synthetic),
298
+ };
299
+
300
+ const widths = columnWidths(width);
301
+ const left = (widths[0] ?? width) - 2;
302
+ const middle = (widths[1] ?? width) - 2;
303
+ const right = (widths[2] ?? width) - 2;
304
+ ```
305
+
306
+ For registration objects measure `:`, `new` and the argument start; for declaration blocks measure `=` and the expression start. Keep different required constructor arguments, and do not cram several registration entries into one row.
307
+
308
+ ## Verification
309
+
310
+ Do not mix role sections in one run; check each line range separately.
311
+ The following measurement command is for the development skill, where `<SKILL>/engine` contains the source helpers.
312
+ Public plugin checks and fixes use the bundled launcher described in its SKILL.md.
313
+
314
+ ```bash
315
+ bun <SKILL>/engine/03_measure-layout.ts src/cli.ts \
316
+ --lines 52-62 \
317
+ --tokens ':#1|(#1|)#1|=>' \
318
+ --center-cell '(#1' \
319
+ --compact-before '}#2'
320
+ ```
321
+
322
+ If the result is not `center(()=ok`, existing spaces were reused as width or the value is not centered within the minimum cell.
323
+
324
+ ## Function map
325
+
326
+ Put a reading map of the file's functions at the top of a feature implementation file. `09_function-map.ts` checks drift between the map and the declarations.
327
+
328
+ ```ts
329
+ // GROUP | FUNCTION | INPUT | RETURN | CALLS | ROLE
330
+ // RUN | runCli | args, dependencies | Promise | runBuildCommand | command dispatch
331
+ // BUILD | runBuildCommand | args, dependencies | Promise | writeSessions | build run
332
+ ```
333
+
334
+ GROUP is the role section in the file (RUN, LIST, STATE, ...), INPUT lists parameter names, and CALLS lists call targets in the file. Check drift with `09_function-map.ts --file <target file>`, and generate a skeleton with `--scaffold`.
@@ -0,0 +1,80 @@
1
+ import { lstatSync, readFileSync, realpathSync } from "node:fs" ;
2
+ import { dirname, join, resolve } from "node:path" ;
3
+
4
+ const UPSTREAM = /^(?:https:\/\/github\.com\/|git@github\.com:)?Woo-JongHo\/boxbox(?:\.git)?\/?$/iu;
5
+
6
+ /** Check before repair copies can erase evidence of a foreign or shared catalog. */
7
+ export function assertClaudeCatalog(root, metadata, allowMissing = false) {
8
+ const directory = join(root , ".claude-plugin" ) ;
9
+ const path = join(directory, "marketplace.json") ;
10
+ for (const [target, isDirectory] of [[directory, true], [path, false]]) {
11
+ let stat;
12
+ try { stat = lstatSync(target); } catch (error) {
13
+ if (allowMissing && error.code === "ENOENT") return;
14
+ throw error;
15
+ }
16
+ if (stat.isSymbolicLink() || (isDirectory ? !stat.isDirectory() : !stat.isFile())) throw new Error(`Refusing non-regular Claude marketplace catalog: ${target}`);
17
+ }
18
+ const catalog = JSON.parse(readFileSync(path, "utf8"));
19
+ if (catalog.name !== metadata.marketplace || !Array.isArray(catalog.plugins) || catalog.plugins.length !== 1 || catalog.plugins[0].name !== metadata.name || catalog.plugins[0].source !== "./plugin") throw new Error("Refusing a foreign or shared Claude marketplace catalog.");
20
+ }
21
+
22
+ /** Bind updates to the installer-owned local marketplace, never a same-named foreign source. */
23
+ export function claudeRegistration(root, metadata, invoke) {
24
+ const intended = realpathSync(root) ;
25
+ const id = `${metadata.name}@${metadata.marketplace}` ;
26
+ function json(args) {
27
+ const value = JSON.parse(invoke(args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).stdout);
28
+ if (!Array.isArray(value)) throw new Error(`Unexpected Claude list schema for ${args.join(" ")}`);
29
+ return value;
30
+ }
31
+ function marketplace(exact) {
32
+ const entries = json(["plugin", "marketplace", "list", "--json"]).filter(value => value.name === metadata.marketplace);
33
+ if (!entries.length && !exact) return;
34
+ if (!exact && entries.length === 1 && entries[0].source !== "directory") {
35
+ const shared = json(["plugin", "list", "--json"]).some(value => typeof value.id === "string" && value.id.endsWith(`@${metadata.marketplace}`) && value.id !== id);
36
+ if (UPSTREAM.test(entries[0].repo ?? entries[0].url ?? "") && !shared) throw new Error(`Claude marketplace ${metadata.marketplace} was added from this project's repository (${entries[0].source}), which has no engine. Remove it, then rerun the BoxBox installer:\n claude plugin uninstall ${id} --scope user\n claude plugin marketplace remove ${metadata.marketplace}`);
37
+ throw new Error(`A different Claude marketplace is already named ${metadata.marketplace}, or it has other plugins. Removing a marketplace also removes its plugins, so BoxBox will not suggest it. Inspect it with "claude plugin marketplace list" and decide before rerunning the installer.`);
38
+ }
39
+ if (entries.length !== 1 || entries[0].source !== "directory" || typeof entries[0].path !== "string" || typeof entries[0].installLocation !== "string") throw new Error("Claude marketplace is missing, ambiguous or not an owned local directory.");
40
+ const source = realpathSync(entries[0].path);
41
+ if (realpathSync(entries[0].installLocation) !== source) throw new Error("Claude marketplace installLocation does not match its source.");
42
+ if (exact && source !== intended) throw new Error("Claude marketplace did not select the intended installation root.");
43
+ if (dirname(source) !== dirname(intended)) throw new Error("Refusing to replace a foreign Claude marketplace.");
44
+ const bundle = JSON.parse(readFileSync(join(source, "bundle.json"), "utf8")) ;
45
+ const manifest = JSON.parse(readFileSync(join(source, "plugin/.claude-plugin/plugin.json"), "utf8")) ;
46
+ if (bundle.name !== metadata.name || bundle.host !== "claude" || bundle.marketplace !== metadata.marketplace || manifest.name !== metadata.name || manifest.version !== bundle.version || source !== realpathSync(resolve(dirname(intended), bundle.version))) throw new Error("Claude marketplace ownership could not be established.");
47
+ assertClaudeCatalog(source, metadata);
48
+ return source;
49
+ }
50
+ function plugin(required) {
51
+ const all = json(["plugin", "list", "--json"]);
52
+ if (all.some(value => typeof value.id === "string" && value.id.endsWith(`@${metadata.marketplace}`) && value.id !== id)) throw new Error("Refusing to replace a Claude marketplace shared with another plugin.");
53
+ const entries = all.filter(value => value.id === id);
54
+ if (!entries.length && !required) return;
55
+ if (entries.length !== 1 || entries[0].scope !== "user" || entries[0].enabled !== true || typeof entries[0].installPath !== "string") throw new Error("Claude plugin identity is missing, ambiguous, disabled or outside user scope.");
56
+ return entries[0];
57
+ }
58
+ function preflight() {
59
+ const help = invoke(["plugin", "update", "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).stdout;
60
+ if (!/(?:^|\s)--scope(?:[\s=,]|$)/u.test(help)) throw new Error("Claude plugin update does not advertise --scope support; upgrade Claude before installation.");
61
+ const source = marketplace(false);
62
+ if (plugin(false) && !source) throw new Error("Existing Claude plugin has no owned marketplace registration.");
63
+ }
64
+ function verify() {
65
+ marketplace(true);
66
+ let installed = plugin(true);
67
+ if (installed.version !== metadata.version) {
68
+ invoke(["plugin", "update", id, "--scope", "user"]);
69
+ marketplace(true);
70
+ installed = plugin(true);
71
+ }
72
+ if (installed.version !== metadata.version) throw new Error("Claude retained the wrong plugin version after registration.");
73
+ const cached = realpathSync(installed.installPath ) ;
74
+ const manifest = JSON.parse (readFileSync(join(cached, ".claude-plugin/plugin.json"), "utf8")) ;
75
+ const runtime = JSON.parse (readFileSync(join(cached, "runtime.json"), "utf8") ) ;
76
+ const expected = JSON.parse (readFileSync(join(intended, "plugin/runtime.json"), "utf8") ) ;
77
+ if (manifest.name !== metadata.name || manifest.version !== metadata.version || realpathSync(runtime.cli) !== realpathSync(expected.cli) || realpathSync(runtime.node) !== realpathSync(expected.node)) throw new Error("Claude cached plugin identity or runtime does not match the intended installation.");
78
+ }
79
+ return { preflight, marketplace: () => marketplace(true), verify };
80
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "boxbox-plugins",
3
+ "interface": { "displayName": "BoxBox" },
4
+ "plugins": [
5
+ {
6
+ "name": "boxbox",
7
+ "source": { "source": "local", "path": "./plugin" },
8
+ "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
9
+ "category": "Coding"
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "boxbox",
3
+ "npmName": "boxbox-plugin",
4
+ "version": "0.2.7",
5
+ "displayName": "BoxBox",
6
+ "marketplace": "boxbox-plugins",
7
+ "engineSha256": "ceafa7995f6e2aed5c39b3ed0f2b9190634c933f866e5342ea72e12c5d7172b3",
8
+ "host": "codex"
9
+ }
@@ -0,0 +1,80 @@
1
+ import { lstatSync, readFileSync, realpathSync } from "node:fs" ;
2
+ import { dirname, join, resolve } from "node:path" ;
3
+
4
+ const UPSTREAM = /^(?:https:\/\/github\.com\/|git@github\.com:)?Woo-JongHo\/boxbox(?:\.git)?\/?$/iu;
5
+
6
+ /** Check before repair copies can erase evidence of a foreign or shared catalog. */
7
+ export function assertClaudeCatalog(root, metadata, allowMissing = false) {
8
+ const directory = join(root , ".claude-plugin" ) ;
9
+ const path = join(directory, "marketplace.json") ;
10
+ for (const [target, isDirectory] of [[directory, true], [path, false]]) {
11
+ let stat;
12
+ try { stat = lstatSync(target); } catch (error) {
13
+ if (allowMissing && error.code === "ENOENT") return;
14
+ throw error;
15
+ }
16
+ if (stat.isSymbolicLink() || (isDirectory ? !stat.isDirectory() : !stat.isFile())) throw new Error(`Refusing non-regular Claude marketplace catalog: ${target}`);
17
+ }
18
+ const catalog = JSON.parse(readFileSync(path, "utf8"));
19
+ if (catalog.name !== metadata.marketplace || !Array.isArray(catalog.plugins) || catalog.plugins.length !== 1 || catalog.plugins[0].name !== metadata.name || catalog.plugins[0].source !== "./plugin") throw new Error("Refusing a foreign or shared Claude marketplace catalog.");
20
+ }
21
+
22
+ /** Bind updates to the installer-owned local marketplace, never a same-named foreign source. */
23
+ export function claudeRegistration(root, metadata, invoke) {
24
+ const intended = realpathSync(root) ;
25
+ const id = `${metadata.name}@${metadata.marketplace}` ;
26
+ function json(args) {
27
+ const value = JSON.parse(invoke(args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).stdout);
28
+ if (!Array.isArray(value)) throw new Error(`Unexpected Claude list schema for ${args.join(" ")}`);
29
+ return value;
30
+ }
31
+ function marketplace(exact) {
32
+ const entries = json(["plugin", "marketplace", "list", "--json"]).filter(value => value.name === metadata.marketplace);
33
+ if (!entries.length && !exact) return;
34
+ if (!exact && entries.length === 1 && entries[0].source !== "directory") {
35
+ const shared = json(["plugin", "list", "--json"]).some(value => typeof value.id === "string" && value.id.endsWith(`@${metadata.marketplace}`) && value.id !== id);
36
+ if (UPSTREAM.test(entries[0].repo ?? entries[0].url ?? "") && !shared) throw new Error(`Claude marketplace ${metadata.marketplace} was added from this project's repository (${entries[0].source}), which has no engine. Remove it, then rerun the BoxBox installer:\n claude plugin uninstall ${id} --scope user\n claude plugin marketplace remove ${metadata.marketplace}`);
37
+ throw new Error(`A different Claude marketplace is already named ${metadata.marketplace}, or it has other plugins. Removing a marketplace also removes its plugins, so BoxBox will not suggest it. Inspect it with "claude plugin marketplace list" and decide before rerunning the installer.`);
38
+ }
39
+ if (entries.length !== 1 || entries[0].source !== "directory" || typeof entries[0].path !== "string" || typeof entries[0].installLocation !== "string") throw new Error("Claude marketplace is missing, ambiguous or not an owned local directory.");
40
+ const source = realpathSync(entries[0].path);
41
+ if (realpathSync(entries[0].installLocation) !== source) throw new Error("Claude marketplace installLocation does not match its source.");
42
+ if (exact && source !== intended) throw new Error("Claude marketplace did not select the intended installation root.");
43
+ if (dirname(source) !== dirname(intended)) throw new Error("Refusing to replace a foreign Claude marketplace.");
44
+ const bundle = JSON.parse(readFileSync(join(source, "bundle.json"), "utf8")) ;
45
+ const manifest = JSON.parse(readFileSync(join(source, "plugin/.claude-plugin/plugin.json"), "utf8")) ;
46
+ if (bundle.name !== metadata.name || bundle.host !== "claude" || bundle.marketplace !== metadata.marketplace || manifest.name !== metadata.name || manifest.version !== bundle.version || source !== realpathSync(resolve(dirname(intended), bundle.version))) throw new Error("Claude marketplace ownership could not be established.");
47
+ assertClaudeCatalog(source, metadata);
48
+ return source;
49
+ }
50
+ function plugin(required) {
51
+ const all = json(["plugin", "list", "--json"]);
52
+ if (all.some(value => typeof value.id === "string" && value.id.endsWith(`@${metadata.marketplace}`) && value.id !== id)) throw new Error("Refusing to replace a Claude marketplace shared with another plugin.");
53
+ const entries = all.filter(value => value.id === id);
54
+ if (!entries.length && !required) return;
55
+ if (entries.length !== 1 || entries[0].scope !== "user" || entries[0].enabled !== true || typeof entries[0].installPath !== "string") throw new Error("Claude plugin identity is missing, ambiguous, disabled or outside user scope.");
56
+ return entries[0];
57
+ }
58
+ function preflight() {
59
+ const help = invoke(["plugin", "update", "--help"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }).stdout;
60
+ if (!/(?:^|\s)--scope(?:[\s=,]|$)/u.test(help)) throw new Error("Claude plugin update does not advertise --scope support; upgrade Claude before installation.");
61
+ const source = marketplace(false);
62
+ if (plugin(false) && !source) throw new Error("Existing Claude plugin has no owned marketplace registration.");
63
+ }
64
+ function verify() {
65
+ marketplace(true);
66
+ let installed = plugin(true);
67
+ if (installed.version !== metadata.version) {
68
+ invoke(["plugin", "update", id, "--scope", "user"]);
69
+ marketplace(true);
70
+ installed = plugin(true);
71
+ }
72
+ if (installed.version !== metadata.version) throw new Error("Claude retained the wrong plugin version after registration.");
73
+ const cached = realpathSync(installed.installPath ) ;
74
+ const manifest = JSON.parse (readFileSync(join(cached, ".claude-plugin/plugin.json"), "utf8")) ;
75
+ const runtime = JSON.parse (readFileSync(join(cached, "runtime.json"), "utf8") ) ;
76
+ const expected = JSON.parse (readFileSync(join(intended, "plugin/runtime.json"), "utf8") ) ;
77
+ if (manifest.name !== metadata.name || manifest.version !== metadata.version || realpathSync(runtime.cli) !== realpathSync(expected.cli) || realpathSync(runtime.node) !== realpathSync(expected.node)) throw new Error("Claude cached plugin identity or runtime does not match the intended installation.");
78
+ }
79
+ return { preflight, marketplace: () => marketplace(true), verify };
80
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs" ;
2
+ import { dirname, resolve } from "node:path" ;
3
+
4
+ function registrationSource(entry) {
5
+ const value = entry?.root ?? entry?.marketplaceSource?.source;
6
+ return typeof value === "string" ? resolve(value) : undefined;
7
+ }
8
+
9
+ export function codexRegistration(root, metadata, invoke) {
10
+ const selector = `${metadata.name}@${metadata.marketplace}`;
11
+
12
+ function json(args) {
13
+ const output = invoke(args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).stdout;
14
+ return JSON.parse(output);
15
+ }
16
+
17
+ function inspect() {
18
+ const marketplaces = json(["plugin", "marketplace", "list", "--json"]);
19
+ if (!Array.isArray(marketplaces?.marketplaces)) throw new Error("Unexpected Codex marketplace list schema.");
20
+ const matches = marketplaces.marketplaces.filter(entry => entry?.name === metadata.marketplace);
21
+ if (matches.length > 1) throw new Error("Multiple Codex BoxBox marketplace registrations were found; remove the duplicates before installation.");
22
+ if (matches.length === 0) return { previous: undefined, installed: false };
23
+ const previous = registrationSource(matches[0]);
24
+ if (!previous) throw new Error("Existing Codex BoxBox marketplace has no local source path.");
25
+ if (previous !== resolve(root)) {
26
+ if (!existsSync(previous) || dirname(realpathSync(previous)) !== realpathSync(dirname(root))) throw new Error("Existing Codex BoxBox marketplace is not owned by this installer; remove it manually before installation.");
27
+ const bundle = JSON.parse(readFileSync(resolve(previous, "bundle.json"), "utf8"));
28
+ if (bundle.host !== "codex" || bundle.name !== metadata.name || bundle.marketplace !== metadata.marketplace) throw new Error("Existing Codex BoxBox marketplace ownership metadata does not match this installer.");
29
+ }
30
+ const plugins = json(["plugin", "list", "--json"]);
31
+ if (!Array.isArray(plugins?.installed)) throw new Error("Unexpected Codex plugin list schema.");
32
+ return { previous, installed: plugins.installed.some(entry => entry?.pluginId === selector && entry?.installed === true) };
33
+ }
34
+
35
+ let state;
36
+ return {
37
+ preflight() { state = inspect(); },
38
+ transition() {
39
+ state ??= inspect();
40
+ if (state.previous === resolve(root)) {
41
+ if (!state.installed) invoke(["plugin", "add", selector]);
42
+ return;
43
+ }
44
+ if (state.previous) {
45
+ if (state.installed) invoke(["plugin", "remove", selector]);
46
+ invoke(["plugin", "marketplace", "remove", metadata.marketplace]);
47
+ }
48
+ try {
49
+ invoke(["plugin", "marketplace", "add", root]);
50
+ invoke(["plugin", "add", selector]);
51
+ } catch (error) {
52
+ if (state.previous) {
53
+ try {
54
+ invoke(["plugin", "marketplace", "add", state.previous]);
55
+ if (state.installed) invoke(["plugin", "add", selector]);
56
+ } catch { /* Preserve the original transition error; the installer receipt names manual recovery. */ }
57
+ }
58
+ throw error;
59
+ }
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,46 @@
1
+ import {
2
+ lstatSync ,
3
+ readFileSync ,
4
+ realpathSync ,
5
+ renameSync ,
6
+ unlinkSync ,
7
+ writeFileSync ,
8
+ } from "node:fs" ;
9
+ import { join } from "node:path" ;
10
+
11
+ const FILE = ".boxbox-install-state.json" ;
12
+ const PHASES = ["prepared", "host-registration-started", "verified"] ;
13
+
14
+ /** A receipt authorizes cleanup only before any host command can change registration. */
15
+ export function installationState(root, metadata) {
16
+ const path = join(root, FILE) ;
17
+ const identity = { schema: 1, root: realpathSync(root), name: metadata.name, version: metadata.version, host: metadata.host, marketplace: metadata.marketplace } ;
18
+ function read() {
19
+ let stat;
20
+ try { stat = lstatSync(path); } catch (error) {
21
+ if (error.code === "ENOENT") return undefined;
22
+ throw error;
23
+ }
24
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Reserved installation receipt is not a regular file: ${path}`);
25
+ const value = JSON.parse(readFileSync(path, "utf8"));
26
+ if (!value || Object.entries(identity).some(([key, expected]) => value[key] !== expected) || !PHASES.includes(value.phase)) throw new Error(`Invalid installation receipt: ${path}`);
27
+ return value;
28
+ }
29
+ function create() {
30
+ writeFileSync(path, JSON.stringify({ ...identity, phase: "prepared" }) + "\n", { flag: "wx", mode: 0o600 });
31
+ }
32
+ function advance(phase) {
33
+ const previous = read();
34
+ if (!previous || !["host-registration-started", "verified"].includes(phase) || previous.phase !== "prepared" && !(previous.phase === "host-registration-started" && phase === "verified")) throw new Error(`Invalid installation phase transition at ${path}`);
35
+ const temporary = `${path}.${process.pid}.tmp`;
36
+ writeFileSync(temporary, JSON.stringify({ ...identity, phase }) + "\n", { flag: "wx", mode: 0o600 });
37
+ try { renameSync(temporary, path); } finally {
38
+ try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; }
39
+ }
40
+ }
41
+ function remove() {
42
+ if (!read()) throw new Error(`Missing installation receipt: ${path}`);
43
+ unlinkSync(path);
44
+ }
45
+ return { path, read, create, advance, remove };
46
+ }