Skip to content

Commit 132699b

Browse files
authored
feat(cli): repeatable surface flags + id-leading-separator fix (#169)
1 parent c498e7d commit 132699b

5 files changed

Lines changed: 249 additions & 133 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"sideshow": minor
3+
---
4+
5+
`sideshow publish` and `sideshow surface add` now accept repeated surface
6+
flags to add several surfaces of the same kind. Previously a repeated
7+
non-multiple flag (`--diff a --diff b`) was silently dropped to the last
8+
value with no error.
9+
10+
- `sideshow publish <html> --diff a.patch --code c.ts --diff b.patch` now
11+
produces `[html, diff, code, diff]` — each repeat adds a surface, in
12+
command-line flag order.
13+
- `sideshow surface add <id> --md a.md --md b.md` appends two markdown
14+
surfaces (one append call per surface, so `--before`/`--after` positioning
15+
still applies per surface).
16+
- The seven surface flags (`--md`, `--mermaid`, `--diff`, `--terminal`,
17+
`--json`, `--code`, `--image`) are now `repeatable` in both commands.
18+
19+
This closes the remaining gap from #151 (multiple surfaces of the same kind
20+
on the CLI); surface order control was already fixed in 0.9.x via the
21+
token-walk for #158.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"sideshow": patch
3+
---
4+
5+
Fixed a latent bug where post, surface, and session ids could start with `-`
6+
or `_` (URL-safe base64 maps `+``-`, `/``_`, so ~1/64 of ids began with a
7+
separator). Any id starting with `-` broke CLI commands that take an id as a
8+
positional — `node:util` `parseArgs` treated it as an unknown option
9+
(`Unknown option '-6'` for an id like `-6K4AJsKD4M`), affecting `sideshow
10+
update`, `show`, and `surface add/remove/edit/move`. Two fixes:
11+
12+
- `newId` now swaps a leading separator for an alphanumeric, so new ids are
13+
always CLI-safe.
14+
- The CLI's `parse()` wrapper swaps id-shaped `-`/`_`-prefixed tokens for a
15+
sentinel before `parseArgs` sees them, then restores them in the result
16+
(positionals, tokens, option values). This rescues already-stored ids that
17+
start with a separator.

bin/sideshow.js

Lines changed: 141 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ usage:
2121
sideshow serve [--port N] [--open] start the surface (API + viewer)
2222
sideshow publish <file|-> [options] publish an HTML post (one html surface)
2323
--title <t> post title
24-
--md <file|-> add a markdown surface (prose) — combine with html
25-
--mermaid <file|-> add a mermaid surface (diagram source → SVG) — combine with html
26-
--diff <file|-> add a diff surface from a unified/git patch (combine with html)
27-
--terminal <file|-> add a terminal surface from monospace/ANSI output
28-
--json <file|-> add a json surface from a JSON file (collapsible tree)
29-
--code <file|-> add a code surface from a file (shiki-highlighted)
24+
--md <file|-> add a markdown surface (prose) — repeatable
25+
--mermaid <file|-> add a mermaid surface (diagram source → SVG) — repeatable
26+
--diff <file|-> add a diff surface from a unified/git patch — repeatable
27+
--terminal <file|-> add a terminal surface from monospace/ANSI output — repeatable
28+
--json <file|-> add a json surface from a JSON file (collapsible tree) — repeatable
29+
--code <file|-> add a code surface from a file (shiki-highlighted) — repeatable
3030
--kit <id> opt the html surface into a kit (repeatable; see "sideshow kits")
31-
--image <file> upload an image and append it as an image surface
31+
--image <file> upload an image and append it as an image surface — repeatable
3232
--session <id> target session (default: auto per agent session)
33+
surfaces appear in command-line flag order; repeat a flag to add several of one kind
3334
--session-title <t> name for a newly created session — name the task,
3435
e.g. "Auth refactor" (ignored if the session exists)
3536
--agent <name> agent name for new sessions (default: $SIDESHOW_AGENT or "agent")
@@ -78,16 +79,17 @@ usage:
7879
--surface <N> target surface N (id or 0-based index) in a multi-surface post
7980
sideshow surface <sub> [options] edit individual surfaces of a post
8081
surface add <id> [flags] append a surface to an existing post
81-
--md <f> markdown surface
82-
--code <f> code surface (language inferred from filename)
83-
--diff <f> diff surface from a patch
84-
--terminal <f> terminal surface
85-
--mermaid <f> mermaid surface
86-
--json <f> json surface
87-
--image <f> image surface (uploads the file first)
82+
--md <f> markdown surface (repeatable)
83+
--code <f> code surface (language inferred from filename; repeatable)
84+
--diff <f> diff surface from a patch (repeatable)
85+
--terminal <f> terminal surface (repeatable)
86+
--mermaid <f> mermaid surface (repeatable)
87+
--json <f> json surface (repeatable)
88+
--image <f> image surface (uploads the file first; repeatable)
8889
--layout split split layout for --diff surfaces
8990
--before <N> insert before surface N (id or index)
9091
--after <N> insert after surface N (id or index)
92+
surfaces append in command-line flag order; repeat a flag for several of one kind
9193
surface remove <id> <N> remove surface N (id or 0-based index)
9294
surface edit <id> <N> <file|-> replace surface N's content (kind preserved)
9395
surface move <id> <N> --to <M> move surface N to position M
@@ -478,6 +480,77 @@ function normalizeKits(flag) {
478480
return ids.length > 0 ? [...new Set(ids)] : undefined;
479481
}
480482

