@subshell-ai/plugin-codex 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- //#region ../../plugin-api/dist/types-CSNepTNr.d.ts
1
+ //#region ../../plugin-api/dist/types-D6R-hjGN.d.ts
2
2
  //#region src/types.d.ts
3
3
  /**
4
4
  * The contract a Subshell plugin implements.
@@ -20,16 +20,28 @@
20
20
  * For humans: it groups and labels in the UI and filters the catalog. Code
21
21
  * branches on {@link PluginCapability} instead, so adding a type here touches
22
22
  * labels while adding a capability touches the launch pipeline.
23
+ *
24
+ * ONE exception, and it is structural rather than a branch on a label: a
25
+ * `network` plugin implements {@link NetworkPlugin} instead of
26
+ * {@link SubshellPlugin}, so the loader picks WHICH members to require by
27
+ * type. A harness drives a thing in a pane; a network plugin connects this
28
+ * host to one network. They share the store, the manifest and the capability
29
+ * vocabulary, and nothing else.
23
30
  */
24
- type PluginType = "agent-harness" | "terminal";
31
+ type PluginType = "agent-harness" | "terminal" | "network";
25
32
  /**
26
33
  * What a plugin can DO. The launch pipeline and the UI read this; neither
27
34
  * branches on {@link PluginType}.
35
+ *
36
+ * The union is shared across types and the applicable SUBSET is not, so
37
+ * {@link capabilityMismatches} takes the type: a harness claiming `publish`
38
+ * and a network plugin claiming `resume` are both refused at load, for the
39
+ * same reason a claimed-but-unimplemented capability is.
28
40
  */
