kracked-core 1.0.0 → 1.4.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/README.md CHANGED
@@ -49,16 +49,24 @@ npx kracked-core init
49
49
 
50
50
  The wizard asks a few questions — name your agent, is this a new or existing project, which editor you use — and writes the files. Hold Enter through it and you'll get a sane setup.
51
51
 
52
- **Works with:** Antigravity · Claude Code · Cursor · Windsurf · anything that reads `AGENTS.md`
52
+ **Full support** (memory + all five commands):
53
+ **Antigravity** · **Claude Code** · **Kilo Code** (VS Code) · **Roo Code** (VS Code)
54
+
55
+ **Memory only:** Cursor, Windsurf, and anything else that reads `AGENTS.md` — your agent loads its
56
+ memory, but the `/kracked-*` commands aren't installed, because those editors use a different
57
+ skill format. You can still run the flow by asking in plain language.
53
58
 
54
59
  ## Use
55
60
 
56
- Three commands. That's the whole thing.
61
+ Five commands. Three of them are the whole daily loop.
57
62
 
58
63
  ```
59
64
  /kracked-boot Start of session — agent loads its memory and tells you where you left off
60
65
  /kracked-sdd Build something — idea → spec → docs → build → review
61
66
  /kracked-wrap End of session — agent writes down what it learned
67
+
68
+ /kracked-identity Change your agent's name, tone, or how you like to work
69
+ /kracked-explain "What is all this?" — walks you through what got installed
62
70
  ```
63
71
 
64
72
  > **`/kracked-wrap` is not optional.** Boot without wrap is a memory system that just forgets more slowly. The wrap is where memory actually gets written.
@@ -79,7 +87,13 @@ your-project/.kracked/ PROJECT — this codebase only
79
87
  project.md stack, conventions, how to run it
80
88
  session.md where we are right now
81
89
  decisions.md why things are the way they are
82
- sdd/tracker.md story status + evidence
90
+ sdd/
91
+ tracker.md story status + evidence
92
+ specs/ what to build and why
93
+ epics/ groups of related stories
94
+ stories/ one shippable slice each
95
+ architecture/ how it's built (large work only)
96
+ decisions/ ADRs, numbered, never renumbered
83
97
  ```
84
98
 
85
99
  ### The boundary rule
@@ -120,6 +134,30 @@ Then it tracks the work:
120
134
 
121
135
  **A story cannot move to `done` without evidence.** Not "the tests pass" — what did you actually verify, and what did you *not*? Green tests are not a working feature. This one rule catches more bugs than any other part of the system.
122
136
 
137
+ ## Updating
138
+
139
+ When a new version ships:
140
+
141
+ ```bash
142
+ npx kracked-core@latest update
143
+ ```
144
+
145
+ Refreshes the skills and loaders. **Your memory is never touched** — identity, preferences,
146
+ lessons, and everything under `.kracked/` stay exactly as they are. It keeps your agent's name too.
147
+
148
+ Restart your editor afterwards so it picks up the new skills.
149
+
150
+ ## Uninstalling
151
+
152
+ ```bash
153
+ npx kracked-core uninstall
154
+ ```
155
+
156
+ Shows what it will remove and asks before deleting — project files and global memory are separate
157
+ questions. It never touches a `CLAUDE.md` or `AGENTS.md` another tool wrote.
158
+
159
+ Full guide, including manual removal and how to keep your docs: [docs/UNINSTALL.md](https://github.com/Krackeddevs-Org/kracked-core/blob/main/docs/UNINSTALL.md)
160
+
123
161
  ## FAQ
124
162
 
125
163
  **Does this send my code anywhere?**
@@ -1,24 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { runInit } from '../src/wizard.mjs';
4
+ import { runUninstall } from '../src/uninstall.mjs';
5
+ import { runUpdate } from '../src/update.mjs';
6
+ import { beginTypeAhead } from '../src/prompt.mjs';
4
7
 
5
8
  const USAGE = `kracked-core — memory & workflow installer for AI coding agents
6
9
 
7
10
  Usage:
8
- npx kracked-core init Set up global + project memory
11
+ npx kracked-core init Set up global + project memory
12
+ npx kracked-core update Refresh skills + loaders, keeping your memory
13
+ npx kracked-core uninstall Remove kracked-core files (asks before deleting)
9
14
 
10
15
  Options:
11
- -h, --help Show this help
16
+ -h, --help Show this help
17
+ -v, --version Show the installed version
12
18
  `;
13
19
 
14
20
  async function main() {
15
21
  const [, , subcommand] = process.argv;
16
22
 
23
+ // Capture keystrokes from the very first millisecond — a student holding
24
+ // Enter starts before Node has finished booting.
25
+ if (['init', 'update', 'uninstall'].includes(subcommand)) beginTypeAhead();
26
+
17
27
  if (subcommand === 'init') {
18
28
  await runInit();
19
29
  return;
20
30
  }
21
31
 
32
+ if (subcommand === 'update') {
33
+ await runUpdate();
34
+ return;
35
+ }
36
+
37
+ if (subcommand === 'uninstall') {
38
+ await runUninstall();
39
+ return;
40
+ }
41
+
42
+ if (subcommand === '--version' || subcommand === '-v') {
43
+ const { createRequire } = await import('node:module');
44
+ const pkg = createRequire(import.meta.url)('../package.json');
45
+ process.stdout.write(`${pkg.version}\n`);
46
+ return;
47
+ }
48
+
22
49
  if (subcommand === '--help' || subcommand === '-h' || !subcommand) {
23
50
  process.stdout.write(USAGE);
24
51
  process.exit(subcommand ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kracked-core",
3
- "version": "1.0.0",
3
+ "version": "1.4.0",
4
4
  "description": "Zero-dependency memory & workflow installer for AI coding agents — global identity, project memory, and skills that survive session restarts.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/detect.mjs CHANGED
@@ -5,6 +5,30 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
7
 
8
+ /**
9
+ * Scrub a value read from an untrusted repo before it can reach a template.
10
+ *
11
+ * Everything scanned from a project (package.json name, script names) gets
12
+ * interpolated into AGENTS.md and ~/.kracked/projects.md — files an AI agent
13
+ * READS AS INSTRUCTIONS on every boot. A crafted `name` containing newlines and
14
+ * a markdown heading can therefore inject text that reads as package-authored
15
+ * instruction, and via the global registry it persists into every OTHER project
16
+ * the user opens. Sanitize here, at the boundary, so no caller can forget.
17
+ */
18
+ export function sanitizeScanned(value, maxLength = 64) {
19
+ if (typeof value !== 'string') return '';
20
+ return value
21
+ .replace(/[\r\n\t]+/g, ' ') // no line breaks — kills injected blocks
22
+ // eslint-disable-next-line no-control-regex
23
+ .replace(/[\u0000-\u001f\u007f]/g, '') // strip remaining control chars
24
+ .replace(/[|`<>]/g, '') // markdown table + code + html structure
25
+ .replace(/^[\s#>*\-=+]+/, '') // no leading heading/quote/list markers
26
+ .replace(/\s+/g, ' ')
27
+ .trim()
28
+ .slice(0, maxLength)
29
+ .trim();
30
+ }
31
+
8
32
  /** Does ~/.kracked/ already exist? */
9
33
  export function globalMemoryExists() {
10
34
  const dir = path.join(os.homedir(), '.kracked');
@@ -15,6 +39,39 @@ export function globalMemoryDir() {
15
39
  return path.join(os.homedir(), '.kracked');
16
40
  }
17
41
 
42
+ /**
43
+ * Find agent setups already governing this directory, so we never silently
44
+ * overwrite someone's existing system. A CLAUDE.md/AGENTS.md that kracked-core
45
+ * didn't write is another system's loader — installing over it would replace
46
+ * that agent's identity, which is exactly the failure this check prevents.
47
+ *
48
+ * Returns [{ file, path, scope, ours }] — `ours` marks files kracked-core
49
+ * itself wrote (safe to refresh), everything else is someone else's.
50
+ */
51
+ export function detectExistingAgentSetup(projectDir) {
52
+ const found = [];
53
+
54
+ const inspect = (filePath, label, scope) => {
55
+ if (!fs.existsSync(filePath)) return;
56
+ let body = '';
57
+ try {
58
+ body = fs.readFileSync(filePath, 'utf8');
59
+ } catch {
60
+ return; // unreadable is not our problem to solve here
61
+ }
62
+ // Our own loaders always reference kracked-core by name.
63
+ const ours = /kracked-core|kracked\.md|\.kracked\//.test(body);
64
+ found.push({ file: label, path: filePath, scope, ours });
65
+ };
66
+
67
+ inspect(path.join(projectDir, 'CLAUDE.md'), 'CLAUDE.md', 'project');
68
+ inspect(path.join(projectDir, 'AGENTS.md'), 'AGENTS.md', 'project');
69
+ inspect(path.join(projectDir, '.agents', 'rules', 'kracked.md'), '.agents/rules/kracked.md', 'project');
70
+ inspect(path.join(os.homedir(), '.claude', 'CLAUDE.md'), '~/.claude/CLAUDE.md', 'global');
71
+
72
+ return found;
73
+ }
74
+
18
75
  /**
19
76
  * Is the target dir an existing codebase, empty, or new (doesn't exist yet)?
20
77
  * Returns one of: "new" (doesn't exist), "empty" (exists, no entries),
@@ -66,9 +123,17 @@ export function scanExistingProject(targetDir) {
66
123
  if (fs.existsSync(pkgPath)) {
67
124
  try {
68
125
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
69
- if (pkg.name) summary.name = pkg.name;
126
+
127
+ // Everything below comes from an untrusted repo and ends up inside files
128
+ // the agent reads as instructions — sanitize before it goes anywhere.
129
+ const cleanName = sanitizeScanned(pkg.name);
130
+ if (cleanName) summary.name = cleanName;
131
+
70
132
  if (pkg.scripts && typeof pkg.scripts === 'object') {
71
- summary.scripts = pkg.scripts;
133
+ // Keep only plausible script names; a hostile key is dropped, not rendered.
134
+ for (const key of Object.keys(pkg.scripts)) {
135
+ if (/^[a-zA-Z0-9:_-]{1,40}$/.test(key)) summary.scripts[key] = true;
136
+ }
72
137
  }
73
138
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
74
139
  const frameworkHints = [
@@ -94,13 +159,3 @@ export function stackSummaryLine(summary) {
94
159
  return parts.length ? parts.join(' ') : 'unknown — not auto-detected';
95
160
  }
96
161
 
97
- /** Which harnesses already have a footprint in this project dir? */
98
- export function detectHarnesses(targetDir) {
99
- return {
100
- claudeCode: fs.existsSync(path.join(targetDir, '.claude')) ||
101
- fs.existsSync(path.join(targetDir, 'CLAUDE.md')),
102
- antigravity: fs.existsSync(path.join(targetDir, '.agents')) ||
103
- fs.existsSync(path.join(targetDir, 'AGENTS.md')),
104
- cursor: fs.existsSync(path.join(targetDir, '.cursor')),
105
- };
106
- }
package/src/prompt.mjs ADDED
@@ -0,0 +1,339 @@
1
+ // Keyboard-driven prompts: arrow-key single select and spacebar checkboxes.
2
+ // Zero dependencies — raw TTY keypress handling, so the installer never fails
3
+ // in a classroom because of a missing package.
4
+
5
+ import { stdin, stdout } from 'node:process';
6
+
7
+ const ESC = '';
8
+ const KEY = {
9
+ up: `${ESC}[A`,
10
+ down: `${ESC}[B`,
11
+ ctrlC: '',
12
+ ctrlD: '',
13
+ enter: '\r',
14
+ newline: '\n',
15
+ space: ' ',
16
+ };
17
+
18
+ const c = {
19
+ dim: (s) => `${s}`,
20
+ cyan: (s) => `${s}`,
21
+ green: (s) => `${s}`,
22
+ bold: (s) => `${s}`,
23
+ };
24
+
25
+ /** Hide/show the cursor so the redrawing menu doesn't flicker a caret around. */
26
+ function setCursor(visible) {
27
+ stdout.write(visible ? `${ESC}[?25h` : `${ESC}[?25l`);
28
+ }
29
+
30
+ // A throw anywhere outside the keypress handler would otherwise exit with the
31
+ // terminal still in raw mode and the cursor hidden — leaving the user's shell
32
+ // with invisible, unechoed input. One idempotent restore covers every exit path,
33
+ // including process.exit().
34
+ let restoreRegistered = false;
35
+ function registerTerminalRestore() {
36
+ if (restoreRegistered) return;
37
+ restoreRegistered = true;
38
+ process.on('exit', () => {
39
+ try {
40
+ if (stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
41
+ stdout.write(`${ESC}[?25h`);
42
+ } catch {
43
+ // Nothing useful to do while the process is already exiting.
44
+ }
45
+ });
46
+ }
47
+
48
+ /** Move up n lines and clear from there down — used to redraw the menu in place. */
49
+ function clearLines(n) {
50
+ if (n > 0) stdout.write(`${ESC}[${n}A`);
51
+ stdout.write(`${ESC}[0J`);
52
+ }
53
+
54
+ /**
55
+ * Split one stdin chunk into individual keys.
56
+ *
57
+ * stdin delivers DATA, not keystrokes: holding Enter (key auto-repeat), typing
58
+ * fast, pasting, or running over SSH/tmux routinely packs several keys into one
59
+ * chunk. Comparing a whole chunk against '\r' then matches nothing, so the key
60
+ * is silently dropped and the prompt hangs — the worst kind of failure, because
61
+ * there is no error to see. Escape sequences (arrows) must stay intact as one
62
+ * key; everything else is per-character.
63
+ */
64
+ function splitKeys(chunk) {
65
+ const keys = [];
66
+ let i = 0;
67
+
68
+ while (i < chunk.length) {
69
+ if (chunk[i] === ESC) {
70
+ // CSI sequence: ESC [ ... final-byte (@ through ~). Arrows are ESC[A..D.
71
+ const match = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(chunk.slice(i));
72
+ if (match) {
73
+ keys.push(match[0]);
74
+ i += match[0].length;
75
+ continue;
76
+ }
77
+ // ESC alone, or an unrecognised sequence — take just the ESC.
78
+ keys.push(chunk[i]);
79
+ i += 1;
80
+ continue;
81
+ }
82
+ keys.push(chunk[i]);
83
+ i += 1;
84
+ }
85
+
86
+ return keys;
87
+ }
88
+
89
+ /**
90
+ * Read single keypresses until `onKey` signals completion.
91
+ * onKey(key) returns { done: true, value } to finish, or falsy to keep listening.
92
+ */
93
+ // Keys that arrive between prompts — while one question has resolved and the
94
+ // next hasn't attached its listener yet — would otherwise be dropped on the
95
+ // floor. Holding Enter produces exactly that. Buffer them and replay into the
96
+ // next prompt so no keystroke is ever silently lost.
97
+ const pendingKeys = [];
98
+
99
+ /**
100
+ * Start capturing keystrokes immediately, before the first prompt renders.
101
+ * Node takes a moment to boot and draw; anything typed in that window is
102
+ * otherwise lost. Call once at CLI start.
103
+ */
104
+ export function beginTypeAhead() {
105
+ if (!stdin.isTTY) return;
106
+ registerTerminalRestore();
107
+ stdin.setRawMode(true);
108
+ stdin.resume();
109
+ stdin.setEncoding('utf8');
110
+ stdin.on('data', bufferEarlyKeys);
111
+ }
112
+
113
+ function bufferEarlyKeys(chunk) {
114
+ pendingKeys.push(...splitKeys(chunk));
115
+ }
116
+
117
+ function readKeys(onKey) {
118
+ return new Promise((resolve, reject) => {
119
+ registerTerminalRestore();
120
+ const wasRaw = stdin.isRaw;
121
+ stdin.setRawMode(true);
122
+ stdin.resume();
123
+ stdin.setEncoding('utf8');
124
+ stdin.removeListener('data', bufferEarlyKeys);
125
+
126
+ const cleanup = () => {
127
+ stdin.setRawMode(wasRaw ?? false);
128
+ stdin.removeListener('data', handler);
129
+ // Resume early-capture so keys typed between prompts aren't lost.
130
+ stdin.on('data', bufferEarlyKeys);
131
+ setCursor(true);
132
+ };
133
+
134
+ /** Feed one key to onKey. Returns true when the prompt is finished. */
135
+ const dispatch = (key) => {
136
+ if (key === KEY.ctrlC || key === KEY.ctrlD) {
137
+ cleanup();
138
+ stdout.write('\n\nCancelled — nothing was written.\n');
139
+ process.exit(130);
140
+ }
141
+
142
+ let result;
143
+ try {
144
+ result = onKey(key);
145
+ } catch (err) {
146
+ cleanup();
147
+ reject(err);
148
+ return true;
149
+ }
150
+
151
+ if (result && result.done) {
152
+ cleanup();
153
+ resolve(result.value);
154
+ return true;
155
+ }
156
+ return false;
157
+ };
158
+
159
+ // Replay anything typed ahead of this prompt before listening for more.
160
+ while (pendingKeys.length) {
161
+ if (dispatch(pendingKeys.shift())) return;
162
+ }
163
+
164
+ function handler(chunk) {
165
+ // One chunk can carry several keys. Dispatch them one at a time; once a
166
+ // key completes this prompt, park the rest for the next one rather than
167
+ // discarding them.
168
+ const keys = splitKeys(chunk);
169
+ for (let i = 0; i < keys.length; i++) {
170
+ if (dispatch(keys[i])) {
171
+ pendingKeys.push(...keys.slice(i + 1));
172
+ return;
173
+ }
174
+ }
175
+ }
176
+
177
+ stdin.on('data', handler);
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Arrow-key single select. `choices` is [{ label, value, hint }].
183
+ * Returns the chosen `value`.
184
+ */
185
+ export async function select(question, choices, defaultIndex = 0) {
186
+ let index = defaultIndex;
187
+ let drawn = 0;
188
+
189
+ const render = () => {
190
+ clearLines(drawn);
191
+ let out = `${c.bold('?')} ${question} ${c.dim('↑↓ to move, Enter to pick')}\n`;
192
+ for (let i = 0; i < choices.length; i++) {
193
+ const active = i === index;
194
+ const pointer = active ? c.cyan('❯') : ' ';
195
+ const label = active ? c.cyan(choices[i].label) : choices[i].label;
196
+ const hint = choices[i].hint ? ` ${c.dim(choices[i].hint)}` : '';
197
+ out += `${pointer} ${label}${hint}\n`;
198
+ }
199
+ stdout.write(out);
200
+ drawn = choices.length + 1;
201
+ };
202
+
203
+ setCursor(false);
204
+ render();
205
+
206
+ const value = await readKeys((key) => {
207
+ if (key === KEY.up) {
208
+ index = (index - 1 + choices.length) % choices.length;
209
+ render();
210
+ } else if (key === KEY.down) {
211
+ index = (index + 1) % choices.length;
212
+ render();
213
+ } else if (key === KEY.enter || key === KEY.newline) {
214
+ return { done: true, value: choices[index].value };
215
+ }
216
+ return null;
217
+ });
218
+
219
+ // Collapse the menu to a single answered line.
220
+ clearLines(drawn);
221
+ const chosen = choices.find((ch) => ch.value === value);
222
+ stdout.write(`${c.green('✔')} ${question} ${c.cyan(chosen.label)}\n`);
223
+ return value;
224
+ }
225
+
226
+ /**
227
+ * Spacebar checkbox multi-select. `choices` is [{ label, value, hint, checked }].
228
+ * Returns an array of checked `value`s. Enter with nothing checked is refused,
229
+ * since every multi-select in this wizard needs at least one answer.
230
+ */
231
+ export async function checkbox(question, choices) {
232
+ let index = 0;
233
+ let drawn = 0;
234
+ const state = choices.map((ch) => Boolean(ch.checked));
235
+ let warning = '';
236
+
237
+ const render = () => {
238
+ clearLines(drawn);
239
+ let out = `${c.bold('?')} ${question} ${c.dim('↑↓ move, space to toggle, Enter to confirm')}\n`;
240
+ for (let i = 0; i < choices.length; i++) {
241
+ const active = i === index;
242
+ const pointer = active ? c.cyan('❯') : ' ';
243
+ const box = state[i] ? c.green('◉') : '◯';
244
+ const label = active ? c.cyan(choices[i].label) : choices[i].label;
245
+ const hint = choices[i].hint ? ` ${c.dim(choices[i].hint)}` : '';
246
+ out += `${pointer} ${box} ${label}${hint}\n`;
247
+ }
248
+ if (warning) out += `${c.dim(warning)}\n`;
249
+ stdout.write(out);
250
+ drawn = choices.length + 1 + (warning ? 1 : 0);
251
+ };
252
+
253
+ setCursor(false);
254
+ render();
255
+
256
+ const values = await readKeys((key) => {
257
+ if (key === KEY.up) {
258
+ index = (index - 1 + choices.length) % choices.length;
259
+ warning = '';
260
+ render();
261
+ } else if (key === KEY.down) {
262
+ index = (index + 1) % choices.length;
263
+ warning = '';
264
+ render();
265
+ } else if (key === KEY.space) {
266
+ state[index] = !state[index];
267
+ warning = '';
268
+ render();
269
+ } else if (key === KEY.enter || key === KEY.newline) {
270
+ const picked = choices.filter((_, i) => state[i]).map((ch) => ch.value);
271
+ if (picked.length === 0) {
272
+ warning = ' Pick at least one (space to toggle).';
273
+ render();
274
+ return null;
275
+ }
276
+ return { done: true, value: picked };
277
+ }
278
+ return null;
279
+ });
280
+
281
+ clearLines(drawn);
282
+ const labels = choices.filter((_, i) => state[i]).map((ch) => ch.label).join(', ');
283
+ stdout.write(`${c.green('✔')} ${question} ${c.cyan(labels)}\n`);
284
+ return values;
285
+ }
286
+
287
+ /**
288
+ * Free-text input with a default. Implemented on the same raw-mode reader as
289
+ * the menus: readline and raw mode cannot share stdin — if both are listening,
290
+ * keystrokes go to the wrong reader and the wizard hangs. One owner, always.
291
+ */
292
+ export async function input(question, defaultValue = '') {
293
+ let buffer = '';
294
+ const hint = defaultValue ? c.dim(` [${defaultValue}]`) : '';
295
+
296
+ const render = () => {
297
+ stdout.write(`\r${ESC}[0K${c.bold('?')} ${question}${hint} ${buffer}`);
298
+ };
299
+
300
+ setCursor(true);
301
+ render();
302
+
303
+ const value = await readKeys((key) => {
304
+ if (key === KEY.enter || key === KEY.newline) {
305
+ return { done: true, value: buffer.trim() || defaultValue };
306
+ }
307
+ // Backspace (DEL or BS)
308
+ if (key === '' || key === '\b') {
309
+ buffer = buffer.slice(0, -1);
310
+ render();
311
+ return null;
312
+ }
313
+ // Ignore escape sequences (arrows etc.) and any control character. Compare
314
+ // the code point, not the string — `key < ' '` on a multi-char value tests
315
+ // lexicographic order, not "is this a control char".
316
+ if (key.startsWith(ESC)) return null;
317
+ const code = key.codePointAt(0);
318
+ if (code < 0x20 || code === 0x7f) return null;
319
+
320
+ buffer += key;
321
+ render();
322
+ return null;
323
+ });
324
+
325
+ stdout.write(`\r${ESC}[0K${c.green('✔')} ${question} ${c.cyan(value)}\n`);
326
+ return value;
327
+ }
328
+
329
+ /** Yes/no as an arrow menu, so every prompt in the wizard behaves the same way. */
330
+ export async function confirm(question, defaultYes = true) {
331
+ return select(
332
+ question,
333
+ [
334
+ { label: 'Yes', value: true },
335
+ { label: 'No', value: false },
336
+ ],
337
+ defaultYes ? 0 : 1
338
+ );
339
+ }