483+
// Surface-kind flags accepted by `publish` and `surface add` (the two commands
484+
// that compose a post from one flag per surface kind). Each is declared
485+
// `multiple: true` in the parser, so a repeated flag yields an array — letting
486+
// an author emit several surfaces of the same kind (--diff a --diff b).
487+
const SURFACE_FLAGS = new Map([
488+
["md", "markdown"],
489+
["mermaid", "mermaid"],
490+
["diff", "diff"],
491+
["terminal", "terminal"],
492+
["json", "json"],
493+
["code", "code"],
494+
["image", "image"],
495+
]);
496+
497+
// Build a single surface object from one flag value. Mirrors the per-kind
498+
// construction that used to be inlined in `publish` and `surface add`.
499+
async function buildSurface(kind, value, { session, layout }) {
500+
const file = value || "-";
501+
if (kind === "markdown") return { kind: "markdown", markdown: readContent(file) };
502+
if (kind === "mermaid") return { kind: "mermaid", mermaid: readContent(file) };
503+
if (kind === "diff")
504+
return {
505+
kind: "diff",
506+
patch: readContent(file),
507+
...(layout === "split" && { layout: "split" }),
508+
};
509+
if (kind === "terminal") return { kind: "terminal", text: readContent(file) };
510+
if (kind === "json") {
511+
const text = readContent(file);
512+
try {
513+
return { kind: "json", data: JSON.parse(text) };
514+
} catch {
515+
fail(`--json: invalid JSON${value && value !== "-" ? ` in ${value}` : ""}`);
516+
}
517+
}
518+
if (kind === "code") {
519+
const part = { kind: "code", code: readContent(file) };
520+
const codeLang = value && value !== "-" ? inferLang(value) : undefined;
521+
if (codeLang) part.language = codeLang;
522+
if (value && value !== "-") part.title = value.split("/").pop() || value;
523+
return part;
524+
}
525+
if (kind === "image") {
526+
const asset = await uploadFile(value, { session, kind: "image" });
527+
return { kind: "image", assetId: asset.id };
528+
}
529+
fail(`unknown surface kind: ${kind}`);
530+
}
531+
532+
// Walk parseArgs `tokens` (which preserve command-line order, including
533+
// repeats when a surface flag is `multiple: true`) and build one surface per
534+
// flag occurrence, pulling successive values from each flag's value array.
535+
// Surfaces render top-to-bottom, so order is user-visible — this honors the
536+
// order the author wrote the flags, repeats included.
537+
async function surfacesFromFlags(flags, tokens, { session, layout }) {
538+
const idx = new Map();
539+
const out = [];
540+
for (const t of tokens ?? []) {
541+
if (t.kind !== "option" || !SURFACE_FLAGS.has(t.name)) continue;
542+
const flagName = t.name;
543+
const arr = flags[flagName];
544+
if (!Array.isArray(arr)) continue;
545+
const i = idx.get(flagName) ?? 0;
546+
const value = arr[i];
547+
if (value === undefined) continue;
548+
idx.set(flagName, i + 1);
549+
out.push(await buildSurface(SURFACE_FLAGS.get(flagName), value, { session, layout }));
550+
}
551+
return out;
552+
}
553+
481554
async function publishSurface(parts, flags) {
482555
const session = await resolveSession(flags, { create: true });
483556
return api("/api/surfaces", {
@@ -545,11 +618,27 @@ const [cmd, ...rest] = process.argv.slice(2);
545618
// Subcommand flag parsing. parseArgs is strict, so without this --help (or
546619
// any typo) throws a raw stack trace; instead --help/-h prints usage and
547620
// exits 0, and an unknown option fails with a one-line hint.
621+
//
622+
// Ids are base64url and can start with - or _ (~1/64 each). parseArgs strict
623+
// mode treats those as unknown options ("Unknown option '-6'" for an id like
624+
// "-6K4AJsKD4M"). We swap any id-shaped token that starts with a separator
625+
// for a sentinel before parsing, then restore it in the result — so positionals,
626+
// tokens, and option values all get the original id back, in the right order.
627+
const ID_LIKE = /^[-_](?![-_])[A-Za-z0-9_-]{7,}$/;
548628
function parse(config = {}) {
629+
const rescued = new Map();
630+
const args = rest.map((a) => {
631+
if (ID_LIKE.test(a)) {
632+
const s = `\x00${rescued.size}\x00`;
633+
rescued.set(s, a);
634+
return s;
635+
}
636+
return a;
637+
});
549638
let parsed;
550639
try {
551640
parsed = parseArgs({
552-
args: rest,
641+
args,
553642
...config,
554643
options: { ...config.options, help: { type: "boolean", short: "h" } },
555644
});
@@ -561,6 +650,17 @@ function parse(config = {}) {
561650
console.log(HELP);
562651
process.exit(0);
563652
}
653+
const restore = (v) => (typeof v === "string" && rescued.has(v) ? rescued.get(v) : v);
654+
if (parsed.positionals) parsed.positionals = parsed.positionals.map(restore);
655+
if (parsed.tokens) {
656+
parsed.tokens = parsed.tokens.map((t) =>
657+
t.kind === "positional" && rescued.has(t.value) ? { ...t, value: rescued.get(t.value) } : t,
658+
);
659+
}
660+
for (const k of Object.keys(parsed.values ?? {})) {
661+
const v = parsed.values[k];
662+
parsed.values[k] = Array.isArray(v) ? v.map(restore) : restore(v);
663+
}
564664
return parsed;
565665
}
566666

@@ -825,13 +925,13 @@ const commands = {
825925
allowPositionals: true,
826926
options: {
827927
title: { type: "string" },
828-
md: { type: "string" },
829-
mermaid: { type: "string" },
830-
diff: { type: "string" },
831-
image: { type: "string" },
832-
terminal: { type: "string" },
833-
json: { type: "string" },
834-
code: { type: "string" },
928+
md: { type: "string", multiple: true },
929+
mermaid: { type: "string", multiple: true },
930+
diff: { type: "string", multiple: true },
931+
image: { type: "string", multiple: true },
932+
terminal: { type: "string", multiple: true },
933+
json: { type: "string", multiple: true },
934+
code: { type: "string", multiple: true },
835935
kit: { type: "string", multiple: true },
836936
layout: { type: "string" },
837937
session: { type: "string" },
@@ -843,61 +943,15 @@ const commands = {
843943
const htmlPart = { kind: "html", html: readContent(positionals[0]) };
844944
const kits = normalizeKits(flags.kit);
845945
if (kits) htmlPart.kits = kits;
846-
// Surfaces render top-to-bottom, so order is user-visible. Walk the
847-
// parseArgs tokens (which preserve command-line order) and append each
848-
// surface flag the first time it appears, instead of a fixed if-ladder.
849-
const SURFACE_FLAGS = new Map([
850-
["md", "markdown"],
851-
["mermaid", "mermaid"],
852-
["diff", "diff"],
853-
["terminal", "terminal"],
854-
["json", "json"],
855-
["code", "code"],
856-
["image", "image"],
857-
]);
858-
const orderedKinds = [];
859-
const seen = new Set();
860-
for (const t of tokens ?? []) {
861-
if (t.kind === "option" && SURFACE_FLAGS.has(t.name) && !seen.has(t.name)) {
862-
seen.add(t.name);
863-
orderedKinds.push(SURFACE_FLAGS.get(t.name));
864-
}
865-
}
866946
// Resolve the session first so image uploads and the post share it.
867947
const session = await resolveSession(flags, { create: true });
868-
const parts = [htmlPart];
869-
for (const kind of orderedKinds) {
870-
if (kind === "markdown") {
871-
parts.push({ kind: "markdown", markdown: readContent(flags.md || "-") });
872-
} else if (kind === "mermaid") {
873-
parts.push({ kind: "mermaid", mermaid: readContent(flags.mermaid || "-") });
874-
} else if (kind === "diff") {
875-
parts.push({
876-
kind: "diff",
877-
patch: readContent(flags.diff || "-"),
878-
...(flags.layout === "split" && { layout: "split" }),
879-
});
880-
} else if (kind === "terminal") {
881-
parts.push({ kind: "terminal", text: readContent(flags.terminal || "-") });
882-
} else if (kind === "json") {
883-
const text = readContent(flags.json || "-");
884-
try {
885-
parts.push({ kind: "json", data: JSON.parse(text) });
886-
} catch {
887-
fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`);
888-
}
889-
} else if (kind === "code") {
890-
const codeFile = flags.code || "-";
891-
const part = { kind: "code", code: readContent(codeFile) };
892-
const codeLang = codeFile !== "-" ? inferLang(codeFile) : undefined;
893-
if (codeLang) part.language = codeLang;
894-
if (codeFile !== "-") part.title = codeFile.split("/").pop() || codeFile;
895-
parts.push(part);
896-
} else if (kind === "image") {
897-
const asset = await uploadFile(flags.image, { session, kind: "image" });
898-
parts.push({ kind: "image", assetId: asset.id });
899-
}
900-
}
948+
// Surfaces render top-to-bottom, so order is user-visible. `surfacesFromFlags`
949+
// walks parseArgs tokens (command-line order, repeats included) and builds
950+
// one surface per flag occurrence — so --diff a --diff b yields two diffs.
951+
const parts = [
952+
htmlPart,
953+
...(await surfacesFromFlags(flags, tokens, { session, layout: flags.layout })),
954+
];
901955
outSurface(await publishSurface(parts, { ...flags, session }));
902956
},
903957

@@ -1148,13 +1202,13 @@ const commands = {
11481202
tokens: true,
11491203
allowPositionals: true,
11501204
options: {
1151-
md: { type: "string" },
1152-
mermaid: { type: "string" },
1153-
diff: { type: "string" },
1154-
terminal: { type: "string" },
1155-
json: { type: "string" },
1156-
code: { type: "string" },
1157-
image: { type: "string" },
1205+
md: { type: "string", multiple: true },
1206+
mermaid: { type: "string", multiple: true },
1207+
diff: { type: "string", multiple: true },
1208+
terminal: { type: "string", multiple: true },
1209+
json: { type: "string", multiple: true },
1210+
code: { type: "string", multiple: true },
1211+
image: { type: "string", multiple: true },
11581212
before: { type: "string" },
11591213
after: { type: "string" },
11601214
layout: { type: "string" },
@@ -1164,57 +1218,13 @@ const commands = {
11641218
const postId = positionals[0];
11651219
if (!postId) fail("usage: sideshow surface add <postId> [--md f] [--code f] ...");
11661220

1167-
const SURFACE_FLAGS = new Map([
1168-
["md", "markdown"],
1169-
["mermaid", "mermaid"],
1170-
["diff", "diff"],
1171-
["terminal", "terminal"],
1172-
["json", "json"],
1173-
["code", "code"],
1174-
["image", "image"],
1175-
]);
1176-
const orderedKinds = [];
1177-
const seen = new Set();
1178-
for (const t of tokens ?? []) {
1179-
if (t.kind === "option" && SURFACE_FLAGS.has(t.name) && !seen.has(t.name)) {
1180-
seen.add(t.name);
1181-
orderedKinds.push(SURFACE_FLAGS.get(t.name));
1182-
}
1183-
}
1184-
if (orderedKinds.length === 0) fail("provide at least one surface flag (--md, --code, ...)");
11851221
const session = await resolveSession(flags, { create: true });
1222+
const surfaces = await surfacesFromFlags(flags, tokens, { session, layout: flags.layout });
1223+
if (surfaces.length === 0) fail("provide at least one surface flag (--md, --code, ...)");
1224+
// Each surface is a separate append call so --before/--after positioning
1225+
// applies per surface (repeats append in command-line order).
11861226
let lastResult;
1187-
for (const kind of orderedKinds) {
1188-
let surface;
1189-
if (kind === "markdown") {
1190-
surface = { kind: "markdown", markdown: readContent(flags.md || "-") };
1191-
} else if (kind === "mermaid") {
1192-
surface = { kind: "mermaid", mermaid: readContent(flags.mermaid || "-") };
1193-
} else if (kind === "diff") {
1194-
surface = {
1195-
kind: "diff",
1196-
patch: readContent(flags.diff || "-"),
1197-
...(flags.layout === "split" && { layout: "split" }),
1198-
};
1199-
} else if (kind === "terminal") {
1200-
surface = { kind: "terminal", text: readContent(flags.terminal || "-") };
1201-
} else if (kind === "json") {
1202-
const text = readContent(flags.json || "-");
1203-
try {
1204-
surface = { kind: "json", data: JSON.parse(text) };
1205-
} catch {
1206-
fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`);
1207-
}
1208-
} else if (kind === "code") {
1209-
const codeFile = flags.code || "-";
1210-
surface = { kind: "code", code: readContent(codeFile) };
1211-
const codeLang = codeFile !== "-" ? inferLang(codeFile) : undefined;
1212-
if (codeLang) surface.language = codeLang;
1213-
if (codeFile !== "-") surface.title = codeFile.split("/").pop() || codeFile;
1214-
} else if (kind === "image") {
1215-
const asset = await uploadFile(flags.image, { session, kind: "image" });
1216-
surface = { kind: "image", assetId: asset.id };
1217-
}
1227+
for (const surface of surfaces) {
12181228
const body = { surface };
12191229
if (flags.before !== undefined) body.before = flags.before;
12201230
if (flags.after !== undefined) body.after = flags.after;

0 commit comments

Comments
 (0)