29
- type PluginCapability = "mcp" | "resume" | "attention" | "settings";
30
- /** A harness profile as defined by the user (decoded JSON blobs). */
31
- interface ProfileDefinition {
32
- /** Human-friendly profile name */
41
+ type PluginCapability = "mcp" | "resume" | "attention" | "publish" | "supervise" | "guard" | "settings";
42
+ /** A harness preset as defined by the user (decoded JSON blobs). */
43
+ interface PresetDefinition {
44
+ /** Human-friendly preset name */
33
45
  name: string;
34
46
  /** Optional longer description */
35
47
  description?: string | null;
@@ -39,21 +51,21 @@ interface ProfileDefinition {
39
51
  flags: string[];
40
52
  /** Settings blob passed to the harness (e.g. claude --settings JSON) */
41
53
  settings: Record<string, unknown> | null;
42
- /** If true, only this profile's config sources apply (isolation) */
54
+ /** If true, only this preset's config sources apply (isolation) */
43
55
  configIsolation: boolean;
44
- /** If true, new subshells from this profile auto-restart on exit */
56
+ /** If true, new subshells from this preset auto-restart on exit */
45
57
  restartOnExit?: boolean;
46
58
  }
47
- /** Snapshot of a single field-level validation error on a profile. */
48
- interface ProfileValidationIssue {
59
+ /** Snapshot of a single field-level validation error on a preset. */
60
+ interface PresetValidationIssue {
49
61
  /** Field name (e.g. "name", "env", "settings") */
50
62
  field: string;
51
63
  /** Human-readable problem description */
52
64
  message: string;
53
65
  }
54
- interface ProfileValidationResult {
66
+ interface PresetValidationResult {
55
67
  valid: boolean;
56
- issues: ProfileValidationIssue[];
68
+ issues: PresetValidationIssue[];
57
69
  }
58
70
  /** Everything a plugin needs to build a launch command. */
59
71
  interface BuildCommandInput {
@@ -61,8 +73,8 @@ interface BuildCommandInput {
61
73
  binary: string;
62
74
  /** Working directory the harness runs in */
63
75
  cwd: string;
64
- /** The validated profile being used */
65
- profile: ProfileDefinition;
76
+ /** The validated preset being used */
77
+ preset: PresetDefinition;
66
78
  /** Subshell display name ("" = let the harness pick a default) */
67
79
  subshellName: string;
68
80
  /** Any additional CLI flags from route/request context */
@@ -169,12 +181,12 @@ interface McpRegistration {
169
181
  args?: string[];
170
182
  /**
171
183
  * Extra pane env the harness needs to discover the file (e.g. OPENCODE_CONFIG).
172
- * Consumed by the HOST, which bakes it into the pane env ahead of profile env;
184
+ * Consumed by the HOST, which bakes it into the pane env ahead of preset env;
173
185
  * plugins never read it back.
174
186
  */
175
187
  env?: Record<string, string>;
176
188
  }
177
- /** One copy-paste line shown in the profile editor for manual-setup harnesses. */
189
+ /** One copy-paste line shown in the preset editor for manual-setup harnesses. */
178
190
  interface McpSetupStep {
179
191
  /** What the user should do, and where the text goes */
180
192
  label: string;
@@ -202,10 +214,23 @@ interface SettingsField {
202
214
  label: string;
203
215
  /** Short description for the editor */
204
216
  description?: string;
205
- /** One of: string, boolean, number, select */
206
- type: "string" | "boolean" | "number" | "select";
217
+ /**
218
+ * One of: string, boolean, number, select, secret.
219
+ *
220
+ * `secret` is WRITE-ONLY and network-only: the host stores it through
221
+ * {@link PluginHost.secrets} rather than in the settings object, reads
222
+ * report only whether one is set, and a plugin never receives the value —
223
+ * it names the secret and the host hydrates it into a process it spawns
224
+ * ({@link SupervisedProcessSpec}). The preset editor does not render this
225
+ * type; a harness declaring one is a bug rather than a second secret store.
226
+ */
227
+ type: "string" | "boolean" | "number" | "select" | "secret";
207
228
  /** Choices when type === "select" */
208
229
  choices?: string[];
230
+ /** True when the field must be set before the plugin can act */
231
+ required?: boolean;
232
+ /** Placeholder / example, shown in the editor. Never a real credential. */
233
+ placeholder?: string;
209
234
  /** Default value when unset */
210
235
  default?: string | boolean | number;
211
236
  }
@@ -248,6 +273,86 @@ interface PluginHost {
248
273
  debug(message: string): void;
249
274
  warn(message: string): void;
250
275
  };
276
+ /**
277
+ * Runs a command to completion under the host's bounds and reports it.
278
+ *
279
+ * The one way a plugin executes anything. A plugin does not spawn: the host
280
+ * owns the deadline, the environment allowlist (the process's own
281
+ * environment holds the auth secret and the database path, and is never
282
+ * handed over), the PATH a service-run server would otherwise lack, and the
283
+ * output cap. Two refusals throw rather than returning a result, because
284
+ * both are bugs in the plugin rather than outcomes: `argv[0]` that is not an
285
+ * ABSOLUTE path (resolve it with {@link findBinary} — a bare name would
286
+ * resolve against a PATH the plugin cannot see), and `argv[0]` that is
287
+ * `sudo`. The server has no terminal to answer a password prompt, so a
288
+ * privileged command is a copy-paste instruction for the operator and never
289
+ * something this runs.
290
+ *
291
+ * Long-running processes are NOT this: a plugin that needs one describes it
292
+ * ({@link SupervisedProcessSpec}) and the host supervises it.
293
+ * @since apiVersion 2
294
+ */
295
+ run(argv: string[], opts?: RunOptions): Promise<RunResult>;
296
+ /**
297
+ * Write-only credential storage, scoped to this plugin.
298
+ *
299
+ * There is deliberately NO `get`. A plugin that could read a credential
300
+ * could put it in an argv (visible in `ps`), a log line or a hint string;
301
+ * every legitimate consumer is a process the HOST spawns, so the host
302
+ * hydrates the value itself from the name the plugin gives it. Values are
303
+ * stored 0600 under the host's data directory, never in the database and
304
+ * never in a settings row.
305
+ * @since apiVersion 2
306
+ */
307
+ secrets: PluginSecrets;
308
+ /** The operating system this host runs on. */
309
+ readonly platform: PluginPlatform;
310
+ /** The home directory of the user this host runs as. */
311
+ readonly homeDir: string;
312
+ }
313
+ /** Where a plugin can be driven at all. Mirrors the manifest's `platforms`. */
314
+ type PluginPlatform = "darwin" | "linux";
315
+ /** Options for {@link PluginHost.run}. */
316
+ interface RunOptions {
317
+ /** Deadline in milliseconds. Default 30_000; the host caps it at 10 minutes. */
318
+ timeoutMs?: number;
319
+ /** Called per output line, ANSI already stripped, from both streams interleaved. */
320
+ onLine?: (line: string) => void;
321
+ /**
322
+ * Ends the run early without failing it.
323
+ *
324
+ * What an interactive login needs: `tailscale up` with no key blocks until a
325
+ * human finishes in a browser, having already printed the URL — so the
326
+ * plugin reads the URL off {@link onLine} and aborts, leaving the daemon
327
+ * waiting. An aborted run reports `aborted: true` and whatever was captured.
328
+ */
329
+ signal?: AbortSignal;
330
+ /** Extra environment on top of the host's allowlist. Never overrides PATH. */
331
+ env?: Record<string, string>;
332
+ /** Text to write to stdin. Absent means stdin is /dev/null, so a prompt cannot hang forever. */
333
+ stdin?: string;
334
+ }
335
+ /** What {@link PluginHost.run} reports. Never throws for a non-zero exit. */
336
+ interface RunResult {
337
+ /** Exit code, or null when the process was signalled or never started. */
338
+ code: number | null;
339
+ /** Captured stdout, ANSI stripped, capped. */
340
+ stdout: string;
341
+ /** Captured stderr, ANSI stripped, capped. */
342
+ stderr: string;
343
+ /** True when the deadline ended it. */
344
+ timedOut: boolean;
345
+ /** True when {@link RunOptions.signal} ended it. */
346
+ aborted: boolean;
347
+ }
348
+ /** Write-only, per-plugin credential storage. See {@link PluginHost.secrets}. */
349
+ interface PluginSecrets {
350
+ /** Stores (or replaces) a credential under this name. */
351
+ set(name: string, value: string): Promise<void>;
352
+ /** Whether a credential is stored under this name. The only read there is. */
353
+ has(name: string): Promise<boolean>;
354
+ /** Removes it. Absent is not an error. */
355
+ delete(name: string): Promise<void>;
251
356
  }
252
357
  /**
253
358
  * The plugin itself.
@@ -259,8 +364,8 @@ interface PluginHost {
259
364
  interface SubshellPlugin {
260
365
  /** Builds the argv (no shell) used to launch a subshell. */
261
366
  buildCommand(input: BuildCommandInput): string[];
262
- /** Validates a profile definition before saving. */
263
- validateProfile(profile: ProfileDefinition): ProfileValidationResult;
367
+ /** Validates a preset definition before saving. */
368
+ validatePreset(preset: PresetDefinition): PresetValidationResult;
264
369
  /** Which optional members below are meaningful on this plugin. */
265
370
  capabilities(): PluginCapability[];
266
371
  /** Maps a harness exit code to a human label (null = unknown). */
@@ -292,21 +397,30 @@ interface SubshellPlugin {
292
397
  mcpRegistration?(launch: McpLaunchSpec, configPath: string): McpRegistration;
293
398
  /** How users obtain the subshell MCP tools here. Declare the `mcp` capability with it. */
294
399
  mcpSetup?(launch: McpLaunchSpec): McpSetupInfo;
295
- /** Settings rendered in the PROFILE editor and stored on the profile. */
296
- profileSettings?(): SettingsField[];
297
- /** Known extra env var suggestions for the profile editor. */
400
+ /** Settings rendered in the PRESET editor and stored on the preset. */
401
+ presetSettings?(): SettingsField[];
402
+ /** Known extra env var suggestions for the preset editor. */
298
403
  suggestedEnv?(): {
299
404
  key: string;
300
405
  description: string;
301
406
  }[];
302
- /** Known CLI flag suggestions for the profile editor. */
407
+ /** Known CLI flag suggestions for the preset editor. */
303
408
  suggestedFlags?(): {
304
409
  flag: string;
305
410
  description: string;
306
411
  }[];
307
412
  }
308
- /** What a plugin module default-exports. */
309
- type PluginFactory = (host: PluginHost) => SubshellPlugin;
413
+ /**
414
+ * A factory that returns a harness, for a plugin that knows which it is.
415
+ *
416
+ * {@link PluginFactory} is the LOADER's view — it must accept either shape,
417
+ * because it decides which to expect from the manifest. A plugin itself has no
418
+ * such ambiguity, and annotating its own factory with the union would widen
419
+ * what its tests can call: `createPlugin(host).buildCommand(...)` stops
420
+ * type-checking on a value that might be a network plugin. So a plugin
421
+ * declares the half it implements and the loader keeps the union.
422
+ */
423
+ type HarnessPluginFactory = (host: PluginHost) => SubshellPlugin;
310
424
  //#endregion
311
425
  //#region ../../plugin-api/dist/index.d.ts
312
426
  /** How a host finds this plugin's binary, as data, so a scan loads no plugin code. */
@@ -315,16 +429,158 @@ interface DetectSpec {
315
429
  binaryName: string;
316
430
  /** Env var that overrides the lookup outright, e.g. "CLAUDE_PATH" */
317
431
  envOverride: string;
318
- /** Well-known locations relative to HOME, tried after PATH */
432
+ /**
433
+ * Well-known locations, tried after PATH and before the version managers.
434
+ *
435
+ * HOME-relative (`.local/bin/mytool`), or ABSOLUTE when the entry starts
436
+ * with `/` — which is how a vendor's GUI install is named, since
437
+ * `/Applications/Tailscale.app/Contents/MacOS/Tailscale` is nobody's HOME.
438
+ * Each is a candidate to search, so an entry that does not resolve costs
439
+ * nothing and the ladder carries on.
440
+ */
319
441
  knownPaths: string[];
320
442
  }
321
443
  /** Where to send someone whose binary is missing. */
322
444
  interface InstallSpec {
323
- /** Copy-pasteable install command for the official installer */
445
+ /**
446
+ * Copy-pasteable install command for the official installer.
447
+ *
448
+ * A command the HOST may run on request, which is why it may not be
449
+ * privileged: `sudo`, `doas` and `pkexec` are refused by the parser at any
450
+ * command boundary (the line is run through a shell — see
451
+ * {@link needsPrivileges}), so an installer that needs root is described
452
+ * under {@link NetworkManifest.privileged} instead and is only ever shown to
453
+ * copy. Without that refusal the two would be one field with two meanings,
454
+ * and the surface offering a button would have to guess.
455
+ */
324
456
  command: string;
325
- /** URL of the installation documentation */
457
+ /**
458
+ * URL of the installation documentation.
459
+ *
460
+ * Rendered as a link on an admin page, so it must be `http:` or `https:`
461
+ * and the parser refuses anything else — see {@link isDocsUrl}.
462
+ */
326
463
  docsUrl: string;
327
464
  }
465
+ /** One step an operator must run themselves, because the host may not. */
466
+ interface PrivilegedStep {
467
+ /** What it does, in a few words: "Install the daemon" */
468
+ label: string;
469
+ /** The command to copy. Usually `sudo …`. */
470
+ command: string;
471
+ /** Where the vendor documents it; `http:`/`https:` only, as {@link isDocsUrl} requires. */
472
+ docsUrl?: string;
473
+ /**
474
+ * Which ALTERNATIVE this step belongs to, and the heading it renders under.
475
+ *
476
+ * Steps sharing a group are one sequence to run in order; different groups
477
+ * are different ways to arrive at the same place, and a surface renders an
478
+ * `or` between them rather than continuing the numbering. Steps with no
479
+ * group are one plain sequence, which is what every step was before this
480
+ * existed.
481
+ *
482
+ * It exists because a platform can genuinely offer two routes: on macOS
483
+ * Tailscale ships both a GUI app and a command-line daemon, and a page that
484
+ * numbered those 1..3 told a person to install both. Non-empty when present
485
+ * — it is a heading, so `""` is refused rather than coerced.
486
+ */
487
+ group?: string;
488
+ }
489
+ /**
490
+ * The `subshell.network` block. Required when `type` is `network`.
491
+ *
492
+ * Everything here is DATA a host reads without loading plugin code: which
493
+ * machines this plugin can run on at all, whether it can produce a login URL,
494
+ * what publishing it exposes, and which steps only a human with root can
495
+ * perform. A page can therefore say "not available on this platform" or print
496
+ * the two sudo commands before any plugin code has been imported — and before
497
+ * the vendor's CLI is anywhere on the machine.
498
+ */
499
+ interface NetworkManifest {
500
+ /**
501
+ * Operating systems this plugin can be driven on.
502
+ *
503
+ * Not a hint: a host refuses every act on a platform absent from this list,
504
+ * and the UI renders the row as unavailable rather than offering a button
505
+ * that would 409. Vendors differ here for real reasons — a CLI that talks to
506
+ * a logged-in desktop session cannot be driven from a launchd service.
507
+ */
508
+ platforms: PluginPlatform[];
509
+ /**
510
+ * True when `join({})` with no credential can yield a URL a human finishes.
511
+ *
512
+ * Data rather than a capability because it describes what a RETURN VALUE may
513
+ * be, and a capability is checked against members. The UI reads it to decide
514
+ * whether to offer "sign in" beside "paste a key".
515
+ */
516
+ interactiveLogin?: boolean;
517
+ /**
518
+ * True when publishing leaves NOTHING the daemon can later be asked about.
519
+ *
520
+ * NetBird is the case that forced the field: a join is the whole publish,
521
+ * so `status()` can only ever see the join, and a plugin that declared
522
+ * itself `published` would claim a thing its own vendor CLI never told it.
523
+ * The HOST's publish record is the one witness of the transition, and this
524
+ * flag is what lets the host (and only the host) upgrade `joined` to
525
+ * `published` from it. A plugin without the flag is never upgraded: for
526
+ * Tailscale a serve reset outside this app is a real state, and `joined`
527
+ * is the honest reading of it.
528
+ */
529
+ publishImplicit?: boolean;
530
+ /**
531
+ * What publishing on this network exposes the server to.
532
+ *
533
+ * `private` is a network only invited machines are on. `public-with-gate`
534
+ * reaches the open internet with an identity check in front — a different
535
+ * security posture, stated here so every surface can say so before the act
536
+ * rather than after it.
537
+ */
538
+ exposure: "private" | "public-with-gate";
539
+ /**
540
+ * Steps the host can never perform, per platform, in the order to run them.
541
+ *
542
+ * Every mesh VPN installs a daemon as root and the server has no terminal to
543
+ * answer a password prompt, so these are printed to copy and never executed.
544
+ * Keeping them out of {@link InstallSpec} is what lets a surface offer a
545
+ * button for one and a copy row for the other without inspecting the string.
546
+ */
547
+ privileged?: Partial<Record<PluginPlatform, PrivilegedStep[]>>;
548
+ /**
549
+ * What this network calls the two things a person acts on.
550
+ *
551
+ * Vendors name the same act differently and a generic word is wrong rather
552
+ * than merely bland: Tailscale takes an "auth key", NetBird a "setup key",
553
+ * Cloudflare a "tunnel token", and a field labelled "Auth key" on the
554
+ * NetBird row asks for something NetBird does not have. Likewise publishing
555
+ * is "Tailscale Serve" on one network and starting a tunnel on another.
556
+ *
557
+ * MANIFEST data rather than something the plugin returns, for the same
558
+ * reason `platforms` is: a surface renders these before any plugin code has
559
+ * been loaded, and before the vendor's CLI is anywhere on the machine. Both
560
+ * are optional and both have a sensible generic default, so a plugin that
561
+ * says nothing is merely plain rather than broken.
562
+ */
563
+ labels?: NetworkLabels;
564
+ }
565
+ /** The vendor's own words for the two acts a person takes. See {@link NetworkManifest.labels}. */
566
+ interface NetworkLabels {
567
+ /** What to call the pasted credential: "Auth key", "Setup key", "Tunnel token". */
568
+ credential?: string;
569
+ /** What to call publishing: "Publish with Tailscale Serve", "Start tunnel". */
570
+ publish?: string;
571
+ /**
572
+ * Where the credential named by `credential` comes from — the vendor page
573
+ * that mints one.
574
+ *
575
+ * The credential box asks for an "Auth key" on a card that otherwise says
576
+ * nothing about what an auth key is or where to get it, and every vendor
577
+ * words that differently enough that the SPA cannot write the sentence
578
+ * itself. So the plugin names its page, and the card renders it as a Docs
579
+ * link on the label row. http(s) only, refused at parse like every other
580
+ * URL this contract carries — it lands in an `href` on an admin page.
581
+ */
582
+ credentialDocsUrl?: string;
583
+ }
328
584
  /** The parsed `subshell` block. */
329
585
  interface SubshellManifest {
330
586
  /** plugin-api version this plugin was built against */
@@ -337,7 +593,11 @@ interface SubshellManifest {
337
593
  name: string;
338
594
  /** One-line description shown in the UI */
339
595
  description: string;
340
- /** Optional emoji/glyph */
596
+ /**
597
+ * Relative path to this plugin's icon file, e.g. `icon.svg`. Served by
598
+ * the control plane at `/api/plugins/<id>/icon`; absent means the UI
599
+ * renders a monogram instead.
600
+ */
341
601
  icon?: string;
342
602
  /** Module to import, relative to the package directory */
343
603
  entry: string;
@@ -355,6 +615,8 @@ interface SubshellManifest {
355
615
  * fallback, i.e. a resume that quietly never offers itself (§11).
356
616
  */
357
617
  hostEnv?: string[];
618
+ /** Network facts. Present exactly when `type` is `network`. */
619
+ network?: NetworkManifest;
358
620
  }
359
621
  //#endregion
360
622
  //#region src/manifest.d.ts
@@ -366,7 +628,7 @@ declare const manifest: SubshellManifest;
366
628
  *
367
629
  * Launch shape:
368
630
  * codex [-m <model>] [-s <sandbox>] [-a <approval>] [mcp -c pairs]
369
- * [profile flags] [extra flags]
631
+ * [preset flags] [extra flags]
370
632
  * A bare launch (no subcommand, no prompt) opens the interactive TUI. Codex
371
633
  * has no create-time flag for naming its own session and no way to pin a
372
634
  * conversation id, so neither the subshell name nor a resume capability is
@@ -374,7 +636,7 @@ declare const manifest: SubshellManifest;
374
636
  * The pane's
375
637
  * cwd is already the working directory, so `-C/--cd` is never passed, and
376
638
  * `--dangerously-bypass-approvals-and-sandbox` is NEVER baked into a launch
377
- * (a user may put it in their own profile flags — that is their call).
639
+ * (a user may put it in their own preset flags — that is their call).
378
640
  *
379
641
  * subshell MCP is wired automatically WITHOUT touching user state: every
380
642
  * launch carries `-c mcp_servers.subshell.command=… -c mcp_servers.subshell.args=…`
@@ -392,6 +654,6 @@ declare const manifest: SubshellManifest;
392
654
  *
393
655
  * `host` carries what this module cannot import. See `@subshell-ai/plugin-api`.
394
656
  */
395
- declare const createPlugin: PluginFactory;
657
+ declare const createPlugin: HarnessPluginFactory;
396
658
  //#endregion
397
659
  export { createPlugin as default, manifest };
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
- //#region ../../plugin-api/dist/manifest-UFScnYqn.js
1
+ //#region ../../plugin-api/dist/manifest-DKMceUPh.js
2
2
  /** Every {@link PluginType}, for anything that has to iterate or validate them. */
3
- const PLUGIN_TYPES = ["agent-harness", "terminal"];
3
+ const PLUGIN_TYPES = [
4
+ "agent-harness",
5
+ "terminal",
6
+ "network"
7
+ ];
8
+ /** Every {@link PluginPlatform}, for validation. */
9
+ const PLUGIN_PLATFORMS = ["darwin", "linux"];
4
10
  /**
5
11
  * The MCP server name every harness registers the built-in `subshell mcp`
6
12
  * server under: the config object key, and the `hermes mcp add|remove`
@@ -9,6 +15,17 @@ const PLUGIN_TYPES = ["agent-harness", "terminal"];
9
15
  */
10
16
  const MCP_SERVER_NAME = "subshell";
11
17
  /**
18
+ * Image formats a plugin icon may be. The server maps these to a Content-Type
19
+ * from a fixed table rather than sniffing the file, so this list and that
20
+ * table are one decision: adding a format here without adding it there leaves
21
+ * an icon that parses and then 500s when something asks for it.
22
+ */
23
+ const ICON_EXTENSIONS = [
24
+ ".svg",
25
+ ".png",
26
+ ".webp"
27
+ ];
28
+ /**
12
29
  * Ids become directory names under `<dataDir>/plugins/`, so they are path
13
30
  * segments and nothing else: no separators, no traversal, no surprises from a
14
31
  * case-insensitive filesystem.
@@ -17,6 +34,80 @@ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
17
34
  function isRecord(value) {
18
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
19
36
  }
37
+ /** The privilege wrappers no install command may ever run, by first-word basename. */
38
+ const PRIVILEGED_COMMANDS = [
39
+ "sudo",
40
+ "doas",
41
+ "pkexec"
42
+ ];
43
+ /** Shell boundaries that introduce a NEW command word: `;` `|` `&` (so `&&` and `||` too) and newline. */
44
+ const COMMAND_BOUNDARY_RE = /[;&|\n]+/;
45
+ /** A leading `NAME=…` env assignment, which `sh` strips before choosing the command. */
46
+ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/;
47
+ /**
48
+ * Whether a shell line runs one of {@link PRIVILEGED_COMMANDS} AS A COMMAND
49
+ * anywhere in it — not merely at the start.
50
+ *
51
+ * `install.command` is the one manifest field a host RUNS, and it runs through
52
+ * `sh -c` (`api/network/install-network.route.ts`), which means any boundary
53
+ * in the line can start the privileged command the start of the line hides:
54
+ * `"apt-get update && sudo apt-get install -y x"` is sudo-without-prompt on a
55
+ * NOPASSWD host. A line-start regex was the first gate and it was not one.
56
+ *
57
+ * The split is CONSERVATIVE, not a shell parser: segments are taken at every
58
+ * boundary character quote-blind (so a `;` inside a quoted string still
59
+ * counts, which can only over-refuse), leading env assignments are stripped
60
+ * (`FOO=bar sudo x` runs sudo), and each segment's first word is compared by
61
+ * BASENAME (`/usr/bin/sudo` is sudo, `pkexec-demo` is not). A word merely
62
+ * CONTAINING the token is fine — the comparison is equality on the basename.
63
+ * The accepted limit is a wrapper hidden inside quotes (`sh -c 'sudo …'`),
64
+ * which is the same limit `PluginHost.run`'s first-word check has, and it is
65
+ * accepted for the same reason: a plugin whose code LOADS can already run
66
+ * anything in this process (`docs/security.md` §11.9) — this rule refuses
67
+ * careless manifests deterministically, it is not a sandbox.
68
+ */
69
+ function needsPrivileges(command) {
70
+ for (const segment of command.split(COMMAND_BOUNDARY_RE)) {
71
+ let rest = segment.trim();
72
+ while (ENV_ASSIGNMENT_RE.test(rest)) rest = rest.replace(ENV_ASSIGNMENT_RE, "");
73
+ if (rest === "") continue;
74
+ const ws = rest.search(/\s/);
75
+ const firstWord = ws === -1 ? rest : rest.slice(0, ws);
76
+ const base = firstWord.slice(firstWord.lastIndexOf("/") + 1);
77
+ if (PRIVILEGED_COMMANDS.includes(base)) return true;
78
+ }
79
+ return false;
80
+ }
81
+ /**
82
+ * Whether a string may be rendered as a link in an operator's browser.
83
+ *
84
+ * Every `docsUrl` in this contract ends up as the `href` of an anchor on an
85
+ * admin page, and an `href` is not inert: a `javascript:` URL in one is script
86
+ * running on the control plane's own origin, in the session of the one person
87
+ * who can install plugins. So a URL this contract carries is restricted to the
88
+ * two schemes that navigate, and both the parser below and the host check it.
89
+ *
90
+ * It is checked in two places on purpose. A manifest is static data, so a bad
91
+ * value there is a plugin defect and is refused at load. A URL a plugin
92
+ * REPORTS at runtime is often something it read off a vendor CLI — Tailscale's
93
+ * `AuthURL` comes from whichever control server the operator pointed the
94
+ * daemon at — so the host drops that one instead, because refusing to load a
95
+ * working plugin over a value its control server chose would be the wrong
96
+ * failure.
97
+ *
98
+ * Not a reachability claim: nothing fetches these, and a docs page that 404s
99
+ * is a broken link rather than a hazard.
100
+ * @param value - the candidate URL, as written in a manifest or returned by a plugin
101
+ */
102
+ function isDocsUrl(value) {
103
+ let parsed;
104
+ try {
105
+ parsed = new URL(value);
106
+ } catch {
107
+ return false;
108
+ }
109
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
110
+ }
20
111
  /**
21
112
  * Reads and validates the `subshell` block of a plugin's package.json.
22
113
  *
@@ -31,13 +122,18 @@ function parseManifest(pkgJson) {
31
122
  const block = pkgJson.subshell;
32
123
  if (!isRecord(block)) return { error: "package.json has no `subshell` block, so it is not a Subshell plugin" };
33
124
  if (typeof block.apiVersion !== "number" || !Number.isInteger(block.apiVersion)) return { error: "`subshell.apiVersion` must be an integer" };
34
- if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements 1` };
35
- if (block.apiVersion > 1) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 1; upgrade the agent` };
125
+ if (block.apiVersion < 1) return { error: `this plugin declares plugin-api ${block.apiVersion}, which is not a version; the lowest is 1 and this host implements 2` };
126
+ if (block.apiVersion > 2) return { error: `this plugin needs plugin-api ${block.apiVersion}, but this host implements 2; upgrade Subshell` };
36
127
  if (typeof block.id !== "string" || !ID_RE.test(block.id)) return { error: "`subshell.id` must be lowercase letters, digits and hyphens, starting alphanumeric (it becomes a directory name)" };
37
128
  if (typeof block.type !== "string" || !PLUGIN_TYPES.includes(block.type)) return { error: `\`subshell.type\` must be one of: ${PLUGIN_TYPES.join(", ")}` };
38
129
  if (typeof block.name !== "string" || block.name.trim() === "") return { error: "`subshell.name` must be a non-empty string" };
39
130
  if (typeof block.description !== "string") return { error: "`subshell.description` must be a string" };
40
- if (block.icon !== void 0 && typeof block.icon !== "string") return { error: "`subshell.icon` must be a string" };
131
+ if (block.icon !== void 0) {
132
+ if (typeof block.icon !== "string" || block.icon.trim() === "") return { error: "`subshell.icon` must be a non-empty relative path to an image in the package" };
133
+ if (block.icon.startsWith("/") || block.icon.split("/").includes("..")) return { error: "`subshell.icon` must stay inside the package (no leading `/` and no `..` segment)" };
134
+ const icon = block.icon;
135
+ if (!ICON_EXTENSIONS.some((ext) => icon.endsWith(ext))) return { error: `\`subshell.icon\` must end in one of: ${ICON_EXTENSIONS.join(", ")}` };
136
+ }
41
137
  if (typeof block.entry !== "string" || block.entry.trim() === "") return { error: "`subshell.entry` must be a non-empty relative path" };
42
138
  if (block.entry.startsWith("/") || block.entry.split("/").includes("..")) return { error: "`subshell.entry` must stay inside the package (no leading `/` and no `..` segment)" };
43
139
  let detect;
@@ -54,6 +150,8 @@ function parseManifest(pkgJson) {
54
150
  if (block.install !== void 0) {
55
151
  const i = block.install;
56
152
  if (!isRecord(i) || typeof i.command !== "string" || typeof i.docsUrl !== "string") return { error: "`subshell.install` needs a command and a docsUrl" };
153
+ if (needsPrivileges(i.command)) return { error: "`subshell.install.command` must not need sudo, doas or pkexec — the host runs it through a shell and has no terminal for a password prompt; put privileged steps in `subshell.network.privileged`" };
154
+ if (!isDocsUrl(i.docsUrl)) return { error: "`subshell.install.docsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
57
155
  install = {
58
156
  command: i.command,
59
157
  docsUrl: i.docsUrl
@@ -64,39 +162,106 @@ function parseManifest(pkgJson) {
64
162
  if (!Array.isArray(block.hostEnv) || !block.hostEnv.every((k) => typeof k === "string" && k.trim() !== "")) return { error: "`subshell.hostEnv` must be an array of environment variable names" };
65
163
  hostEnv = [...block.hostEnv];
66
164
  }
165
+ const type = block.type;
166
+ const parsedNetwork = parseNetworkBlock(block.network, type);
167
+ if (parsedNetwork !== void 0 && "error" in parsedNetwork) return parsedNetwork;
67
168
  return {
68
169
  apiVersion: block.apiVersion,
69
170
  id: block.id,
70
- type: block.type,
171
+ type,
71
172
  name: block.name,
72
173
  description: block.description,
73
174
  ...typeof block.icon === "string" ? { icon: block.icon } : {},
74
175
  entry: block.entry,
75
176
  ...detect ? { detect } : {},
76
177
  ...install ? { install } : {},
77
- ...hostEnv ? { hostEnv } : {}
178
+ ...hostEnv ? { hostEnv } : {},
179
+ ...parsedNetwork ? { network: parsedNetwork } : {}
180
+ };
181
+ }
182
+ /**
183
+ * Parses `subshell.network`, which is required for and exclusive to `network`.
184
+ *
185
+ * Both directions are refused rather than tolerated. A network plugin without
186
+ * the block would be unrenderable — nothing could say which platforms it runs
187
+ * on or what publishing it exposes, and the safe default for "what does this
188
+ * expose" is not a default anyone should pick silently. A harness WITH one
189
+ * declares facts nothing reads, which is how a manifest starts lying.
190
+ * @returns the parsed block, an error, or undefined when there is none to have
191
+ */
192
+ function parseNetworkBlock(raw, type) {
193
+ if (type !== "network") {
194
+ if (raw !== void 0) return { error: `\`subshell.network\` is only for \`type: "network"\` plugins, and this one is \`${type}\`` };
195
+ return;
196
+ }
197
+ if (!isRecord(raw)) return { error: "`subshell.network` is required for `type: \"network\"` plugins" };
198
+ if (!Array.isArray(raw.platforms) || raw.platforms.length === 0 || !raw.platforms.every((p) => typeof p === "string" && PLUGIN_PLATFORMS.includes(p))) return { error: `\`subshell.network.platforms\` must be a non-empty array of: ${PLUGIN_PLATFORMS.join(", ")}` };
199
+ if (raw.exposure !== "private" && raw.exposure !== "public-with-gate") return { error: "`subshell.network.exposure` must be \"private\" or \"public-with-gate\"" };
200
+ if (raw.interactiveLogin !== void 0 && typeof raw.interactiveLogin !== "boolean") return { error: "`subshell.network.interactiveLogin` must be a boolean" };
201
+ if (raw.publishImplicit !== void 0 && typeof raw.publishImplicit !== "boolean") return { error: "`subshell.network.publishImplicit` must be a boolean" };
202
+ let privileged;
203
+ if (raw.privileged !== void 0) {
204
+ if (!isRecord(raw.privileged)) return { error: "`subshell.network.privileged` must be an object keyed by platform" };
205
+ privileged = {};
206
+ for (const [platform, steps] of Object.entries(raw.privileged)) {
207
+ if (!PLUGIN_PLATFORMS.includes(platform)) return { error: `\`subshell.network.privileged\` has an unknown platform "${platform}"` };
208
+ if (!Array.isArray(steps) || !steps.every((s) => isRecord(s) && typeof s.label === "string" && s.label.trim() !== "" && typeof s.command === "string" && s.command.trim() !== "" && (s.docsUrl === void 0 || typeof s.docsUrl === "string") && (s.group === void 0 || typeof s.group === "string"))) return { error: `\`subshell.network.privileged.${platform}\` must be an array of { label, command, docsUrl?, group? }` };
209
+ const badGroup = steps.find((s) => s.group !== void 0 && s.group.trim() === "");
210
+ if (badGroup) return { error: `\`subshell.network.privileged.${platform}\` has an empty \`group\`, which is a heading with no words: "${badGroup.label}"` };
211
+ const badDocs = steps.find((s) => s.docsUrl !== void 0 && !isDocsUrl(s.docsUrl));
212
+ if (badDocs) return { error: `\`subshell.network.privileged.${platform}\` has a docsUrl that is not an http(s) URL: "${badDocs.label}"` };
213
+ privileged[platform] = steps.map((s) => ({
214
+ label: s.label,
215
+ command: s.command,
216
+ ...s.docsUrl ? { docsUrl: s.docsUrl } : {},
217
+ ...s.group ? { group: s.group } : {}
218
+ }));
219
+ }
220
+ }
221
+ let labels;
222
+ if (raw.labels !== void 0) {
223
+ if (!isRecord(raw.labels)) return { error: "`subshell.network.labels` must be an object" };
224
+ for (const key of ["credential", "publish"]) {
225
+ const value = raw.labels[key];
226
+ if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) return { error: `\`subshell.network.labels.${key}\` must be a non-empty string` };
227
+ }
228
+ const credDocs = raw.labels.credentialDocsUrl;
229
+ if (credDocs !== void 0 && (typeof credDocs !== "string" || !isDocsUrl(credDocs))) return { error: "`subshell.network.labels.credentialDocsUrl` must be an http(s) URL — it is rendered as a link on an admin page" };
230
+ labels = {
231
+ ...typeof raw.labels.credential === "string" ? { credential: raw.labels.credential } : {},
232
+ ...typeof raw.labels.publish === "string" ? { publish: raw.labels.publish } : {},
233
+ ...typeof raw.labels.credentialDocsUrl === "string" ? { credentialDocsUrl: raw.labels.credentialDocsUrl } : {}
234
+ };
235
+ }
236
+ return {
237
+ platforms: [...raw.platforms],
238
+ exposure: raw.exposure,
239
+ ...raw.interactiveLogin === true ? { interactiveLogin: true } : {},
240
+ ...raw.publishImplicit === true ? { publishImplicit: true } : {},
241
+ ...privileged ? { privileged } : {},
242
+ ...labels && Object.keys(labels).length > 0 ? { labels } : {}
78
243
  };
79
244
  }
80
245
  //#endregion
81
246
  //#region ../../plugin-api/dist/index.js
82
247
  /**
83
- * The profile checks every built-in harness performs unchanged: a non-empty
248
+ * The preset checks every built-in harness performs unchanged: a non-empty
84
249
  * name, string env values, and flags that look like flags. Harnesses with
85
250
  * extra rules run their own checks on top of these.
86
- * @param profile - The decoded profile definition to check
251
+ * @param preset - The decoded preset definition to check
87
252
  * @returns Issues per field; `valid` is true when no issues were found
88
253
  */
89
- function validateGenericProfile(profile) {
254
+ function validateGenericPreset(preset) {
90
255
  const issues = [];
91
- if (!profile.name || profile.name.trim().length === 0) issues.push({
256
+ if (!preset.name || preset.name.trim().length === 0) issues.push({
92
257
  field: "name",
93
- message: "Profile name is required."
258
+ message: "Preset name is required."
94
259
  });
95
- for (const [key, value] of Object.entries(profile.env)) if (typeof value !== "string") issues.push({
260
+ for (const [key, value] of Object.entries(preset.env)) if (typeof value !== "string") issues.push({
96
261
  field: "env",
97
262
  message: `Env var "${key}" must be a string.`
98
263
  });
99
- for (const flag of profile.flags) if (!flag.startsWith("-")) issues.push({
264
+ for (const flag of preset.flags) if (!flag.startsWith("-")) issues.push({
100
265
  field: "flags",
101
266
  message: `Flag "${flag}" must start with "-".`
102
267
  });
@@ -118,7 +283,7 @@ function validateGenericProfile(profile) {
118
283
  const parsed = parseManifest({
119
284
  name: "@subshell-ai/plugin-codex",
120
285
  description: "Codex harness plugin for Subshell",
121
- version: "0.1.1",
286
+ version: "1.0.0",
122
287
  license: "Apache-2.0",
123
288
  type: "module",
124
289
  main: "dist/index.js",
@@ -152,15 +317,15 @@ const parsed = parseManifest({
152
317
  "tsdown": "0.22.14",
153
318
  "typescript": "7.0.2"
154
319
  },
155
- files: ["dist"],
320
+ files: ["dist", "icon.svg"],
156
321
  publishConfig: { "access": "public" },
157
322
  subshell: {
158
- "apiVersion": 1,
323
+ "apiVersion": 2,
159
324
  "id": "codex",
160
325
  "type": "agent-harness",
161
326
  "name": "Codex",
162
327
  "description": "OpenAI's agentic coding CLI (interactive TUI)",
163
- "icon": "📖",
328
+ "icon": "icon.svg",
164
329
  "entry": "dist/index.js",
165
330
  "detect": {
166
331
  "binaryName": "codex",
@@ -239,7 +404,7 @@ function tomlStringArray(values) {
239
404
  *
240
405
  * Launch shape:
241
406
  * codex [-m <model>] [-s <sandbox>] [-a <approval>] [mcp -c pairs]
242
- * [profile flags] [extra flags]
407
+ * [preset flags] [extra flags]
243
408
  * A bare launch (no subcommand, no prompt) opens the interactive TUI. Codex
244
409
  * has no create-time flag for naming its own session and no way to pin a
245
410
  * conversation id, so neither the subshell name nor a resume capability is
@@ -247,7 +412,7 @@ function tomlStringArray(values) {
247
412
  * The pane's
248
413
  * cwd is already the working directory, so `-C/--cd` is never passed, and
249
414
  * `--dangerously-bypass-approvals-and-sandbox` is NEVER baked into a launch
250
- * (a user may put it in their own profile flags — that is their call).
415
+ * (a user may put it in their own preset flags — that is their call).
251
416
  *
252
417
  * subshell MCP is wired automatically WITHOUT touching user state: every
253
418
  * launch carries `-c mcp_servers.subshell.command=… -c mcp_servers.subshell.args=…`
@@ -268,14 +433,14 @@ function tomlStringArray(values) {
268
433
  const createPlugin = (_host) => ({
269
434
  capabilities: () => ["mcp", "settings"],
270
435
  buildCommand(input) {
271
- const { binary, profile, extraFlags, mcp } = input;
436
+ const { binary, preset, extraFlags, mcp } = input;
272
437
  const args = [binary];
273
- const s = profile.settings ?? {};
438
+ const s = preset.settings ?? {};
274
439
  if (typeof s.model === "string" && s.model) args.push("-m", s.model);
275
440
  if (typeof s.sandbox === "string" && s.sandbox) args.push("-s", s.sandbox);
276
441
  if (typeof s.askForApproval === "string" && s.askForApproval) args.push("-a", s.askForApproval);
277
442
  if (mcp?.args) args.push(...mcp.args);
278
- for (const flag of profile.flags) args.push(flag);
443
+ for (const flag of preset.flags) args.push(flag);
279
444
  if (extraFlags) args.push(...extraFlags);
280
445
  return args;
281
446
  },
@@ -317,10 +482,10 @@ const createPlugin = (_host) => ({
317
482
  summary: "Subshell registers itself with every Codex subshell automatically (per-invocation -c mcp_servers.subshell.* overrides; your ~/.codex config is never modified)."
318
483
  };
319
484
  },
320
- validateProfile(profile) {
321
- return validateGenericProfile(profile);
485
+ validatePreset(preset) {
486
+ return validateGenericPreset(preset);
322
487
  },
323
- profileSettings() {
488
+ presetSettings() {
324
489
  return CODEX_SETTINGS_FIELDS;
325
490
  },
326
491
  suggestedEnv() {
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><title>Codex</title>
2
+ <circle cx="12" cy="12" r="12" fill="#0080F7"/>
3
+ <path d="M9.94494 9.59163V8.13227C9.94494 8.00935 9.99105 7.91713 10.0985 7.85575L13.0327 6.16599C13.4321 5.93558 13.9083 5.8281 14.3998 5.8281C16.2432 5.8281 17.4108 7.25677 17.4108 8.77751C17.4108 8.885 17.4108 9.00792 17.3953 9.13083L14.3537 7.34884C14.1694 7.24135 13.985 7.24135 13.8007 7.34884L9.94494 9.59163ZM16.7963 15.2755V11.7883C16.7963 11.5732 16.704 11.4196 16.5197 11.3121L12.664 9.0693L13.9236 8.34725C14.0311 8.28587 14.1234 8.28587 14.2308 8.34725L17.165 10.037C18.0099 10.5287 18.5782 11.5732 18.5782 12.587C18.5782 13.7544 17.887 14.8298 16.7963 15.2753V15.2755ZM9.03861 12.2031L7.77896 11.4658C7.67146 11.4045 7.62535 11.3122 7.62535 11.1893V7.8098C7.62535 6.16613 8.88501 4.92176 10.5902 4.92176C11.2354 4.92176 11.8344 5.13689 12.3415 5.52089L9.31526 7.27218C9.13097 7.37968 9.03875 7.53328 9.03875 7.74841V12.2033L9.03861 12.2031ZM11.75 13.77L9.94494 12.7562V10.6056L11.75 9.59178L13.5549 10.6056V12.7562L11.75 13.77ZM12.9098 18.44C12.2645 18.44 11.6655 18.2249 11.1585 17.8409L14.1847 16.0896C14.369 15.9821 14.4612 15.8285 14.4612 15.6134V11.1585L15.7363 11.8958C15.8438 11.9572 15.8899 12.0494 15.8899 12.1723V15.5519C15.8899 17.1955 14.6148 18.44 12.9098 18.44ZM9.26901 15.0144L6.33486 13.3246C5.4899 12.833 4.92161 11.7885 4.92161 10.7746C4.92161 9.59177 5.62824 8.53183 6.71886 8.0863V11.5887C6.71886 11.8039 6.81109 11.9575 6.99538 12.065L10.8359 14.2923L9.57621 15.0144C9.46872 15.0758 9.37649 15.0758 9.26901 15.0144ZM9.10013 17.5337C7.36426 17.5337 6.08919 16.2279 6.08919 14.6149C6.08919 14.492 6.1046 14.3691 6.11988 14.2462L9.1461 15.9975C9.33039 16.105 9.51483 16.105 9.69912 15.9975L13.5549 13.7702V15.2295C13.5549 15.3524 13.5088 15.4446 13.4013 15.506L10.4671 17.1958C10.0677 17.4262 9.59148 17.5337 9.09999 17.5337H9.10013ZM12.9098 19.3616C14.7685 19.3616 16.32 18.0406 16.6735 16.2893C18.3939 15.8438 19.5 14.2308 19.5 12.5872C19.5 11.5118 19.0391 10.4673 18.2096 9.71454C18.2864 9.39192 18.3326 9.0693 18.3326 8.74682C18.3326 6.55014 16.5505 4.90634 14.4921 4.90634C14.0774 4.90634 13.6779 4.96772 13.2785 5.10605C12.5872 4.43011 11.6347 4 10.5902 4C8.7314 4 7.17996 5.32103 6.8265 7.07232C5.10605 7.51786 4 9.13083 4 10.7745C4 11.8498 4.4608 12.8944 5.29035 13.6471C5.21354 13.9697 5.16743 14.2923 5.16743 14.6148C5.16743 16.8114 6.94941 18.4552 9.00792 18.4552C9.42261 18.4552 9.82204 18.3939 10.2215 18.2556C10.9127 18.9315 11.8651 19.3616 12.9098 19.3616Z" fill="white"/>
4
+ </svg>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@subshell-ai/plugin-codex",
3
3
  "description": "Codex harness plugin for Subshell",
4
- "version": "0.1.1",
4
+ "version": "1.0.0",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -39,19 +39,17 @@
39
39
  "tsdown": "0.22.14",
40
40
  "typescript": "7.0.2"
41
41
  },
42
- "files": [
43
- "dist"
44
- ],
42
+ "files": ["dist", "icon.svg"],
45
43
  "publishConfig": {
46
44
  "access": "public"
47
45
  },
48
46
  "subshell": {
49
- "apiVersion": 1,
47
+ "apiVersion": 2,
50
48
  "id": "codex",
51
49
  "type": "agent-harness",
52
50
  "name": "Codex",
53
51
  "description": "OpenAI's agentic coding CLI (interactive TUI)",
54
- "icon": "📖",
52
+ "icon": "icon.svg",
55
53
  "entry": "dist/index.js",
56
54
  "detect": {
57
55
  "binaryName": "codex",