@jhizzard/termdeck-stack 0.4.0 → 0.4.1

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.
@@ -2,72 +2,131 @@
2
2
 
3
3
  The `@jhizzard/termdeck-stack` installer can drop `memory-session-end.js`
4
4
  into `~/.claude/hooks/` and wire it into `~/.claude/settings.json` under
5
- `hooks.Stop`. The installer prompts you before doing this; default is yes.
5
+ `hooks.Stop`. The installer prompts you before doing this; default is
6
+ yes.
6
7
 
7
8
  ## What the hook does
8
9
 
9
- On every Claude Code session close, Claude Code fires its `Stop` hook with
10
- a JSON payload on stdin:
10
+ On every Claude Code session close, Claude Code fires its `Stop` hook
11
+ with a JSON payload on stdin:
11
12
 
12
13
  ```json
13
- { "transcript_path": "/path/to/session-transcript.jsonl", "cwd": "/path/where/you/were/working" }
14
+ { "transcript_path": "/path/to/session.jsonl", "cwd": "/path/where/you/were/working", "session_id": "..." }
14
15
  ```
15
16
 
16
17
  The hook:
17
18
 
18
- 1. Skips transcripts smaller than 5 KB (no signal in tiny sessions).
19
- 2. Detects the project from `cwd` against a built-in regex table; falls
20
- back to `"global"` when nothing matches.
21
- 3. Spawns a detached ingester (`process-session.ts` from `rag-system`),
22
- which reads the transcript and writes a session summary into Mnestra.
23
- 4. Logs every step to `~/.claude/hooks/memory-hook.log`.
19
+ 1. Skips transcripts smaller than 5 KB (no signal in tiny sessions
20
+ override via `TERMDECK_HOOK_MIN_BYTES`).
21
+ 2. Validates env vars (`SUPABASE_URL`, `SUPABASE_SERVICE_KEY`,
22
+ `OPENAI_API_KEY`); if any are missing, logs the missing list and
23
+ exits cleanly without blocking the session close.
24
+ 3. Detects the project from `cwd` against a built-in regex table; falls
25
+ back to `"global"` when nothing matches. **The default table is
26
+ intentionally empty** — see "Customizing the project map" below to
27
+ add your own entries.
28
+ 4. Builds a coarse session summary from the last ~30 messages of the
29
+ transcript (~7 KB cap to stay inside OpenAI's embedding-input
30
+ budget).
31
+ 5. Embeds the summary via OpenAI `text-embedding-3-small` (1,536-dim).
32
+ 6. POSTs **one row** to Supabase `/rest/v1/memory_items` with
33
+ `source_type='session_summary'`.
34
+ 7. Logs every step to `~/.claude/hooks/memory-hook.log`.
35
+
36
+ The hook is **fail-soft**: any error (network, parse, env-var-missing,
37
+ malformed transcript) is logged and the hook exits 0. Claude Code's
38
+ session close is never blocked.
39
+
40
+ ## Required environment
41
+
42
+ The hook needs three env vars at run time:
43
+
44
+ | Var | What | How to set |
45
+ |---|---|---|
46
+ | `SUPABASE_URL` | Your Supabase project URL (e.g. `https://abc.supabase.co`) | `~/.termdeck/secrets.env` (Tier 2) |
47
+ | `SUPABASE_SERVICE_KEY` | Service-role key with INSERT on `memory_items`. **Not the anon key.** | `~/.termdeck/secrets.env` |
48
+ | `OPENAI_API_KEY` | OpenAI key for embedding inference | `~/.termdeck/secrets.env` or your shell |
49
+
50
+ Claude Code propagates the parent shell's environment into hook
51
+ processes, so anything in your shell init or
52
+ `~/.termdeck/secrets.env` (sourced by `scripts/start.sh` /
53
+ `npx @jhizzard/termdeck`) is visible to the hook.
54
+
55
+ If any of the three is missing the log line will name them:
24
56
 
25
- The spawn is detached + unref'd, so Claude Code's session close is not
26
- blocked waiting for ingestion the 30-second `timeout` in
27
- `settings.json` is a backstop, not a target.
57
+ ```
58
+ [2026-04-27T21:30:00.000Z] env-var-missing: OPENAI_API_KEYset these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.
59
+ ```
28
60
 
29
- ## Dependency on `rag-system`
61
+ ## Customizing the project map
30
62
 
31
- The hook delegates ingestion to a script inside the `rag-system` repo:
63
+ The hook ships with an **empty `PROJECT_MAP`** by default every
64
+ session lands under `project: 'global'` until you add entries. To add
65
+ your own:
32
66
 
