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 +41 -3
- package/bin/kracked-core.mjs +29 -2
- package/package.json +1 -1
- package/src/detect.mjs +67 -12
- package/src/prompt.mjs +339 -0
- package/src/scaffold.mjs +99 -20
- package/src/uninstall.mjs +214 -0
- package/src/update.mjs +150 -0
- package/src/wizard.mjs +167 -82
- package/templates/loaders/AGENTS.md +2 -0
- package/templates/loaders/CLAUDE.md +1 -0
- package/templates/loaders/antigravity-rules.md +1 -0
- package/templates/project/sdd/README.md +46 -0
- package/templates/project/sdd/architecture/.gitkeep +0 -0
- package/templates/project/sdd/architecture/_TEMPLATE.md +51 -0
- package/templates/project/sdd/architecture/decisions/_TEMPLATE.md +40 -0
- package/templates/project/sdd/epics/.gitkeep +0 -0
- package/templates/project/sdd/epics/_TEMPLATE.md +34 -0
- package/templates/project/sdd/specs/_TEMPLATE.md +44 -0
- package/templates/project/sdd/stories/.gitkeep +0 -0
- package/templates/project/sdd/stories/_TEMPLATE.md +33 -0
- package/templates/skills/kracked-boot/SKILL.md +13 -5
- package/templates/skills/kracked-identity/SKILL.md +70 -0
- package/templates/skills/kracked-sdd/SKILL.md +36 -1
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
|
-
**
|
|
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
|
-
|
|
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/
|
|
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?**
|
package/bin/kracked-core.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
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) => `[2m${s}[0m`,
|
|
20
|
+
cyan: (s) => `[36m${s}[0m`,
|
|
21
|
+
green: (s) => `[32m${s}[0m`,
|
|
22
|
+
bold: (s) => `[1m${s}[0m`,
|
|
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
|
+
}
|