@marcfargas/skills 0.2.0 → 0.3.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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync } from "child_process";
4
+ import { readFileSync, unlinkSync } from "fs";
5
+ import { join } from "path";
6
+ import { tmpdir } from "os";
7
+ import { randomBytes } from "crypto";
8
+ import { Readability } from "@mozilla/readability";
9
+ import { JSDOM } from "jsdom";
10
+ import TurndownService from "turndown";
11
+ import { gfm } from "turndown-plugin-gfm";
12
+
13
+ const args = process.argv.slice(2);
14
+
15
+ // Parse --content flag
16
+ const contentIndex = args.indexOf("--content");
17
+ const fetchContent = contentIndex !== -1;
18
+ if (fetchContent) args.splice(contentIndex, 1);
19
+
20
+ // Parse --news flag
21
+ const newsIndex = args.indexOf("--news");
22
+ const isNews = newsIndex !== -1;
23
+ if (isNews) args.splice(newsIndex, 1);
24
+
25
+ // Parse -n <num>
26
+ let numResults = 5;
27
+ const nIndex = args.indexOf("-n");
28
+ if (nIndex !== -1 && args[nIndex + 1]) {
29
+ numResults = parseInt(args[nIndex + 1], 10);
30
+ args.splice(nIndex, 2);
31
+ }
32
+
33
+ // Parse -r <region>
34
+ let region = null;
35
+ const rIndex = args.indexOf("-r");
36
+ if (rIndex !== -1 && args[rIndex + 1]) {
37
+ region = args[rIndex + 1];
38
+ args.splice(rIndex, 2);
39
+ }
40
+
41
+ // Parse -t <timelimit>
42
+ let timelimit = null;
43
+ const tIndex = args.indexOf("-t");
44
+ if (tIndex !== -1 && args[tIndex + 1]) {
45
+ timelimit = args[tIndex + 1];
46
+ args.splice(tIndex, 2);
47
+ }
48
+
49
+ // Parse -b <backend>
50
+ let backend = null;
51
+ const bIndex = args.indexOf("-b");
52
+ if (bIndex !== -1 && args[bIndex + 1]) {
53
+ backend = args[bIndex + 1];
54
+ args.splice(bIndex, 2);
55
+ }
56
+
57
+ const query = args.join(" ");
58
+
59
+ if (!query) {
60
+ console.log("Usage: search.js [--news] <query> [-n <num>] [--content] [-r <region>] [-t <timelimit>] [-b <backend>]");
61
+ console.log("\nOptions:");
62
+ console.log(" --news News search instead of web search");
63
+ console.log(" -n <num> Number of results (default: 5)");
64
+ console.log(" --content Fetch readable content as markdown");
65
+ console.log(" -r <region> Region: us-en, es-es, de-de, fr-fr, etc.");
66
+ console.log(" -t <timelimit> Time filter: d (day), w (week), m (month), y (year)");
67
+ console.log(" -b <backend> Backend: auto, all, bing, brave, duckduckgo, google, etc.");
68
+ console.log("\nExamples:");
69
+ console.log(' search.js "javascript async await"');
70
+ console.log(' search.js "rust programming" -n 10');
71
+ console.log(' search.js "climate change" --content');
72
+ console.log(' search.js --news "AI agents" -t w');
73
+ process.exit(1);
74
+ }
75
+
76
+ function htmlToMarkdown(html) {
77
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
78
+ turndown.use(gfm);
79
+ turndown.addRule("removeEmptyLinks", {
80
+ filter: (node) => node.nodeName === "A" && !node.textContent?.trim(),
81
+ replacement: () => "",
82
+ });
83
+ return turndown
84
+ .turndown(html)
85
+ .replace(/\[\\?\[\s*\\?\]\]\([^)]*\)/g, "")
86
+ .replace(/ +/g, " ")
87
+ .replace(/\s+,/g, ",")
88
+ .replace(/\s+\./g, ".")
89
+ .replace(/\n{3,}/g, "\n\n")
90
+ .trim();
91
+ }
92
+
93
+ async function fetchPageContent(url) {
94
+ try {
95
+ const response = await fetch(url, {
96
+ headers: {
97
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
98
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
99
+ },
100
+ signal: AbortSignal.timeout(10000),
101
+ });
102
+
103
+ if (!response.ok) {
104
+ return `(HTTP ${response.status})`;
105
+ }
106
+
107
+ const html = await response.text();
108
+ const dom = new JSDOM(html, { url });
109
+ const reader = new Readability(dom.window.document);
110
+ const article = reader.parse();
111
+
112
+ if (article && article.content) {
113
+ return htmlToMarkdown(article.content).substring(0, 5000);
114
+ }
115
+
116
+ // Fallback: try to get main content
117
+ const fallbackDoc = new JSDOM(html, { url });
118
+ const body = fallbackDoc.window.document;
119
+ body.querySelectorAll("script, style, noscript, nav, header, footer, aside").forEach((el) => el.remove());
120
+ const main = body.querySelector("main, article, [role='main'], .content, #content") || body.body;
121
+ const text = main?.textContent || "";
122
+
123
+ if (text.trim().length > 100) {
124
+ return text.trim().substring(0, 5000);
125
+ }
126
+
127
+ return "(Could not extract content)";
128
+ } catch (e) {
129
+ return `(Error: ${e.message})`;
130
+ }
131
+ }
132
+
133
+ function runDdgs(query, numResults, isNews, region, timelimit, backend) {
134
+ const tmpFile = join(tmpdir(), `ddgs-${randomBytes(6).toString("hex")}.json`);
135
+ const subcommand = isNews ? "news" : "text";
136
+
137
+ const ddgsArgs = [subcommand, "-q", query, "-m", String(numResults), "-o", tmpFile];
138
+
139
+ if (region) {
140
+ ddgsArgs.push("-r", region);
141
+ }
142
+ if (timelimit) {
143
+ ddgsArgs.push("-t", timelimit);
144
+ }
145
+ if (backend) {
146
+ ddgsArgs.push("-b", backend);
147
+ }
148
+
149
+ try {
150
+ execFileSync("ddgs", ddgsArgs, {
151
+ stdio: ["pipe", "pipe", "pipe"],
152
+ timeout: 30000,
153
+ });
154
+ } catch (e) {
155
+ // ddgs may write output to file even if it exits with error
156
+ // (e.g. "Aborted!" after printing results)
157
+ }
158
+
159
+ try {
160
+ const raw = readFileSync(tmpFile, "utf-8");
161
+ unlinkSync(tmpFile);
162
+ return JSON.parse(raw);
163
+ } catch {
164
+ try {
165
+ unlinkSync(tmpFile);
166
+ } catch {}
167
+ throw new Error("ddgs produced no results. Is ddgs installed? Run: uv tool install ddgs");
168
+ }
169
+ }
170
+
171
+ // Main
172
+ try {
173
+ const rawResults = runDdgs(query, numResults, isNews, region, timelimit, backend);
174
+
175
+ if (!rawResults || rawResults.length === 0) {
176
+ console.error("No results found.");
177
+ process.exit(0);
178
+ }
179
+
180
+ // Normalize results to a common shape
181
+ const results = rawResults.map((r) => ({
182
+ title: r.title || "",
183
+ link: r.href || r.url || "",
184
+ snippet: r.body || "",
185
+ date: r.date || "",
186
+ source: r.source || "",
187
+ }));
188
+
189
+ if (fetchContent) {
190
+ for (const result of results) {
191
+ result.content = await fetchPageContent(result.link);
192
+ }
193
+ }
194
+
195
+ for (let i = 0; i < results.length; i++) {
196
+ const r = results[i];
197
+ console.log(`--- Result ${i + 1} ---`);
198
+ console.log(`Title: ${r.title}`);
199
+ console.log(`Link: ${r.link}`);
200
+ if (r.date) {
201
+ console.log(`Date: ${r.date}`);
202
+ }
203
+ if (r.source) {
204
+ console.log(`Source: ${r.source}`);
205
+ }
206
+ console.log(`Snippet: ${r.snippet}`);
207
+ if (r.content) {
208
+ console.log(`Content:\n${r.content}`);
209
+ }
210
+ console.log("");
211
+ }
212
+ } catch (e) {
213
+ console.error(`Error: ${e.message}`);
214
+ process.exit(1);
215
+ }
@@ -106,13 +106,23 @@ Require git # Fail if git not in PATH
106
106
  Require node
