ace-interview-prep 0.1.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/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/commands/add.js +92 -0
- package/dist/commands/feedback.js +133 -0
- package/dist/commands/generate.js +224 -0
- package/dist/commands/init.js +100 -0
- package/dist/commands/list.js +107 -0
- package/dist/commands/reset.js +68 -0
- package/dist/commands/score.js +70 -0
- package/dist/commands/setup.js +84 -0
- package/dist/commands/test.js +85 -0
- package/dist/index.js +72 -0
- package/dist/lib/categories.js +103 -0
- package/dist/lib/config.js +61 -0
- package/dist/lib/llm.js +134 -0
- package/dist/lib/paths.js +38 -0
- package/dist/lib/scaffold.js +110 -0
- package/dist/lib/scorecard.js +116 -0
- package/dist/prompts/code-review.md +59 -0
- package/dist/prompts/design-review.md +67 -0
- package/dist/prompts/question-brainstorm.md +31 -0
- package/dist/prompts/question-generate.md +65 -0
- package/dist/templates/design/notes.md.hbs +27 -0
- package/dist/templates/js-ts/solution.test.ts.hbs +11 -0
- package/dist/templates/js-ts/solution.ts.hbs +11 -0
- package/dist/templates/leetcode-algo/solution.test.ts.hbs +11 -0
- package/dist/templates/leetcode-algo/solution.ts.hbs +11 -0
- package/dist/templates/leetcode-ds/solution.test.ts.hbs +11 -0
- package/dist/templates/leetcode-ds/solution.ts.hbs +11 -0
- package/dist/templates/react-apps/App.test.tsx.hbs +16 -0
- package/dist/templates/react-apps/App.tsx.hbs +16 -0
- package/dist/templates/readme.md.hbs +9 -0
- package/dist/templates/web-components/component.test.ts.hbs +11 -0
- package/dist/templates/web-components/component.ts.hbs +22 -0
- package/dist/templates/web-components/index.html.hbs +12 -0
- package/package.json +72 -0
- package/questions/design-be/url-shortener/README.md +23 -0
- package/questions/design-be/url-shortener/notes.md +27 -0
- package/questions/design-be/url-shortener/scorecard.json +1 -0
- package/questions/design-fe/news-feed/README.md +22 -0
- package/questions/design-fe/news-feed/notes.md +27 -0
- package/questions/design-fe/news-feed/scorecard.json +1 -0
- package/questions/design-full/google-docs/README.md +22 -0
- package/questions/design-full/google-docs/notes.md +27 -0
- package/questions/design-full/google-docs/scorecard.json +1 -0
- package/questions/js-ts/debounce/README.md +86 -0
- package/questions/js-ts/debounce/scorecard.json +9 -0
- package/questions/js-ts/debounce/solution.test.ts +128 -0
- package/questions/js-ts/debounce/solution.ts +4 -0
- package/questions/leetcode-algo/two-sum/README.md +58 -0
- package/questions/leetcode-algo/two-sum/scorecard.json +1 -0
- package/questions/leetcode-algo/two-sum/solution.test.ts +55 -0
- package/questions/leetcode-algo/two-sum/solution.ts +4 -0
- package/questions/leetcode-ds/lru-cache/README.md +70 -0
- package/questions/leetcode-ds/lru-cache/scorecard.json +1 -0
- package/questions/leetcode-ds/lru-cache/solution.test.ts +82 -0
- package/questions/leetcode-ds/lru-cache/solution.ts +14 -0
- package/questions/react-apps/todo-app/App.test.tsx +130 -0
- package/questions/react-apps/todo-app/App.tsx +10 -0
- package/questions/react-apps/todo-app/README.md +23 -0
- package/questions/react-apps/todo-app/scorecard.json +9 -0
- package/questions/web-components/star-rating/README.md +45 -0
- package/questions/web-components/star-rating/component.test.ts +64 -0
- package/questions/web-components/star-rating/component.ts +28 -0
- package/questions/web-components/star-rating/index.html +14 -0
- package/questions/web-components/star-rating/scorecard.json +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Neel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ace
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ace-interview-prep)
|
|
4
|
+
[](https://github.com/neel/ace-interview-prep/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A CLI tool for staff-engineer-level frontend interview preparation. Scaffolds questions with test cases, tracks progress with scorecards, and provides LLM-powered feedback.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g ace-interview-prep
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or run directly with npx:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx ace-interview-prep help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Configure API Keys
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ace setup
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Stores your OpenAI / Anthropic API keys in `~/.ace/config.json` (one-time, works across all workspaces).
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Non-interactive
|
|
33
|
+
ace setup --openai-key sk-... --anthropic-key sk-ant-...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Initialize a Workspace
|
|
37
|
+
|
|
38
|
+
Navigate to any folder where you want to practice:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ace init
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Creates a `questions/` directory and vitest config files. Then install the test dependencies:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install vitest happy-dom @testing-library/jest-dom
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 3. Practice
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Generate a question via LLM
|
|
54
|
+
ace generate --topic "debounce" --category js-ts --difficulty medium
|
|
55
|
+
|
|
56
|
+
# Interactive brainstorm mode
|
|
57
|
+
ace generate --brainstorm
|
|
58
|
+
|
|
59
|
+
# Manually add a question
|
|
60
|
+
ace add
|
|
61
|
+
|
|
62
|
+
# List all questions
|
|
63
|
+
ace list
|
|
64
|
+
|
|
65
|
+
# Run tests
|
|
66
|
+
ace test debounce
|
|
67
|
+
ace test # run all
|
|
68
|
+
ace test --watch # watch mode
|
|
69
|
+
|
|
70
|
+
# Get LLM feedback
|
|
71
|
+
ace feedback debounce
|
|
72
|
+
|
|
73
|
+
# View scorecard
|
|
74
|
+
ace score debounce
|
|
75
|
+
|
|
76
|
+
# Reset a question
|
|
77
|
+
ace reset debounce
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Question Categories
|
|
81
|
+
|
|
82
|
+
| Category | Slug | Type |
|
|
83
|
+
|----------|------|------|
|
|
84
|
+
| JS/TS Puzzles | `js-ts` | Coding |
|
|
85
|
+
| Web Components | `web-components` | Coding |
|
|
86
|
+
| React Web Apps | `react-apps` | Coding |
|
|
87
|
+
| LeetCode Data Structures | `leetcode-ds` | Coding |
|
|
88
|
+
| LeetCode Algorithms | `leetcode-algo` | Coding |
|
|
89
|
+
| System Design — Frontend | `design-fe` | Design |
|
|
90
|
+
| System Design — Backend | `design-be` | Design |
|
|
91
|
+
| System Design — Full Stack | `design-full` | Design |
|
|
92
|
+
|
|
93
|
+
## How It Works
|
|
94
|
+
|
|
95
|
+
1. **Pick a question** from the dashboard (`ace list`) or generate one (`ace generate`).
|
|
96
|
+
2. **Open the question folder** — read `README.md` for the problem statement.
|
|
97
|
+
3. **Write your solution** in the solution file (`solution.ts`, `App.tsx`, `component.ts`, or `notes.md`).
|
|
98
|
+
4. **Run tests** with `ace test <slug>` to check your work.
|
|
99
|
+
5. **Get feedback** with `ace feedback <slug>` for an LLM-powered code or design review.
|
|
100
|
+
6. **Track progress** with `ace score <slug>` and `ace list`.
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
**Global** (`~/.ace/`) — API keys stored once, shared across all workspaces.
|
|
105
|
+
|
|
106
|
+
- `~/.ace/config.json` — primary config (created by `ace setup`)
|
|
107
|
+
- `~/.ace/.env` — fallback (dotenv format)
|
|
108
|
+
- Environment variables — final fallback
|
|
109
|
+
|
|
110
|
+
**Workspace** — each workspace gets its own `questions/` directory and test config.
|
|
111
|
+
|
|
112
|
+
## Seed Questions
|
|
113
|
+
|
|
114
|
+
Ships with 8 starter questions (one per category) so you can begin practicing immediately after install.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
git clone https://github.com/neel/ace-interview-prep.git
|
|
120
|
+
cd ace-interview-prep
|
|
121
|
+
npm install
|
|
122
|
+
npm run ace help
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development guide.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { CATEGORIES, CATEGORY_SLUGS, slugify } from "../lib/categories.js";
|
|
4
|
+
import { scaffoldQuestion } from "../lib/scaffold.js";
|
|
5
|
+
import { resolveWorkspaceRoot, isWorkspaceInitialized } from "../lib/paths.js";
|
|
6
|
+
async function run(_args) {
|
|
7
|
+
const root = resolveWorkspaceRoot();
|
|
8
|
+
if (!isWorkspaceInitialized(root)) {
|
|
9
|
+
console.log(chalk.yellow("\nWorkspace not initialized. Running init...\n"));
|
|
10
|
+
const initModule = await import("./init.js");
|
|
11
|
+
await initModule.run([]);
|
|
12
|
+
console.log();
|
|
13
|
+
}
|
|
14
|
+
console.log(chalk.cyan("\n--- Add a Question Manually ---\n"));
|
|
15
|
+
const { category } = await prompts({
|
|
16
|
+
type: "select",
|
|
17
|
+
name: "category",
|
|
18
|
+
message: "Category:",
|
|
19
|
+
choices: CATEGORY_SLUGS.map((s) => ({ title: CATEGORIES[s].name, value: s }))
|
|
20
|
+
});
|
|
21
|
+
if (!category) return;
|
|
22
|
+
const { difficulty } = await prompts({
|
|
23
|
+
type: "select",
|
|
24
|
+
name: "difficulty",
|
|
25
|
+
message: "Difficulty:",
|
|
26
|
+
choices: [
|
|
27
|
+
{ title: "Easy", value: "easy" },
|
|
28
|
+
{ title: "Medium", value: "medium" },
|
|
29
|
+
{ title: "Hard", value: "hard" }
|
|
30
|
+
]
|
|
31
|
+
});
|
|
32
|
+
if (!difficulty) return;
|
|
33
|
+
const { title } = await prompts({
|
|
34
|
+
type: "text",
|
|
35
|
+
name: "title",
|
|
36
|
+
message: "Question title:",
|
|
37
|
+
validate: (v) => v.length > 0 ? true : "Title is required"
|
|
38
|
+
});
|
|
39
|
+
if (!title) return;
|
|
40
|
+
const slug = slugify(title);
|
|
41
|
+
const { description } = await prompts({
|
|
42
|
+
type: "text",
|
|
43
|
+
name: "description",
|
|
44
|
+
message: "Problem description (paste markdown, press Enter when done):"
|
|
45
|
+
});
|
|
46
|
+
const config = CATEGORIES[category];
|
|
47
|
+
let signature;
|
|
48
|
+
let testCode;
|
|
49
|
+
let solutionCode;
|
|
50
|
+
if (config.type === "coding") {
|
|
51
|
+
const sigResult = await prompts({
|
|
52
|
+
type: "text",
|
|
53
|
+
name: "signature",
|
|
54
|
+
message: "Function/component signature (optional):"
|
|
55
|
+
});
|
|
56
|
+
signature = sigResult.signature || void 0;
|
|
57
|
+
const testResult = await prompts({
|
|
58
|
+
type: "text",
|
|
59
|
+
name: "testCode",
|
|
60
|
+
message: "Paste test code (optional, press Enter to skip):"
|
|
61
|
+
});
|
|
62
|
+
testCode = testResult.testCode || void 0;
|
|
63
|
+
const solResult = await prompts({
|
|
64
|
+
type: "text",
|
|
65
|
+
name: "solutionCode",
|
|
66
|
+
message: "Paste starter/solution code (optional, press Enter to skip):"
|
|
67
|
+
});
|
|
68
|
+
solutionCode = solResult.solutionCode || void 0;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const questionDir = scaffoldQuestion({
|
|
72
|
+
title,
|
|
73
|
+
slug,
|
|
74
|
+
category,
|
|
75
|
+
difficulty,
|
|
76
|
+
description: description || "",
|
|
77
|
+
signature,
|
|
78
|
+
testCode,
|
|
79
|
+
solutionCode
|
|
80
|
+
});
|
|
81
|
+
console.log(chalk.green(`
|
|
82
|
+
Created: questions/${category}/${slug}/`));
|
|
83
|
+
console.log(chalk.dim(` ${questionDir}`));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof Error) {
|
|
86
|
+
console.error(chalk.red(err.message));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export {
|
|
91
|
+
run
|
|
92
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { findQuestion, readScorecard, writeScorecard } from "../lib/scorecard.js";
|
|
5
|
+
import { CATEGORIES, isDesignCategory } from "../lib/categories.js";
|
|
6
|
+
import { chatStream, requireProvider } from "../lib/llm.js";
|
|
7
|
+
import { resolveWorkspaceRoot, isWorkspaceInitialized } from "../lib/paths.js";
|
|
8
|
+
const PROMPTS_DIR = path.resolve(import.meta.dirname, "../prompts");
|
|
9
|
+
function loadPrompt(name) {
|
|
10
|
+
return fs.readFileSync(path.join(PROMPTS_DIR, name), "utf-8");
|
|
11
|
+
}
|
|
12
|
+
function parseArgs(args) {
|
|
13
|
+
let slug;
|
|
14
|
+
let provider;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg === "--provider" && args[i + 1]) {
|
|
18
|
+
provider = args[++i];
|
|
19
|
+
} else if (!arg.startsWith("--")) {
|
|
20
|
+
slug = arg;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { slug, provider };
|
|
24
|
+
}
|
|
25
|
+
async function run(args) {
|
|
26
|
+
const root = resolveWorkspaceRoot();
|
|
27
|
+
if (!isWorkspaceInitialized(root)) {
|
|
28
|
+
console.error(chalk.red("\nError: Workspace not initialized."));
|
|
29
|
+
console.error(chalk.dim("Run `ace init` in this folder first.\n"));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const parsed = parseArgs(args);
|
|
33
|
+
if (!parsed.slug) {
|
|
34
|
+
console.error(chalk.red("Missing question slug."));
|
|
35
|
+
console.error(chalk.dim("Usage: npm run ace feedback <slug>"));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const question = findQuestion(parsed.slug);
|
|
39
|
+
if (!question) {
|
|
40
|
+
console.error(chalk.red(`Question not found: ${parsed.slug}`));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const provider = requireProvider(parsed.provider);
|
|
44
|
+
const config = CATEGORIES[question.category];
|
|
45
|
+
const isDesign = isDesignCategory(question.category);
|
|
46
|
+
const readmePath = path.join(question.dir, "README.md");
|
|
47
|
+
const readme = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, "utf-8") : "";
|
|
48
|
+
let systemPrompt;
|
|
49
|
+
let userContent;
|
|
50
|
+
if (isDesign) {
|
|
51
|
+
systemPrompt = loadPrompt("design-review.md");
|
|
52
|
+
const notesPath = path.join(question.dir, "notes.md");
|
|
53
|
+
const notes = fs.existsSync(notesPath) ? fs.readFileSync(notesPath, "utf-8") : "";
|
|
54
|
+
if (!notes.trim() || notes.includes("<!-- List the core features")) {
|
|
55
|
+
console.error(chalk.yellow("Notes file appears to be empty. Write your design notes first!"));
|
|
56
|
+
console.error(chalk.dim(`Edit: questions/${question.category}/${parsed.slug}/notes.md`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const designSubType = question.category === "design-fe" ? "frontend" : question.category === "design-be" ? "backend" : "full-stack";
|
|
60
|
+
userContent = `## Design Sub-Type: ${designSubType}
|
|
61
|
+
|
|
62
|
+
## Problem Statement
|
|
63
|
+
${readme}
|
|
64
|
+
|
|
65
|
+
## Candidate's Design Notes
|
|
66
|
+
${notes}`;
|
|
67
|
+
} else {
|
|
68
|
+
systemPrompt = loadPrompt("code-review.md");
|
|
69
|
+
const solutionFiles = config.solutionFiles;
|
|
70
|
+
let solutionContent = "";
|
|
71
|
+
for (const f of solutionFiles) {
|
|
72
|
+
const fp = path.join(question.dir, f);
|
|
73
|
+
if (fs.existsSync(fp)) {
|
|
74
|
+
const content = fs.readFileSync(fp, "utf-8");
|
|
75
|
+
solutionContent += `
|
|
76
|
+
--- ${f} ---
|
|
77
|
+
${content}
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!solutionContent.trim() || solutionContent.includes("// TODO: implement")) {
|
|
82
|
+
console.error(chalk.yellow("Solution appears to be the default stub. Write your solution first!"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let testContent = "";
|
|
86
|
+
for (const f of config.testFiles) {
|
|
87
|
+
const fp = path.join(question.dir, f);
|
|
88
|
+
if (fs.existsSync(fp)) {
|
|
89
|
+
testContent += `
|
|
90
|
+
--- ${f} ---
|
|
91
|
+
${fs.readFileSync(fp, "utf-8")}
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
userContent = `## Problem Statement
|
|
96
|
+
${readme}
|
|
97
|
+
|
|
98
|
+
## Candidate's Solution Code
|
|
99
|
+
${solutionContent}
|
|
100
|
+
|
|
101
|
+
## Test Cases
|
|
102
|
+
${testContent}`;
|
|
103
|
+
}
|
|
104
|
+
console.log(chalk.cyan(`
|
|
105
|
+
--- LLM ${isDesign ? "Design" : "Code"} Review: ${parsed.slug} ---`));
|
|
106
|
+
console.log(chalk.dim(`Provider: ${provider}
|
|
107
|
+
`));
|
|
108
|
+
const messages = [
|
|
109
|
+
{ role: "system", content: systemPrompt },
|
|
110
|
+
{ role: "user", content: userContent }
|
|
111
|
+
];
|
|
112
|
+
const stream = await chatStream(provider, messages);
|
|
113
|
+
let fullResponse = "";
|
|
114
|
+
for await (const chunk of stream) {
|
|
115
|
+
process.stdout.write(chunk);
|
|
116
|
+
fullResponse += chunk;
|
|
117
|
+
}
|
|
118
|
+
console.log("\n");
|
|
119
|
+
const scorecard = readScorecard(question.category, parsed.slug);
|
|
120
|
+
if (scorecard) {
|
|
121
|
+
scorecard.llmFeedback = fullResponse;
|
|
122
|
+
const scoreMatch = fullResponse.match(/Overall.*?(\d+(?:\.\d+)?)\s*\/\s*5/i);
|
|
123
|
+
if (scoreMatch && scorecard.attempts.length > 0) {
|
|
124
|
+
const lastAttempt = scorecard.attempts[scorecard.attempts.length - 1];
|
|
125
|
+
lastAttempt.llmScore = parseFloat(scoreMatch[1]);
|
|
126
|
+
}
|
|
127
|
+
writeScorecard(question.category, parsed.slug, scorecard);
|
|
128
|
+
console.log(chalk.dim("Feedback saved to scorecard."));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
run
|
|
133
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { CATEGORIES, CATEGORY_SLUGS, slugify } from "../lib/categories.js";
|
|
6
|
+
import { chat, chatStream, requireProvider } from "../lib/llm.js";
|
|
7
|
+
import { scaffoldQuestion } from "../lib/scaffold.js";
|
|
8
|
+
import { resolveWorkspaceRoot, isWorkspaceInitialized } from "../lib/paths.js";
|
|
9
|
+
const PROMPTS_DIR = path.resolve(import.meta.dirname, "../prompts");
|
|
10
|
+
function parseArgs(args) {
|
|
11
|
+
const result = {};
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
const arg = args[i];
|
|
14
|
+
if (arg.startsWith("--")) {
|
|
15
|
+
const key = arg.slice(2);
|
|
16
|
+
const next = args[i + 1];
|
|
17
|
+
if (next && !next.startsWith("--")) {
|
|
18
|
+
result[key] = next;
|
|
19
|
+
i++;
|
|
20
|
+
} else {
|
|
21
|
+
result[key] = "true";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
function loadPrompt(name) {
|
|
28
|
+
return fs.readFileSync(path.join(PROMPTS_DIR, name), "utf-8");
|
|
29
|
+
}
|
|
30
|
+
function extractJSON(text) {
|
|
31
|
+
const match = text.match(/```json\s*([\s\S]*?)```/);
|
|
32
|
+
if (match) return match[1].trim();
|
|
33
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
34
|
+
if (jsonMatch) return jsonMatch[0];
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
async function directMode(provider, topic, category, difficulty) {
|
|
38
|
+
const systemPrompt = loadPrompt("question-generate.md");
|
|
39
|
+
const categoryConfig = CATEGORIES[category];
|
|
40
|
+
console.log(chalk.cyan(`
|
|
41
|
+
Generating ${categoryConfig.name} question: "${topic}" (${difficulty})...`));
|
|
42
|
+
const userMessage = `Generate a ${difficulty} difficulty ${categoryConfig.name} interview question about: ${topic}
|
|
43
|
+
|
|
44
|
+
Category slug: ${category}
|
|
45
|
+
Question type: ${categoryConfig.type}`;
|
|
46
|
+
const response = await chat(provider, [
|
|
47
|
+
{ role: "system", content: systemPrompt },
|
|
48
|
+
{ role: "user", content: userMessage }
|
|
49
|
+
], true);
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(extractJSON(response));
|
|
53
|
+
} catch {
|
|
54
|
+
console.error(chalk.red("Failed to parse LLM response as JSON. Raw response:"));
|
|
55
|
+
console.error(response);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const slug = parsed.slug || slugify(parsed.title || topic);
|
|
59
|
+
const questionDir = scaffoldQuestion({
|
|
60
|
+
title: parsed.title || topic,
|
|
61
|
+
slug,
|
|
62
|
+
category,
|
|
63
|
+
difficulty,
|
|
64
|
+
description: parsed.description || "",
|
|
65
|
+
signature: parsed.signature,
|
|
66
|
+
testCode: parsed.testCode,
|
|
67
|
+
solutionCode: parsed.solutionCode
|
|
68
|
+
});
|
|
69
|
+
console.log(chalk.green(`
|
|
70
|
+
Created: questions/${category}/${slug}/`));
|
|
71
|
+
console.log(chalk.dim(` ${questionDir}`));
|
|
72
|
+
}
|
|
73
|
+
async function brainstormMode(provider) {
|
|
74
|
+
const systemPrompt = loadPrompt("question-brainstorm.md");
|
|
75
|
+
const messages = [
|
|
76
|
+
{ role: "system", content: systemPrompt }
|
|
77
|
+
];
|
|
78
|
+
console.log(chalk.cyan("\n--- Brainstorm Mode ---"));
|
|
79
|
+
console.log(chalk.dim('Chat with the LLM to design a question. Type "done" when ready to scaffold.\n'));
|
|
80
|
+
const { area } = await prompts({
|
|
81
|
+
type: "text",
|
|
82
|
+
name: "area",
|
|
83
|
+
message: "What area do you want to practice?"
|
|
84
|
+
});
|
|
85
|
+
if (!area) return;
|
|
86
|
+
messages.push({
|
|
87
|
+
role: "user",
|
|
88
|
+
content: `I want to practice: ${area}. Suggest some question directions across relevant categories.`
|
|
89
|
+
});
|
|
90
|
+
while (true) {
|
|
91
|
+
console.log(chalk.dim("\nThinking...\n"));
|
|
92
|
+
const stream = await chatStream(provider, messages);
|
|
93
|
+
let fullResponse = "";
|
|
94
|
+
for await (const chunk of stream) {
|
|
95
|
+
process.stdout.write(chunk);
|
|
96
|
+
fullResponse += chunk;
|
|
97
|
+
}
|
|
98
|
+
console.log("\n");
|
|
99
|
+
messages.push({ role: "assistant", content: fullResponse });
|
|
100
|
+
const { userInput } = await prompts({
|
|
101
|
+
type: "text",
|
|
102
|
+
name: "userInput",
|
|
103
|
+
message: chalk.dim('Your response (or "done" to scaffold, "quit" to exit):')
|
|
104
|
+
});
|
|
105
|
+
if (!userInput || userInput.toLowerCase() === "quit" || userInput.toLowerCase() === "q") {
|
|
106
|
+
console.log(chalk.yellow("Exiting brainstorm."));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (userInput.toLowerCase() === "done" || userInput.toLowerCase() === "y") {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
messages.push({ role: "user", content: userInput });
|
|
113
|
+
}
|
|
114
|
+
console.log(chalk.cyan("\nNow let me finalize the question..."));
|
|
115
|
+
const { category } = await prompts({
|
|
116
|
+
type: "select",
|
|
117
|
+
name: "category",
|
|
118
|
+
message: "Which category?",
|
|
119
|
+
choices: CATEGORY_SLUGS.map((s) => ({ title: CATEGORIES[s].name, value: s }))
|
|
120
|
+
});
|
|
121
|
+
const { difficulty } = await prompts({
|
|
122
|
+
type: "select",
|
|
123
|
+
name: "difficulty",
|
|
124
|
+
message: "Difficulty?",
|
|
125
|
+
choices: [
|
|
126
|
+
{ title: "Easy", value: "easy" },
|
|
127
|
+
{ title: "Medium", value: "medium" },
|
|
128
|
+
{ title: "Hard", value: "hard" }
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
if (!category || !difficulty) return;
|
|
132
|
+
const generatePrompt = loadPrompt("question-generate.md");
|
|
133
|
+
const brainstormSummary = messages.filter((m) => m.role !== "system").map((m) => `${m.role}: ${m.content}`).join("\n\n");
|
|
134
|
+
const categoryConfig = CATEGORIES[category];
|
|
135
|
+
const response = await chat(
|
|
136
|
+
provider,
|
|
137
|
+
[
|
|
138
|
+
{ role: "system", content: generatePrompt },
|
|
139
|
+
{
|
|
140
|
+
role: "user",
|
|
141
|
+
content: `Based on the following brainstorm conversation, generate a structured ${difficulty} ${categoryConfig.name} interview question.
|
|
142
|
+
|
|
143
|
+
Category slug: ${category}
|
|
144
|
+
Question type: ${categoryConfig.type}
|
|
145
|
+
|
|
146
|
+
Brainstorm conversation:
|
|
147
|
+
${brainstormSummary}`
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
true
|
|
151
|
+
);
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(extractJSON(response));
|
|
155
|
+
} catch {
|
|
156
|
+
console.error(chalk.red("Failed to parse LLM response. Raw:"));
|
|
157
|
+
console.error(response);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const slug = parsed.slug || slugify(parsed.title || "brainstorm-question");
|
|
161
|
+
const questionDir = scaffoldQuestion({
|
|
162
|
+
title: parsed.title,
|
|
163
|
+
slug,
|
|
164
|
+
category,
|
|
165
|
+
difficulty,
|
|
166
|
+
description: parsed.description || "",
|
|
167
|
+
signature: parsed.signature,
|
|
168
|
+
testCode: parsed.testCode,
|
|
169
|
+
solutionCode: parsed.solutionCode
|
|
170
|
+
});
|
|
171
|
+
console.log(chalk.green(`
|
|
172
|
+
Created: questions/${category}/${slug}/`));
|
|
173
|
+
console.log(chalk.dim(` ${questionDir}`));
|
|
174
|
+
}
|
|
175
|
+
async function run(args) {
|
|
176
|
+
const parsed = parseArgs(args);
|
|
177
|
+
const root = resolveWorkspaceRoot();
|
|
178
|
+
if (!isWorkspaceInitialized(root)) {
|
|
179
|
+
console.log(chalk.yellow("\nWorkspace not initialized. Running init...\n"));
|
|
180
|
+
const initModule = await import("./init.js");
|
|
181
|
+
await initModule.run([]);
|
|
182
|
+
console.log();
|
|
183
|
+
}
|
|
184
|
+
const provider = requireProvider(parsed.provider);
|
|
185
|
+
console.log(chalk.dim(`Using LLM provider: ${provider}`));
|
|
186
|
+
if (parsed.brainstorm === "true") {
|
|
187
|
+
await brainstormMode(provider);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!parsed.topic) {
|
|
191
|
+
console.error(chalk.red("Missing --topic. Use --brainstorm for interactive mode."));
|
|
192
|
+
console.error(chalk.dim('Example: npm run ace generate -- --topic "debounce" --category js-ts --difficulty medium'));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let category = parsed.category;
|
|
196
|
+
let difficulty = parsed.difficulty;
|
|
197
|
+
if (!category) {
|
|
198
|
+
const result = await prompts({
|
|
199
|
+
type: "select",
|
|
200
|
+
name: "category",
|
|
201
|
+
message: "Which category?",
|
|
202
|
+
choices: CATEGORY_SLUGS.map((s) => ({ title: CATEGORIES[s].name, value: s }))
|
|
203
|
+
});
|
|
204
|
+
category = result.category;
|
|
205
|
+
}
|
|
206
|
+
if (!difficulty) {
|
|
207
|
+
const result = await prompts({
|
|
208
|
+
type: "select",
|
|
209
|
+
name: "difficulty",
|
|
210
|
+
message: "Difficulty?",
|
|
211
|
+
choices: [
|
|
212
|
+
{ title: "Easy", value: "easy" },
|
|
213
|
+
{ title: "Medium", value: "medium" },
|
|
214
|
+
{ title: "Hard", value: "hard" }
|
|
215
|
+
]
|
|
216
|
+
});
|
|
217
|
+
difficulty = result.difficulty;
|
|
218
|
+
}
|
|
219
|
+
if (!category || !difficulty) return;
|
|
220
|
+
await directMode(provider, parsed.topic, category, difficulty);
|
|
221
|
+
}
|
|
222
|
+
export {
|
|
223
|
+
run
|
|
224
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getQuestionsDir, isWorkspaceInitialized } from "../lib/paths.js";
|
|
5
|
+
function parseArgs(args) {
|
|
6
|
+
return {
|
|
7
|
+
force: args.includes("--force"),
|
|
8
|
+
writeScripts: args.includes("--write-scripts")
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const VITEST_CONFIG_TEMPLATE = `import { defineConfig } from 'vitest/config';
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
test: {
|
|
15
|
+
globals: true,
|
|
16
|
+
environment: 'happy-dom',
|
|
17
|
+
include: ['questions/**/*.test.{ts,tsx}'],
|
|
18
|
+
testTimeout: 10000,
|
|
19
|
+
setupFiles: ['vitest.setup.ts'],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
`;
|
|
23
|
+
const VITEST_SETUP_TEMPLATE = `import '@testing-library/jest-dom/vitest';
|
|
24
|
+
`;
|
|
25
|
+
const PACKAGE_JSON_SCRIPTS = {
|
|
26
|
+
ace: "tsx cli/index.ts",
|
|
27
|
+
test: "vitest run",
|
|
28
|
+
"test:watch": "vitest"
|
|
29
|
+
};
|
|
30
|
+
async function run(args) {
|
|
31
|
+
const { force, writeScripts } = parseArgs(args);
|
|
32
|
+
const root = process.cwd();
|
|
33
|
+
console.log(chalk.cyan("\n--- Initialize Workspace ---"));
|
|
34
|
+
console.log(chalk.dim(`Workspace: ${root}
|
|
35
|
+
`));
|
|
36
|
+
if (isWorkspaceInitialized(root) && !force) {
|
|
37
|
+
console.log(chalk.yellow("\u2713 Workspace already initialized (questions/ exists)"));
|
|
38
|
+
console.log(chalk.dim("Use --force to reinitialize.\n"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const changes = [];
|
|
42
|
+
const questionsDir = getQuestionsDir(root);
|
|
43
|
+
if (!fs.existsSync(questionsDir)) {
|
|
44
|
+
fs.mkdirSync(questionsDir, { recursive: true });
|
|
45
|
+
changes.push("Created questions/");
|
|
46
|
+
}
|
|
47
|
+
const vitestConfigPath = path.join(root, "vitest.config.ts");
|
|
48
|
+
if (!fs.existsSync(vitestConfigPath) || force) {
|
|
49
|
+
fs.writeFileSync(vitestConfigPath, VITEST_CONFIG_TEMPLATE, "utf-8");
|
|
50
|
+
changes.push(force && fs.existsSync(vitestConfigPath) ? "Overwrote vitest.config.ts" : "Created vitest.config.ts");
|
|
51
|
+
}
|
|
52
|
+
const vitestSetupPath = path.join(root, "vitest.setup.ts");
|
|
53
|
+
if (!fs.existsSync(vitestSetupPath) || force) {
|
|
54
|
+
fs.writeFileSync(vitestSetupPath, VITEST_SETUP_TEMPLATE, "utf-8");
|
|
55
|
+
changes.push(force && fs.existsSync(vitestSetupPath) ? "Overwrote vitest.setup.ts" : "Created vitest.setup.ts");
|
|
56
|
+
}
|
|
57
|
+
const packageJsonPath = path.join(root, "package.json");
|
|
58
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
59
|
+
if (writeScripts) {
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
62
|
+
pkg.scripts = pkg.scripts || {};
|
|
63
|
+
let added = false;
|
|
64
|
+
for (const [key, value] of Object.entries(PACKAGE_JSON_SCRIPTS)) {
|
|
65
|
+
if (!pkg.scripts[key]) {
|
|
66
|
+
pkg.scripts[key] = value;
|
|
67
|
+
added = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (added) {
|
|
71
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
72
|
+
changes.push("Added scripts to package.json");
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn(chalk.yellow("Warning: Could not update package.json scripts"));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (changes.length > 0) {
|
|
80
|
+
console.log(chalk.green("\u2713 Workspace initialized:\n"));
|
|
81
|
+
for (const change of changes) {
|
|
82
|
+
console.log(chalk.dim(` \u2022 ${change}`));
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
console.log(chalk.green("\u2713 Workspace already initialized (no changes needed)"));
|
|
86
|
+
}
|
|
87
|
+
console.log();
|
|
88
|
+
console.log(chalk.bold("Next steps:"));
|
|
89
|
+
console.log(chalk.dim(" 1. Install dependencies (if not already installed):"));
|
|
90
|
+
console.log(chalk.dim(" npm install vitest happy-dom @testing-library/jest-dom"));
|
|
91
|
+
console.log(chalk.dim(" 2. Configure API keys:"));
|
|
92
|
+
console.log(chalk.dim(" ace setup"));
|
|
93
|
+
console.log(chalk.dim(" 3. Generate or add questions:"));
|
|
94
|
+
console.log(chalk.dim(' ace generate --topic "debounce"'));
|
|
95
|
+
console.log(chalk.dim(" ace add"));
|
|
96
|
+
console.log();
|
|
97
|
+
}
|
|
98
|
+
export {
|
|
99
|
+
run
|
|
100
|
+
};
|