33
- ```
34
- ${RAG_DIR}/src/scripts/process-session.ts
67
+ 1. Open `~/.claude/hooks/memory-session-end.js` after the installer
68
+ has dropped it.
69
+ 2. Find the `PROJECT_MAP` array near the top of the file.
70
+ 3. Add one entry per project; each entry is `{ pattern, project }`
71
+ where `pattern` is a regex matched against `cwd`:
72
+
73
+ ```js
74
+ const PROJECT_MAP = [
75
+ { pattern: /\/PVB\//i, project: 'pvb' },
76
+ { pattern: /\/my-startup\//i, project: 'my-startup' },
77
+ { pattern: /chopin-nashville/i, project: 'chopin-nashville' },
78
+ ];
35
79
  ```
36
80
 
37
- `RAG_DIR` resolves in this order:
81
+ First match wins. Iteration order is array order, so put more specific
82
+ patterns first. Anything that doesn't match falls through to
83
+ `'global'`.
38
84
 
39
- 1. `process.env.TERMDECK_RAG_DIR` (if set)
40
- 2. `~/Documents/Graciella/rag-system` (default Joshua's layout)
85
+ The map is local-only — it's never sent to any service. Editing it
86
+ takes effect on the next Claude Code session close (no restart
87
+ needed).
41
88
 
42
- **If the resolved `RAG_DIR` does not exist on disk, the hook logs that
43
- fact and exits cleanly.** It does not error, does not block session
44
- close, and does not leak a spawn. Fresh users who installed the stack
45
- but do not have `rag-system` checked out will see this skip-message in
46
- the log and nothing else — as if no hook were installed.
89
+ ## Coexistence with Joshua's `rag-system` hook
47
90
 
48
- A future TermDeck sprint will rewrite the hook to call Mnestra's MCP
49
- tools directly so the `rag-system` dependency drops away. Until then,
50
- this hook is most useful for users who already have `rag-system`
51
- available.
91
+ If you have Joshua's private `rag-system` repo and his rag-system-based
92
+ session hook installed, this bundled hook and that one can coexist:
93
+
94
+ - The bundled hook writes `source_type='session_summary'` — one row
95
+ per session, summary-only.
96
+ - The `rag-system` hook writes `source_type='fact'` — multiple rows
97
+ per session via Claude Haiku fact extraction + dedup.
98
+
99
+ Different `source_type` values mean the two paths don't dedup against
100
+ each other. If both are installed at the same path
101
+ (`~/.claude/hooks/memory-session-end.js`) the installer will prompt
102
+ before overwriting; choose accordingly.
52
103
 
53
104
  ## How to disable
54
105
 
55
106
  Two options:
56
107
 
57
- 1. Edit `~/.claude/settings.json` and remove the entry under `hooks.Stop`
58
- that references `memory-session-end.js`. Leave the file in place; it
59
- simply won't fire.
60
- 2. Or delete `~/.claude/hooks/memory-session-end.js` and remove the
108
+ 1. Edit `~/.claude/settings.json` and remove the entry under
109
+ `hooks.Stop` that references `memory-session-end.js`. Leave the
110
+ file in place; it simply won't fire.
111
+ 2. Or delete `~/.claude/hooks/memory-session-end.js` AND remove the
61
112
  `settings.json` entry. (Removing only the file leaves a broken
62
- `command` in settings — Claude Code will log a missing-file error on
63
- every session close.)
113
+ `command` in settings — Claude Code will log a missing-file error
114
+ on every session close.)
115
+
116
+ Re-running `npx @jhizzard/termdeck-stack` after disabling will
117
+ re-prompt to install. Decline at the prompt to stay opted out.
118
+
119
+ ## Optional flags
64
120
 
65
- Re-running `npx @jhizzard/termdeck-stack` after disabling will re-prompt
66
- to install. Decline at the prompt to stay opted out.
121
+ | Env var | Effect |
122
+ |---|---|
123
+ | `TERMDECK_HOOK_DEBUG=1` | Verbose `[debug]` lines in the log |
124
+ | `TERMDECK_HOOK_MIN_BYTES=10000` | Override the 5 KB skip threshold |
67
125
 
68
126
  ## Log file
69
127
 
70
- `~/.claude/hooks/memory-hook.log` accumulates one line per session-close
71
- event. The hook never rotates it. If it grows unwieldy you can truncate
72
- it (`: > ~/.claude/hooks/memory-hook.log`) without affecting hook
128
+ `~/.claude/hooks/memory-hook.log` accumulates one line per session
129
+ event (skips, errors, ingests). The hook never rotates it. If it
130
+ grows unwieldy you can truncate it
131
+ (`: > ~/.claude/hooks/memory-hook.log`) without affecting hook
73
132
  behavior.
@@ -1,53 +1,64 @@
1
1
  /**
2
- * TermDeck session-end memory hook.
2
+ * TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
3
3
  *
4
- * Vendored from Joshua's ~/.claude/hooks/memory-session-end.js (2026-03-11).
5
- * Installed by `@jhizzard/termdeck-stack` into ~/.claude/hooks/ and wired
6
- * into ~/.claude/settings.json under hooks.Stop. Fires on every Claude Code
7
- * session close.
4
+ * Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
5
+ * Wired into ~/.claude/settings.json under hooks.Stop. Fires on Claude Code Stop event.
8
6
  *
9
7
  * Behavior:
10
- * 1. Reads {transcript_path, cwd} from stdin (Claude Code's Stop payload).
11
- * 2. Skips small transcripts (<5KB).
12
- * 3. Detects project from cwd against PROJECT_MAP (else "global").
13
- * 4. Spawns the rag-system ingester detached, returns immediately.
14
- * 5. Logs to ~/.claude/hooks/memory-hook.log.
8
+ * 1. Reads {transcript_path, cwd, session_id} from stdin (Claude Code Stop payload).
9
+ * 2. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
10
+ * 3. Validates env vars; logs and exits cleanly if any required key is missing.
11
+ * 4. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
12
+ * map by editing the array below — see assets/hooks/README.md for guidance.
13
+ * 5. Builds a coarse session summary from the transcript (last ~30 message excerpts).
14
+ * 6. Embeds the summary via OpenAI text-embedding-3-small.
15
+ * 7. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
16
+ * 8. Logs every step to ~/.claude/hooks/memory-hook.log.
15
17
  *
16
- * Path resolution (parameterized for portability — was hardcoded in source):
17
- * RAG_DIR := process.env.TERMDECK_RAG_DIR
18
- * || path.join(os.homedir(), 'Documents/Graciella/rag-system')
18
+ * Required env vars (validated at entry):
19
+ * - SUPABASE_URL e.g. https://luvvbrpaopnblvxdxwzb.supabase.co
20
+ * - SUPABASE_SERVICE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
21
+ * - OPENAI_API_KEY sk-... for text-embedding-3-small
19
22
  *
20
- * If the resolved RAG_DIR doesn't exist on disk, the hook logs and exits
21
- * cleanly. Fresh users who haven't installed rag-system get a no-op hook
22
- * rather than a spawn error. See assets/hooks/README.md for the full story.
23
+ * Optional:
24
+ * - TERMDECK_HOOK_DEBUG=1 verbose logging
25
+ * - TERMDECK_HOOK_MIN_BYTES=5000 transcript size threshold
26
+ *
27
+ * Fail-soft contract: any error (network, parse, env-var-missing, malformed transcript)
28
+ * logs and exits 0. Never blocks Claude Code session close.
29
+ *
30
+ * Co-existence with Joshua's personal rag-system hook: this bundled hook writes
31
+ * source_type='session_summary' (one row per session). Joshua's personal hook
32
+ * writes source_type='fact' (multiple rows from extractFacts pipeline). Different
33
+ * source_types coexist in memory_items without dedup collisions.
23
34
  */
24
35
 
25
- const { spawn } = require('child_process');
26
- const { existsSync, statSync, appendFileSync } = require('fs');
36
+ 'use strict';
37
+
38
+ const { existsSync, statSync, appendFileSync, readFileSync } = require('fs');
27
39
  const { join } = require('path');
28
40
  const os = require('os');
29
41
 
30
- const RAG_DIR = process.env.TERMDECK_RAG_DIR
31
- || join(os.homedir(), 'Documents', 'Graciella', 'rag-system');
32
- const PROCESS_SCRIPT = join(RAG_DIR, 'src', 'scripts', 'process-session.ts');
33
42
  const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
34
43
 
44
+ // PROJECT_MAP — minimal default. Users extend by adding entries to this array.
45
+ // Patterns match against the cwd reported by Claude Code at Stop time.
46
+ // First match wins; falls through to "global".
35
47
  const PROJECT_MAP = [
36
- { pattern: /\/PVB\//i, project: 'pvb' },
37
- { pattern: /chopin-scheduler|chopin_scheduler/i, project: 'chopin-scheduler' },
38
- { pattern: /ChopinNashville|ChopinInBohemia/i, project: 'chopin-nashville' },
39
- { pattern: /rag-system/i, project: 'rag-system' },
40
- { pattern: /PianoCameraAI/i, project: 'piano-camera' },
41
- { pattern: /Practice Piano Network/i, project: 'ppn' },
42
- { pattern: /StanczakJosh/i, project: 'stanczak' },
43
- { pattern: /JoshIzPiano/i, project: 'joshizpiano' },
44
- { pattern: /AutumnArtist/i, project: 'autumn-artist' },
45
- { pattern: /Crosswords/i, project: 'crosswords' },
46
- { pattern: /gorgias/i, project: 'gorgias' },
47
- { pattern: /imessage-reader/i, project: 'imessage-reader' },
48
- { pattern: /antigravity/i, project: 'antigravity' },
48
+ // Example entries — uncomment + edit, or add your own:
49
+ // { pattern: /\/myproject\//i, project: 'my-project' },
50
+ // { pattern: /work-stuff/i, project: 'work' },
49
51
  ];
50
52
 
53
+ const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
54
+ const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
55
+
56
+ function log(msg) {
57
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`); }
58
+ catch (_) { /* fail-soft */ }
59
+ }
60
+ function debug(msg) { if (DEBUG) log(`[debug] ${msg}`); }
61
+
51
62
  function detectProject(cwd) {
52
63
  for (const { pattern, project } of PROJECT_MAP) {
53
64
  if (pattern.test(cwd)) return project;
@@ -55,59 +66,173 @@ function detectProject(cwd) {
55
66
  return 'global';
56
67
  }
57
68
 
58
- function log(msg) {
59
- try {
60
- appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
61
- } catch (_) {}
69
+ function readEnv() {
70
+ const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_KEY', 'OPENAI_API_KEY'];
71
+ const missing = required.filter((k) => !process.env[k]);
72
+ if (missing.length) {
73
+ log(`env-var-missing: ${missing.join(', ')} — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.`);
74
+ return null;
75
+ }
76
+ return {
77
+ supabaseUrl: process.env.SUPABASE_URL.replace(/\/$/, ''),
78
+ supabaseKey: process.env.SUPABASE_SERVICE_KEY,
79
+ openaiKey: process.env.OPENAI_API_KEY,
80
+ };
62
81
  }
63
82
 
64
- let input = '';
65
- process.stdin.setEncoding('utf8');
66
- process.stdin.on('data', (chunk) => { input += chunk; });
67
- process.stdin.on('end', () => {
68
- try {
69
- const data = JSON.parse(input);
70
- const transcriptPath = data.transcript_path;
71
- const cwd = data.cwd || '';
83
+ function buildSummary(transcriptPath) {
84
+ let raw;
85
+ try { raw = readFileSync(transcriptPath, 'utf8'); }
86
+ catch (e) { log(`read-transcript-failed: ${e.message}`); return null; }
72
87
 
73
- if (!transcriptPath) {
74
- log('No transcript_path in input, skipping');
75
- return;
88
+ const lines = raw.split('\n').filter(Boolean);
89
+ const messages = [];
90
+ for (const line of lines) {
91
+ let msg;
92
+ try { msg = JSON.parse(line); } catch (_) { continue; }
93
+ const role = msg?.message?.role;
94
+ if (role !== 'user' && role !== 'assistant') continue;
95
+ const content = msg.message.content;
96
+ let text = '';
97
+ if (typeof content === 'string') text = content;
98
+ else if (Array.isArray(content)) {
99
+ text = content.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ');
76
100
  }
101
+ if (text) messages.push({ role, content: text.slice(0, 400) });
102
+ }
77
103
 
78
- try {
79
- const stat = statSync(transcriptPath);
80
- if (stat.size < 5000) {
81
- log(`Skipping small transcript (${stat.size} bytes): ${transcriptPath}`);
82
- return;
83
- }
84
- } catch (e) {
85
- log(`Cannot stat transcript: ${transcriptPath} — ${e.message}`);
86
- return;
87
- }
104
+ if (messages.length < 5) {
105
+ debug(`session-too-short: ${messages.length} messages, skipping`);
106
+ return null;
107
+ }
88
108
 
89
- if (!existsSync(PROCESS_SCRIPT)) {
90
- log(`RAG_DIR not present (${RAG_DIR}); skipping ingestion. Set TERMDECK_RAG_DIR or install rag-system to enable.`);
91
- return;
109
+ const tail = messages.slice(-30);
110
+ const summary =
111
+ `Session with ${messages.length} messages.\n\n` +
112
+ tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
113
+ // OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
114
+ // 7000 chars is a safe headroom that survives multibyte expansion.
115
+ return summary.slice(0, 7000);
116
+ }
117
+
118
+ async function embedText(text, openaiKey) {
119
+ try {
120
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${openaiKey}`,
125
+ },
126
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
127
+ });
128
+ if (!res.ok) {
129
+ const body = await res.text().catch(() => '');
130
+ log(`openai-embed-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
131
+ return null;
92
132
  }
133
+ const data = await res.json();
134
+ return data?.data?.[0]?.embedding || null;
135
+ } catch (e) {
136
+ log(`openai-embed-exception: ${e.message}`);
137
+ return null;
138
+ }
139
+ }
93
140
 
94
- const project = detectProject(cwd);
95
- log(`Processing session for project "${project}" from ${transcriptPath}`);
96
-
97
- const child = spawn(
98
- 'npx',
99
- ['tsx', PROCESS_SCRIPT, transcriptPath, '--project', project],
100
- {
101
- cwd: RAG_DIR,
102
- detached: true,
103
- stdio: 'ignore',
104
- env: { ...process.env, DOTENV_CONFIG_PATH: join(RAG_DIR, '.env') },
105
- }
106
- );
107
- child.unref();
108
-
109
- log(`Spawned process-session (pid ${child.pid}) for project "${project}"`);
141
+ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId }) {
142
+ try {
143
+ const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'apikey': supabaseKey,
148
+ 'Authorization': `Bearer ${supabaseKey}`,
149
+ 'Prefer': 'return=minimal',
150
+ },
151
+ body: JSON.stringify({
152
+ content,
153
+ embedding: `[${embedding.join(',')}]`,
154
+ source_type: 'session_summary',
155
+ category: 'workflow',
156
+ project,
157
+ source_session_id: sessionId || null,
158
+ }),
159
+ });
160
+ if (!res.ok) {
161
+ const body = await res.text().catch(() => '');
162
+ log(`supabase-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
163
+ return false;
164
+ }
165
+ return true;
110
166
  } catch (e) {
111
- log(`Error: ${e.message}`);
167
+ log(`supabase-insert-exception: ${e.message}`);
168
+ return false;
169
+ }
170
+ }
171
+
172
+ async function processStdinPayload(input) {
173
+ let data;
174
+ try { data = JSON.parse(input); }
175
+ catch (e) { log(`parse-stdin-failed: ${e.message}`); return; }
176
+
177
+ const transcriptPath = data.transcript_path;
178
+ const cwd = data.cwd || '';
179
+ const sessionId =
180
+ data.session_id ||
181
+ (transcriptPath ? transcriptPath.split('/').pop().replace('.jsonl', '') : null);
182
+
183
+ if (!transcriptPath) { log('no-transcript-path: skipping'); return; }
184
+
185
+ let stat;
186
+ try { stat = statSync(transcriptPath); }
187
+ catch (e) { log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`); return; }
188
+
189
+ if (stat.size < MIN_TRANSCRIPT_BYTES) {
190
+ debug(`small-transcript: ${stat.size} bytes < ${MIN_TRANSCRIPT_BYTES}, skipping`);
191
+ return;
112
192
  }
113
- });
193
+
194
+ const env = readEnv();
195
+ if (!env) return;
196
+
197
+ const project = detectProject(cwd);
198
+ debug(`project="${project}", session=${sessionId}`);
199
+
200
+ const summary = buildSummary(transcriptPath);
201
+ if (!summary) return;
202
+
203
+ const embedding = await embedText(summary, env.openaiKey);
204
+ if (!embedding) return;
205
+
206
+ const ok = await postMemoryItem({
207
+ supabaseUrl: env.supabaseUrl,
208
+ supabaseKey: env.supabaseKey,
209
+ content: summary,
210
+ embedding,
211
+ project,
212
+ sessionId,
213
+ });
214
+
215
+ if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length}`);
216
+ }
217
+
218
+ // Module-export contract for testability. When run as a script (require.main === module),
219
+ // read stdin and process. When require()d (tests), expose helpers.
220
+ if (require.main === module) {
221
+ let input = '';
222
+ process.stdin.setEncoding('utf8');
223
+ process.stdin.on('data', (chunk) => { input += chunk; });
224
+ process.stdin.on('end', () => {
225
+ processStdinPayload(input).catch((e) => log(`hook-error: ${e.message}`));
226
+ });
227
+ } else {
228
+ module.exports = {
229
+ PROJECT_MAP,
230
+ detectProject,
231
+ readEnv,
232
+ buildSummary,
233
+ embedText,
234
+ postMemoryItem,
235
+ processStdinPayload,
236
+ LOG_FILE,
237
+ };
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"