boxbox-plugin 0.0.0-stage → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -2
- package/bin/boxbox.mjs +121 -0
- package/package.json +17 -5
- package/payload/bundle.json +8 -0
- package/payload/claude/.claude-plugin/marketplace.json +15 -0
- package/payload/claude/bundle.json +9 -0
- package/payload/claude/claude-registration.mjs +80 -0
- package/payload/claude/codex-registration.mjs +62 -0
- package/payload/claude/install-state.mjs +46 -0
- package/payload/claude/install.mjs +150 -0
- package/payload/claude/packages/boxbox-0.2.8.tgz +0 -0
- package/payload/claude/platform.mjs +107 -0
- package/payload/claude/plugin/.claude-plugin/plugin.json +18 -0
- package/payload/claude/plugin/agents/readability-inspector.md +23 -0
- package/payload/claude/plugin/hooks/hooks.json +54 -0
- package/payload/claude/plugin/scripts/boxbox.mjs +42 -0
- package/payload/claude/plugin/skills/boxbox/SKILL.md +188 -0
- package/payload/claude/plugin/skills/boxbox/references/charter.md +72 -0
- package/payload/claude/plugin/skills/boxbox/references/grammar.md +147 -0
- package/payload/claude/plugin/skills/boxbox/references/readability-contract.md +311 -0
- package/payload/claude/plugin/skills/boxbox/references/typescript-grid-template.md +334 -0
- package/payload/claude-registration.mjs +80 -0
- package/payload/codex/.agents/plugins/marketplace.json +12 -0
- package/payload/codex/bundle.json +9 -0
- package/payload/codex/claude-registration.mjs +80 -0
- package/payload/codex/codex-registration.mjs +62 -0
- package/payload/codex/install-state.mjs +46 -0
- package/payload/codex/install.mjs +150 -0
- package/payload/codex/packages/boxbox-0.2.8.tgz +0 -0
- package/payload/codex/platform.mjs +107 -0
- package/payload/codex/plugin/.codex-plugin/plugin.json +29 -0
- package/payload/codex/plugin/hooks/hooks.json +42 -0
- package/payload/codex/plugin/scripts/boxbox.mjs +42 -0
- package/payload/codex/plugin/skills/boxbox/SKILL.md +188 -0
- package/payload/codex/plugin/skills/boxbox/references/charter.md +72 -0
- package/payload/codex/plugin/skills/boxbox/references/grammar.md +147 -0
- package/payload/codex/plugin/skills/boxbox/references/readability-contract.md +311 -0
- package/payload/codex/plugin/skills/boxbox/references/typescript-grid-template.md +334 -0
- package/payload/codex-registration.mjs +62 -0
- package/payload/install-state.mjs +46 -0
- package/payload/platform.mjs +107 -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,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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process" ;
|
|
2
|
+
import { existsSync } from "node:fs" ;
|
|
3
|
+
import { homedir } from "node:os" ;
|
|
4
|
+
import { posix, win32 } from "node:path" ;
|
|
5
|
+
|
|
6
|
+
export function supportedNode(version) {
|
|
7
|
+
const [major, minor, patch] = version.split(".").map(Number);
|
|
8
|
+
return major > 22 || (major === 22 && (minor > 22 || (minor === 22 && patch >= 3)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function installationRoot(version, host, { windows = process.platform === "win32", environment = process.env, home = homedir() } = {}) {
|
|
12
|
+
if (!["codex", "claude"].includes(host)) throw new Error("Expected codex or claude installation host.");
|
|
13
|
+
if (windows) {
|
|
14
|
+
if (!environment.LOCALAPPDATA || !win32.isAbsolute(environment.LOCALAPPDATA)) throw new Error("LOCALAPPDATA must point to an absolute user data directory on Windows.");
|
|
15
|
+
return win32.join(environment.LOCALAPPDATA, "BoxBox", host === "codex" ? "Codex" : "Claude", version);
|
|
16
|
+
}
|
|
17
|
+
const data = environment.XDG_DATA_HOME || posix.join(home, ".local/share");
|
|
18
|
+
if (!posix.isAbsolute(data)) throw new Error("XDG_DATA_HOME must be an absolute directory when set.");
|
|
19
|
+
return posix.join(data, "boxbox", host, version);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function identifiesCodex(executable, probe) {
|
|
23
|
+
try {
|
|
24
|
+
const result = probe(executable, ["--version"], {
|
|
25
|
+
encoding: "utf8", shell: false, windowsHide: true,
|
|
26
|
+
timeout: 3_000, maxBuffer: 4_096, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return result.status === 0 && !result.error && !result.signal
|
|
30
|
+
&& /^codex-cli \d+\.\d+\.\d+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?$/iu.test(String(result.stdout ?? "").trim());
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve standard Windows npm shims without a shell, and skip unrelated native programs named codex.exe. */
|
|
37
|
+
export function findCommand(name, { windows = process.platform === "win32", node = process.execPath, path = process.env.PATH || "", exists = existsSync, probe = spawnSync } = {}) {
|
|
38
|
+
const paths = windows ? win32 : posix;
|
|
39
|
+
const directories = [...path.split(windows ? ";" : ":").filter(Boolean), paths.dirname(node)];
|
|
40
|
+
for (const directory of directories) {
|
|
41
|
+
if (windows) {
|
|
42
|
+
if (name === "npm" || name === "codex") {
|
|
43
|
+
const script = paths.join(directory, name === "npm" ? "node_modules/npm/bin/npm-cli.js" : "node_modules/@openai/codex/bin/codex.js");
|
|
44
|
+
if (exists(script)) return { executable: node, args: [script] };
|
|
45
|
+
}
|
|
46
|
+
if (name === "claude") {
|
|
47
|
+
const packaged = paths.join(directory, "node_modules/@anthropic-ai/claude-code/bin/claude.exe");
|
|
48
|
+
if (exists(packaged)) return { executable: packaged, args: [] };
|
|
49
|
+
}
|
|
50
|
+
const native = paths.join(directory, `${name}.exe`);
|
|
51
|
+
if (name !== "npm" && exists(native) && (name !== "codex" || identifiesCodex(native, probe))) return { executable: native, args: [] };
|
|
52
|
+
} else {
|
|
53
|
+
const executable = paths.join(directory, name);
|
|
54
|
+
if (exists(executable)) return { executable, args: [] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function shellQuote(value) {
|
|
61
|
+
return "'" + value.replaceAll("'", "'\\''") + "'";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function powershellQuote(value) {
|
|
65
|
+
return "'" + value.replaceAll(/['\u2018\u2019\u201A\u201B]/gu, quote => quote + quote) + "'";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function engineCommand(node, launcher, windows, host = "codex") {
|
|
69
|
+
if (windows && host === "claude") {
|
|
70
|
+
const payload = Buffer.from(JSON.stringify([node, launcher]), "utf8").toString("base64");
|
|
71
|
+
return `node -e "const [n,l]=JSON.parse(Buffer.from('${payload}','base64').toString('utf8'));const r=require('node:child_process').spawnSync(n,[l,...process.argv.slice(1)],{stdio:'inherit'});if(r.error){console.error(r.error.message);process.exit(1)}process.exit(r.status??1)" --`;
|
|
72
|
+
}
|
|
73
|
+
return windows ? `& ${powershellQuote(node)} ${powershellQuote(launcher)}` : `${shellQuote(node)} ${shellQuote(launcher)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `boxbox` and `update` on the host's PATH. Claude Code adds an enabled plugin's `bin/` folder to the PATH of its
|
|
78
|
+
* shell, so the user can type `! update boxbox` in the prompt. `update` answers only `update boxbox` and refuses any
|
|
79
|
+
* other target, so it never stands in for another tool's command. The sh forms serve Git Bash and POSIX shells; the
|
|
80
|
+
* .cmd forms serve cmd.exe and PowerShell on Windows.
|
|
81
|
+
*/
|
|
82
|
+
export function pathLaunchers(node, launcher) {
|
|
83
|
+
const posix = path => path.replaceAll("\\", "/") ;
|
|
84
|
+
const refusal = "update: only \\`update boxbox\\` is provided here (BoxBox plugin)." ;
|
|
85
|
+
const updatePayload = Buffer.from(JSON.stringify([node, launcher, refusal]), "utf8").toString("base64") ;
|
|
86
|
+
return {
|
|
87
|
+
"bin/boxbox" : `#!/bin/sh\n# Generated by the BoxBox installer.\nexec ${shellQuote(posix(node))} ${shellQuote(posix(launcher))} "$@"\n`,
|
|
88
|
+
"bin/boxbox.cmd" : `@echo off\r\nrem Generated by the BoxBox installer.\r\n"${node}" "${launcher}" %*\r\n`,
|
|
89
|
+
"bin/update" : `#!/bin/sh\n# Generated by the BoxBox installer.\nif [ "\${1:-}" != boxbox ]; then printf '%s\\n' "${refusal}" >&2; exit 2; fi\nshift\nexec ${shellQuote(posix(node))} ${shellQuote(posix(launcher))} update "$@"\n`,
|
|
90
|
+
"bin/update.cmd" : `@echo off\r\nrem Generated by the BoxBox installer.\r\n"${node}" -e "const [n,l,m]=JSON.parse(Buffer.from('${updatePayload}','base64').toString('utf8'));const a=process.argv.slice(1);if((a.shift()||'').toLowerCase()!=='boxbox'){console.error(m);process.exit(2)}const r=require('node:child_process').spawnSync(n,[l,'update',...a],{stdio:'inherit'});if(r.error){console.error(r.error.message);process.exit(1)}process.exit(r.status??1)" -- %*\r\n`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function configureSkill(skill, command, previousCommand, windows) {
|
|
95
|
+
const marker = "@BOXBOX_ENGINE_COMMAND@";
|
|
96
|
+
const oldCommand = skill.includes(marker) ? marker : previousCommand;
|
|
97
|
+
if (!oldCommand || !skill.includes(oldCommand)) throw new Error("The installed skill command is missing. Rerun the BoxBox installer to repair the plugin.");
|
|
98
|
+
return skill.replaceAll(oldCommand, () => command).replaceAll(windows ? "```bash" : "```powershell", windows ? "```powershell" : "```bash");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function hookCommands(node, mode, host = "codex") {
|
|
102
|
+
if (host === "claude") return { command: node, args: ["${CLAUDE_PLUGIN_ROOT}/scripts/boxbox.mjs", "hook", mode] };
|
|
103
|
+
return {
|
|
104
|
+
command: `${shellQuote(node)} "\${PLUGIN_ROOT}/scripts/boxbox.mjs" hook ${mode}`,
|
|
105
|
+
commandWindows: `& ${powershellQuote(node)} "$env:PLUGIN_ROOT/scripts/boxbox.mjs" hook ${mode}`,
|
|
106
|
+
};
|
|
107
|
+
}
|