107
107
  ```
108
108
 
109
+ ## Windows: Use `Set Shell "cmd"`
110
+
111
+ VHS's default `bash` resolves to WSL bash on Windows — a separate environment with no access to Windows-installed tools (node, scoop packages, etc.). **Always use `cmd`** on Windows:
112
+
113
+ ```tape
114
+ Set Shell "cmd"
115
+ ```
116
+
117
+ This gives VHS the full Windows PATH so all installed CLIs work.
118
+
109
119
  ## Recommended Defaults
110
120
 
111
121
  For README demos and documentation:
112
122
 
113
123
  ```tape
114
124
  Output demo.gif
115
- Set Shell "bash"
125
+ Set Shell "cmd" # Windows — use "bash" on Linux/macOS
116
126
  Set FontSize 14
117
127
  Set Width 1100
118
128
  Set Height 600
@@ -211,6 +221,82 @@ vhs record > my-session.tape
211
221
  vhs my-session.tape
212
222
  ```
213
223
 
224
+ ### Recording TUI apps with holdpty
225
+
226
+ Use [holdpty](https://github.com/marcfargas/holdpty) to record full-color TUI applications — apps that need a real PTY for colors, take time to start, or are already running. holdpty provides the PTY, VHS captures the output.
227
+
228
+ **Why this matters:**
229
+ - TUI apps (pi, htop, k9s, lazygit) need a real PTY for colors and layout
230
+ - Slow-starting apps (AI agents, servers) can be ready before recording begins
231
+ - Already-running processes can be recorded on demand for monitoring/sharing
232
+
233
+ **The pattern:**
234
+
235
+ ```bash
236
+ # Step 1: Launch the app in holdpty (or it's already running)
237
+ holdpty launch --bg --name demo -- my-tui-app
238
+
239
+ # Step 2: Wait for it to be ready (or it already is)
240
+ sleep 5
241
+
242
+ # Step 3: Record with VHS
243
+ vhs record-demo.tape
244
+
245
+ # Step 4: Clean up (or leave it running)
246
+ holdpty stop demo
247
+ ```
248
+
249
+ **Tape file — attach to a running session:**
250
+
251
+ ```tape
252
+ Output demo.gif
253
+ Output demo.mp4
254
+ Set Shell "cmd"
255
+ Set FontSize 14
256
+ Set Width 1200
257
+ Set Height 700
258
+ Set Theme "Dracula"
259
+ Set Padding 15
260
+ Set WindowBar "Colorful"
261
+ Set BorderRadius 8
262
+
263
+ # Attach to the running holdpty session
264
+ Type "holdpty attach demo"
265
+ Enter
266
+ Sleep 2s
267
+
268
+ # Interact with the app (VHS types, app responds with full TUI)
269
+ Type "your input here"
270
+ Enter
271
+ Sleep 15s
272
+ ```
273
+
274
+ **Tape file — view-only snapshot of a running process:**
275
+
276
+ ```tape
277
+ Output snapshot.gif
278
+ Set Shell "cmd"
279
+ Set FontSize 14
280
+ Set Width 1200
281
+ Set Height 700
282
+ Set Theme "Dracula"
283
+ Set Padding 15
284
+
285
+ # View is read-only — just captures current output
286
+ Type "holdpty view demo"
287
+ Enter
288
+ Sleep 10s
289
+ Ctrl+C
290
+ ```
291
+
292
+ **Use cases:**
293
+ - **README demos** — launch app, let it start, record the interesting part
294
+ - **Monitoring snapshots** — capture what a running service looks like right now
295
+ - **Bug reports** — record a live reproduction with full colors
296
+ - **Agent recordings** — capture an AI agent working with its full TUI (thinking indicators, tool calls, colored output)
297
+
298
+ > On Windows, use `node.exe <path/to/cli.js>` instead of CLI names when launching via holdpty — see the [holdpty skill](https://github.com/marcfargas/holdpty) for the `.cmd` wrapper gotcha.
299
+
214
300
  ## Themes
215
301
 
216
302
  Popular themes for demos:
@@ -241,8 +327,10 @@ List all: `vhs themes`
241
327
 
242
328
  ## Gotchas
243
329
 
330
+ - **Windows: `Set Shell "bash"` uses WSL** — VHS resolves `bash` to WSL bash, not Git Bash. Use `Set Shell "cmd"` to get the full Windows PATH. Custom shell paths (`Set Shell "C:/path/to/bash.exe"`) are rejected.
244
331
  - **No shell expansion in Type** — `Type "echo $HOME"` types the literal string; variable expansion happens when bash executes it, not in the tape
245
332
  - **Quoting** — avoid nested quotes in Type. Use wrapper scripts for complex commands
246
333
  - **Windows paths** — use forward slashes in Type strings (`C:/dev/...` not `C:\dev\...`)
247
334
  - **Long recordings** — GIFs get huge fast. Keep demos under 30 seconds. Use `Set PlaybackSpeed 2.0` to compress
248
335
  - **Terminal size** — if output wraps weird, increase Width or reduce FontSize
336
+ - **TUI apps without holdpty** — apps that need a real PTY for colors (pi, htop, lazygit) will render without colors if launched directly from VHS. Use the holdpty pattern above.