@minhpnq1807/contextos 0.5.39 → 0.5.40

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.40
4
+
5
+ - **Update notifier:** `ctx` now checks npm for newer versions in the background (once per day, 3s timeout). If a newer release exists, a boxed notice is printed at the very end of any command: `Update available: 0.5.39 → 0.5.40`. Check result is cached in `$CONTEXTOS_DATA/.update-check.json` to avoid repeated network calls.
6
+ - **Community skill library browser (`ctx skills`):** New command to browse curated skill libraries from the community. Fetches and parses README files from 4 sources:
7
+ - [antigravity-awesome-skills](https://github.com/sickn33/antigravity-awesome-skills) — 1,400+ universal skills
8
+ - [awesome-claude-skills](https://github.com/ComposioHQ/awesome-claude-skills) — Claude Code skills & workflows
9
+ - [awesome-codex-skills](https://github.com/ComposioHQ/awesome-codex-skills) — Codex CLI skills & automations
10
+ - [awesome-copilot](https://github.com/github/awesome-copilot) — GitHub Copilot instructions & agents
11
+ Results are cached for 24 hours. Use `--agents <names>` to filter and `--refresh` to force refetch.
12
+ - **Post-install skill recommendations:** After interactive `ctx install` or `ctx setup`, a styled recommendation panel shows top 5 skills from each relevant library with descriptions and repo URLs. This guides new users toward useful community skills immediately after setup.
13
+ - **Test stability:** Increased timeout for MCP bridge fallback test to prevent CI flakiness.
14
+
3
15
  ## 0.5.39
4
16
 
5
17
  - **Report layout fix:** Replaced ASCII table formatting with clean markdown output. Reports now render correctly in all agent UIs (Antigravity, Claude Code, Codex) without truncation or line-wrapping issues.
package/bin/ctx.js CHANGED
@@ -34,6 +34,8 @@ import { parsePassthroughArgs, runPassthrough } from "../plugins/ctx/lib/passthr
34
34
  import { parseAgentList, parseSetupArgs, setupSummaryLines } from "../plugins/ctx/lib/setup-wizard.js";
35
35
  import { multiSelect } from "../plugins/ctx/lib/multi-select.js";
36
36
  import { syncWorkflows, warmWorkflowEmbeddings } from "../plugins/ctx/lib/workflow-discoverer.js";
37
+ import { checkForUpdate } from "../plugins/ctx/lib/update-notifier.js";
38
+ import { fetchSkillsForAgents, printSkillRecommendations, getAllLibraries } from "../plugins/ctx/lib/skill-library.js";
37
39
 
38
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
41
  const rootDir = path.resolve(__dirname, "..");
@@ -70,6 +72,9 @@ Usage:
70
72
  ctx sync --workflows Sync workflows across agents
71
73
  ctx sync --workflows --agents <names> Sync workflows to specific agents
72
74
  ctx sync --workflows --dry-run Preview workflow sync without writing
75
+ ctx skills Browse community skill libraries
76
+ ctx skills --agents <names> Filter skills for specific agents
77
+ ctx skills --refresh Force refresh skill library cache
73
78
  ctx embeddings warm -- "task" Pre-warm embedding caches for a task
74
79
  ctx ruler -- <ruler args> Passthrough to ruler CLI
75
80
  ctx skillshare -- <skillshare args> Passthrough to skillshare CLI
@@ -608,6 +613,12 @@ async function setup({ args = [], cwd = process.cwd() } = {}) {
608
613
  console.log("│ Next: restart/open your agent from this project directory.");
609
614
  console.log("│ Try: ctx debug -- \"Recheck authen flow\"");
610
615
  console.log("");
616
+
617
+ // Recommend community skills based on selected agents
618
+ try {
619
+ const libraryResults = await fetchSkillsForAgents(options.agents, { dataDir: contextOSDataDir() });
620
+ printSkillRecommendations(libraryResults);
621
+ } catch { /* skill library is best-effort */ }
611
622
  }
612
623
 
613
624
  const args = process.argv.slice(2);
@@ -622,6 +633,8 @@ function installAgentsFromArgs(args) {
622
633
  return null; // no flag → interactive selection
623
634
  }
624
635
 
636
+ const notifyUpdate = checkForUpdate({ currentVersion: packageVersion(), dataDir: contextOSDataDir() });
637
+
625
638
  try {
626
639
  if (!command || command === "--help" || command === "-h" || command === "help") {
627
640
  console.log(usage());
@@ -654,6 +667,11 @@ try {
654
667
  await streamSetupOutput(() => install({ copy, agent }));
655
668
  console.log("");
656
669
  }
670
+ // Recommend community skills based on selected agents
671
+ try {
672
+ const libraryResults = await fetchSkillsForAgents(selected, { dataDir: contextOSDataDir() });
673
+ printSkillRecommendations(libraryResults);
674
+ } catch { /* skill library is best-effort */ }
657
675
  }
658
676
  }
659
677
  } else if (command === "setup") {
@@ -682,6 +700,31 @@ try {
682
700
  const task = marker >= 0 ? args.slice(marker + 1).join(" ") : args.slice(1).join(" ");
683
701
  if (!task.trim()) throw new Error('Usage: ctx benchmark -- "task"');
684
702
  console.log(formatBenchmark(benchmarkWorkspace({ cwd: process.cwd(), task })));
703
+ } else if (command === "skills") {
704
+ // Browse community skill libraries
705
+ const agentsFlag = args.indexOf("--agents");
706
+ const forceRefresh = args.includes("--refresh");
707
+ let agents;
708
+ if (agentsFlag >= 0 && args[agentsFlag + 1]) {
709
+ agents = args[agentsFlag + 1].split(",").map((a) => a.trim()).filter(Boolean);
710
+ } else {
711
+ agents = ["codex", "claude", "agy", "copilot"];
712
+ }
713
+ console.log("Fetching community skill libraries...\n");
714
+ const libraryResults = await fetchSkillsForAgents(agents, {
715
+ dataDir: contextOSDataDir()
716
+ });
717
+ printSkillRecommendations(libraryResults);
718
+ console.log("");
719
+ const totalSkills = libraryResults.reduce((sum, r) => sum + r.count, 0);
720
+ if (totalSkills === 0) {
721
+ console.log("No skills found. Check your network connection or try --refresh.");
722
+ } else {
723
+ console.log(`Total: ${totalSkills} skills across ${libraryResults.filter((r) => r.count > 0).length} libraries.`);
724
+ console.log("");
725
+ console.log("To install skills, visit the library URLs above or use:");
726
+ console.log(" ctx sync --skills Sync skills across agents via skillshare");
727
+ }
685
728
  } else if (command === "sync") {
686
729
  if (args.includes("--workflows")) {
687
730
  await syncWorkflows({
@@ -719,4 +762,6 @@ try {
719
762
  } catch (error) {
720
763
  console.error(error.message);
721
764
  process.exitCode = 1;
765
+ } finally {
766
+ await notifyUpdate();
722
767
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhpnq1807/contextos",
3
- "version": "0.5.39",
3
+ "version": "0.5.40",
4
4
  "description": "Task-aware AGENTS.md context injection and compliance reporting for Codex, Claude Code, and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,290 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import https from "node:https";
4
+ import os from "node:os";
5
+
6
+ /**
7
+ * Curated skill library sources, mapped by agent.
8
+ *
9
+ * Each source provides a GitHub repo with a README that lists available skills.
10
+ * We fetch the README, parse skill entries, and let the user pick from a
11
+ * multi-select panel after agent selection in `ctx install` or `ctx setup`.
12
+ */
13
+
14
+ const SKILL_LIBRARIES = [
15
+ {
16
+ id: "antigravity-awesome",
17
+ name: "Antigravity Awesome Skills",
18
+ repo: "sickn33/antigravity-awesome-skills",
19
+ url: "https://github.com/sickn33/antigravity-awesome-skills",
20
+ rawReadmeUrl: "https://raw.githubusercontent.com/sickn33/antigravity-awesome-skills/main/README.md",
21
+ agents: ["agy", "claude", "codex", "copilot"], // universal library
22
+ description: "1,400+ agentic skills for all agents"
23
+ },
24
+ {
25
+ id: "awesome-claude",
26
+ name: "Awesome Claude Skills",
27
+ repo: "ComposioHQ/awesome-claude-skills",
28
+ url: "https://github.com/ComposioHQ/awesome-claude-skills",
29
+ rawReadmeUrl: "https://raw.githubusercontent.com/ComposioHQ/awesome-claude-skills/main/README.md",
30
+ agents: ["claude"],
31
+ description: "Curated Claude Code skills & workflows"
32
+ },
33
+ {
34
+ id: "awesome-codex",
35
+ name: "Awesome Codex Skills",
36
+ repo: "ComposioHQ/awesome-codex-skills",
37
+ url: "https://github.com/ComposioHQ/awesome-codex-skills",
38
+ rawReadmeUrl: "https://raw.githubusercontent.com/ComposioHQ/awesome-codex-skills/main/README.md",
39
+ agents: ["codex"],
40
+ description: "Practical Codex CLI skills & automations"
41
+ },
42
+ {
43
+ id: "awesome-copilot",
44
+ name: "Awesome Copilot",
45
+ repo: "github/awesome-copilot",
46
+ url: "https://github.com/github/awesome-copilot",
47
+ rawReadmeUrl: "https://raw.githubusercontent.com/github/awesome-copilot/main/README.md",
48
+ agents: ["copilot"],
49
+ description: "Community GitHub Copilot instructions & agents"
50
+ }
51
+ ];
52
+
53
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
54
+
55
+ // ────────────────────────────── HTTP fetch ────────────────────────────────
56
+
57
+ function fetchUrl(url, timeoutMs = 10000) {
58
+ return new Promise((resolve, reject) => {
59
+ const req = https.get(url, { timeout: timeoutMs }, (res) => {
60
+ // Follow redirects (301/302/307/308)
61
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
62
+ fetchUrl(res.headers.location, timeoutMs).then(resolve, reject);
63
+ return;
64
+ }
65
+ if (res.statusCode !== 200) {
66
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
67
+ res.resume();
68
+ return;
69
+ }
70
+ const chunks = [];
71
+ res.on("data", (chunk) => chunks.push(chunk));
72
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
73
+ res.on("error", reject);
74
+ });
75
+ req.on("timeout", () => { req.destroy(); reject(new Error(`Timeout fetching ${url}`)); });
76
+ req.on("error", reject);
77
+ });
78
+ }
79
+
80
+ // ────────────────────────────── README parser ─────────────────────────────
81
+
82
+ /**
83
+ * Parse a GitHub awesome-list README and extract skill entries.
84
+ *
85
+ * Looks for markdown links in list items:
86
+ * - [Skill Name](url) - Description
87
+ * - **[Skill Name](url)** — Description
88
+ *
89
+ * Returns: Array<{ name, url, description }>
90
+ */
91
+ export function parseSkillEntries(readmeContent) {
92
+ const entries = [];
93
+ const lines = readmeContent.split("\n");
94
+ // Match list items with markdown links
95
+ const linkPattern = /^[-*]\s+\**\[([^\]]+)\]\(([^)]+)\)\**\s*[-–—:]*\s*(.*)/;
96
+ for (const line of lines) {
97
+ const match = line.match(linkPattern);
98
+ if (!match) continue;
99
+ const [, name, url, description] = match;
100
+ // Skip non-skill links (images, badges, section headers)
101
+ if (/\.(png|jpg|svg|gif)$/i.test(url)) continue;
102
+ if (url.startsWith("#")) continue;
103
+ entries.push({
104
+ name: name.trim(),
105
+ url: url.trim(),
106
+ description: (description || "").replace(/^\s*[-–—:]+\s*/, "").trim()
107
+ });
108
+ }
109
+ return entries;
110
+ }
111
+
112
+ // ────────────────────────────── Cache layer ────────────────────────────────
113
+
114
+ function cacheDir(dataDir) {
115
+ return path.join(dataDir, "skill-library-cache");
116
+ }
117
+
118
+ function cacheFilePath(dataDir, libraryId) {
119
+ return path.join(cacheDir(dataDir), `${libraryId}.json`);
120
+ }
121
+
122
+ function readCache(dataDir, libraryId) {
123
+ const filePath = cacheFilePath(dataDir, libraryId);
124
+ try {
125
+ const raw = fs.readFileSync(filePath, "utf8");
126
+ const data = JSON.parse(raw);
127
+ if (Date.now() - (data.fetchedAt || 0) < CACHE_TTL_MS) {
128
+ return data.entries || [];
129
+ }
130
+ } catch { /* cache miss */ }
131
+ return null;
132
+ }
133
+
134
+ function writeCache(dataDir, libraryId, entries) {
135
+ const dir = cacheDir(dataDir);
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ const data = { fetchedAt: Date.now(), entries };
138
+ fs.writeFileSync(cacheFilePath(dataDir, libraryId), JSON.stringify(data, null, 2));
139
+ }
140
+
141
+ // ────────────────────────────── Public API ─────────────────────────────────
142
+
143
+ /**
144
+ * Get libraries relevant to selected agents.
145
+ */
146
+ export function getLibrariesForAgents(selectedAgents) {
147
+ const normalized = selectedAgents.map((a) => a.toLowerCase().replace("antigravity", "agy"));
148
+ return SKILL_LIBRARIES.filter((lib) =>
149
+ lib.agents.some((agent) => normalized.includes(agent))
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Fetch skill entries from a library (with caching).
155
+ */
156
+ export async function fetchLibrarySkills(library, { dataDir, forceRefresh = false } = {}) {
157
+ if (dataDir && !forceRefresh) {
158
+ const cached = readCache(dataDir, library.id);
159
+ if (cached) return { entries: cached, source: "cache" };
160
+ }
161
+
162
+ try {
163
+ const readme = await fetchUrl(library.rawReadmeUrl);
164
+ const entries = parseSkillEntries(readme);
165
+ if (dataDir && entries.length > 0) {
166
+ writeCache(dataDir, library.id, entries);
167
+ }
168
+ return { entries, source: "network" };
169
+ } catch (error) {
170
+ // Try cache even if expired on network failure
171
+ if (dataDir) {
172
+ try {
173
+ const filePath = cacheFilePath(dataDir, library.id);
174
+ const raw = fs.readFileSync(filePath, "utf8");
175
+ const data = JSON.parse(raw);
176
+ return { entries: data.entries || [], source: "stale-cache" };
177
+ } catch { /* no cache at all */ }
178
+ }
179
+ return { entries: [], source: "error", error: error.message };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Fetch skills from all relevant libraries for selected agents.
185
+ * Returns a flat list grouped by library.
186
+ */
187
+ export async function fetchSkillsForAgents(selectedAgents, { dataDir } = {}) {
188
+ const libraries = getLibrariesForAgents(selectedAgents);
189
+ const results = [];
190
+
191
+ for (const lib of libraries) {
192
+ const { entries, source } = await fetchLibrarySkills(lib, { dataDir });
193
+ results.push({
194
+ library: lib,
195
+ entries,
196
+ source,
197
+ count: entries.length
198
+ });
199
+ }
200
+
201
+ return results;
202
+ }
203
+
204
+ /**
205
+ * Format skill library results as multi-select options.
206
+ * Groups by library with a header option (disabled).
207
+ */
208
+ export function formatAsSelectOptions(libraryResults) {
209
+ const options = [];
210
+ for (const result of libraryResults) {
211
+ if (result.entries.length === 0) continue;
212
+ // Add library header as separator
213
+ options.push({
214
+ label: `── ${result.library.name} (${result.count} skills) ──`,
215
+ value: `__header__${result.library.id}`,
216
+ disabled: true,
217
+ isHeader: true
218
+ });
219
+ // Add top skills (limit to prevent overwhelming the user)
220
+ const topEntries = result.entries.slice(0, 30);
221
+ for (const entry of topEntries) {
222
+ const desc = entry.description ? ` — ${entry.description.slice(0, 60)}` : "";
223
+ options.push({
224
+ label: `${entry.name}${desc}`,
225
+ value: entry.url,
226
+ selected: false,
227
+ libraryId: result.library.id,
228
+ skillName: entry.name
229
+ });
230
+ }
231
+ if (result.entries.length > 30) {
232
+ options.push({
233
+ label: ` ... and ${result.entries.length - 30} more at ${result.library.url}`,
234
+ value: `__more__${result.library.id}`,
235
+ disabled: true,
236
+ isHeader: true
237
+ });
238
+ }
239
+ }
240
+ return options;
241
+ }
242
+
243
+ /**
244
+ * Print a compact recommendation panel to the terminal.
245
+ * Used after agent selection in `ctx install` and `ctx setup`.
246
+ */
247
+ export function printSkillRecommendations(libraryResults, { logger = console.log } = {}) {
248
+ const DIM = "\x1B[2m";
249
+ const RESET = "\x1B[0m";
250
+ const CYAN = "\x1B[36m";
251
+ const GREEN = "\x1B[32m";
252
+ const YELLOW = "\x1B[33m";
253
+ const BOLD = "\x1B[1m";
254
+
255
+ const hasSkills = libraryResults.some((r) => r.entries.length > 0);
256
+ if (!hasSkills) return;
257
+
258
+ logger("");
259
+ logger(`${CYAN}◇${RESET} ${BOLD}Community skill libraries available:${RESET}`);
260
+ logger(`${DIM}│${RESET} Browse and install curated skills from the community.`);
261
+ logger(`${DIM}│${RESET}`);
262
+
263
+ for (const result of libraryResults) {
264
+ if (result.entries.length === 0) continue;
265
+ const badge = result.source === "cache" ? `${DIM}(cached)${RESET}` : "";
266
+ logger(`${DIM}│${RESET} ${GREEN}●${RESET} ${BOLD}${result.library.name}${RESET} ${badge}`);
267
+ logger(`${DIM}│${RESET} ${result.count} skills · ${result.library.description}`);
268
+ logger(`${DIM}│${RESET} ${DIM}${result.library.url}${RESET}`);
269
+
270
+ // Show top 5 skills as preview
271
+ const preview = result.entries.slice(0, 5);
272
+ for (const skill of preview) {
273
+ const desc = skill.description ? ` ${DIM}— ${skill.description.slice(0, 50)}${RESET}` : "";
274
+ logger(`${DIM}│${RESET} ${YELLOW}▸${RESET} ${skill.name}${desc}`);
275
+ }
276
+ if (result.entries.length > 5) {
277
+ logger(`${DIM}│${RESET} ${DIM}... and ${result.entries.length - 5} more${RESET}`);
278
+ }
279
+ logger(`${DIM}│${RESET}`);
280
+ }
281
+
282
+ logger(`${DIM}│${RESET} ${DIM}Run ${CYAN}ctx skills${RESET}${DIM} to browse and install community skills.${RESET}`);
283
+ }
284
+
285
+ /**
286
+ * Get all library definitions.
287
+ */
288
+ export function getAllLibraries() {
289
+ return SKILL_LIBRARIES;
290
+ }
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const PACKAGE_NAME = "@minhpnq1807/contextos";
5
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 1 day
6
+ const REQUEST_TIMEOUT_MS = 3000;
7
+
8
+ function cacheFilePath(dataDir) {
9
+ return path.join(dataDir, ".update-check.json");
10
+ }
11
+
12
+ function readCache(dataDir) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(cacheFilePath(dataDir), "utf8"));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function writeCache(dataDir, data) {
21
+ try {
22
+ fs.mkdirSync(dataDir, { recursive: true });
23
+ fs.writeFileSync(cacheFilePath(dataDir), JSON.stringify(data), "utf8");
24
+ } catch {
25
+ // best-effort
26
+ }
27
+ }
28
+
29
+ async function fetchLatestVersion() {
30
+ const https = await import("node:https");
31
+ return new Promise((resolve) => {
32
+ const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
33
+ const req = https.get(url, { timeout: REQUEST_TIMEOUT_MS, headers: { accept: "application/json" } }, (res) => {
34
+ if (res.statusCode !== 200) {
35
+ res.resume();
36
+ return resolve(null);
37
+ }
38
+ let body = "";
39
+ res.on("data", (chunk) => { body += chunk; });
40
+ res.on("end", () => {
41
+ try {
42
+ resolve(JSON.parse(body).version || null);
43
+ } catch {
44
+ resolve(null);
45
+ }
46
+ });
47
+ });
48
+ req.on("error", () => resolve(null));
49
+ req.on("timeout", () => { req.destroy(); resolve(null); });
50
+ });
51
+ }
52
+
53
+ function compareVersions(current, latest) {
54
+ const parse = (v) => String(v || "").replace(/^v/, "").split(".").map(Number);
55
+ const c = parse(current);
56
+ const l = parse(latest);
57
+ for (let i = 0; i < 3; i++) {
58
+ if ((l[i] || 0) > (c[i] || 0)) return 1;
59
+ if ((l[i] || 0) < (c[i] || 0)) return -1;
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ /**
65
+ * Check for updates in background (non-blocking).
66
+ * Returns a function that, when called, prints the update message if available.
67
+ *
68
+ * Usage:
69
+ * const notify = checkForUpdate({ currentVersion, dataDir });
70
+ * // ... run CLI command ...
71
+ * await notify(); // prints update message at the very end
72
+ */
73
+ export function checkForUpdate({ currentVersion, dataDir }) {
74
+ let resultPromise = null;
75
+
76
+ // Start background check lazily
77
+ function startCheck() {
78
+ if (resultPromise) return resultPromise;
79
+ resultPromise = (async () => {
80
+ try {
81
+ const cache = readCache(dataDir);
82
+ const isFresh = cache && typeof cache.checkedAt === "number" && (Date.now() - cache.checkedAt < CHECK_INTERVAL_MS);
83
+
84
+ if (isFresh) {
85
+ return cache.latestVersion && compareVersions(currentVersion, cache.latestVersion) > 0
86
+ ? cache.latestVersion
87
+ : null;
88
+ }
89
+
90
+ const latestVersion = await fetchLatestVersion();
91
+ if (latestVersion) {
92
+ writeCache(dataDir, { checkedAt: Date.now(), latestVersion });
93
+ }
94
+
95
+ return latestVersion && compareVersions(currentVersion, latestVersion) > 0
96
+ ? latestVersion
97
+ : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ })();
102
+ return resultPromise;
103
+ }
104
+
105
+ // Start immediately (non-blocking)
106
+ startCheck();
107
+
108
+ return async () => {
109
+ const latestVersion = await startCheck();
110
+ if (latestVersion) {
111
+ console.error(formatUpdateBox(currentVersion, latestVersion));
112
+ }
113
+ };
114
+ }
115
+
116
+ function formatUpdateBox(currentVersion, latestVersion) {
117
+ const lines = [
118
+ `Update available: ${currentVersion} → ${latestVersion}`,
119
+ "",
120
+ `Run: npm install -g ${PACKAGE_NAME}`,
121
+ `Then run: ctx install --agents codex`,
122
+ ];
123
+ const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0);
124
+ const width = maxLen + 4;
125
+ const pad = (text) => `│ ${text.padEnd(width - 4)} │`;
126
+ const top = `╭${"─".repeat(width - 2)}╮`;
127
+ const bottom = `╰${"─".repeat(width - 2)}╯`;
128
+ return ["", top, ...lines.map(pad), bottom, ""].join("\n");
129
+ }