dw-kit 1.0.1 → 1.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/.claude/rules/code-style.md +37 -37
- package/.claude/rules/commit-standards.md +37 -37
- package/.claude/rules/workflow-rules.md +15 -0
- package/.claude/settings.json +1 -1
- package/.claude/skills/dw-onboard/SKILL.md +201 -0
- package/.claude/skills/dw-prompt/SKILL.md +62 -0
- package/.claude/skills/dw-retroactive/SKILL.md +311 -0
- package/CLAUDE.md +13 -0
- package/README.md +22 -27
- package/package.json +8 -2
- package/src/__fixtures__/claude-cli-bug-snippet.js +15 -0
- package/src/cli.mjs +33 -0
- package/src/commands/claude-vn-fix.mjs +267 -0
- package/src/commands/init.mjs +1 -1
- package/src/commands/prompt.mjs +112 -0
- package/src/lib/clipboard.mjs +24 -0
- package/src/lib/prompt-suggest.mjs +84 -0
- package/src/lib/update-checker.mjs +73 -0
- package/src/smoke-test.mjs +47 -1
- package/.claude/settings.local.json +0 -12
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import { header, info, ok, warn, err, log } from '../lib/ui.mjs';
|
|
6
|
+
|
|
7
|
+
export const PATCH_MARKER = '/* dw-kit Vietnamese IME fix */';
|
|
8
|
+
export const DEL_CHAR = '\x7f';
|
|
9
|
+
|
|
10
|
+
export async function claudeVnFixCommand(opts) {
|
|
11
|
+
header('dw-kit Claude Vietnamese IME Fix');
|
|
12
|
+
|
|
13
|
+
const filePath = opts.path ? opts.path : findCliJs();
|
|
14
|
+
log(`Target file: ${filePath}`);
|
|
15
|
+
|
|
16
|
+
if (!existsSync(filePath)) {
|
|
17
|
+
err(`File not found: ${filePath}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (opts.restore) {
|
|
22
|
+
info('Restoring from latest backup');
|
|
23
|
+
const restored = restoreLatestBackup(filePath, { dryRun: !!opts.dryRun });
|
|
24
|
+
if (!restored) process.exit(1);
|
|
25
|
+
ok('Restore complete. Restart Claude CLI.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
info('Patching');
|
|
30
|
+
const result = patchCliJs(filePath, { dryRun: !!opts.dryRun });
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
err(result.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
ok(result.message);
|
|
36
|
+
log('Restart Claude CLI for changes to take effect.');
|
|
37
|
+
warn('Note: This modifies a third-party installed file; you may need to re-run after Claude updates.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findCliJs() {
|
|
41
|
+
// Strategy: check global npm root first, then common cache locations.
|
|
42
|
+
// This is intentionally conservative (no deep recursive scan of entire home).
|
|
43
|
+
const candidates = [];
|
|
44
|
+
|
|
45
|
+
// npm root -g
|
|
46
|
+
const npmRoot = tryNpmRootGlobal();
|
|
47
|
+
if (npmRoot) {
|
|
48
|
+
candidates.push(join(npmRoot, '@anthropic-ai', 'claude-code', 'cli.js'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const home = os.homedir();
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
const appData = process.env.APPDATA || '';
|
|
54
|
+
const localAppData = process.env.LOCALAPPDATA || '';
|
|
55
|
+
if (appData) candidates.push(join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'));
|
|
56
|
+
if (localAppData) {
|
|
57
|
+
const npxBase = join(localAppData, 'npm-cache', '_npx');
|
|
58
|
+
const latest = findLatestNpxClaudeCli(npxBase);
|
|
59
|
+
if (latest) candidates.push(latest);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
const npxLatest = findLatestNpxClaudeCli(join(home, '.npm', '_npx'));
|
|
63
|
+
if (npxLatest) candidates.push(npxLatest);
|
|
64
|
+
candidates.push('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
65
|
+
candidates.push('/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const p of candidates) {
|
|
69
|
+
if (p && existsSync(p) && statSync(p).isFile()) return p;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Could not auto-detect @anthropic-ai/claude-code/cli.js.\n' +
|
|
74
|
+
'Provide it explicitly via: dw claude-vn-fix --path "<path-to-cli.js>"',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function tryNpmRootGlobal() {
|
|
79
|
+
try {
|
|
80
|
+
const out = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 5000 });
|
|
81
|
+
return out.trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function safeMtime(p) {
|
|
88
|
+
try { return statSync(p).mtimeMs || 0; } catch { return 0; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findLatestNpxClaudeCli(npxBase) {
|
|
92
|
+
try {
|
|
93
|
+
if (!npxBase || !existsSync(npxBase)) return null;
|
|
94
|
+
const entries = readdirSync(npxBase, { withFileTypes: true })
|
|
95
|
+
.filter((e) => e.isDirectory())
|
|
96
|
+
.map((e) => join(npxBase, e.name));
|
|
97
|
+
const sorted = entries
|
|
98
|
+
.map((d) => ({ d, t: safeMtime(d) }))
|
|
99
|
+
.sort((a, b) => b.t - a.t)
|
|
100
|
+
.map((x) => x.d);
|
|
101
|
+
|
|
102
|
+
for (const dir of sorted.slice(0, 50)) {
|
|
103
|
+
const p = join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
|
|
104
|
+
if (existsSync(p)) return p;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function patchCliJs(filePath, { dryRun }) {
|
|
113
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
114
|
+
|
|
115
|
+
// Guard: ensure this is actually a Claude CLI bundle before patching.
|
|
116
|
+
if (!content.includes('@anthropic-ai') || !content.includes('claude-code')) {
|
|
117
|
+
return { ok: false, message: 'File does not appear to be a Claude CLI bundle. Use --path to specify the correct file.' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (content.includes(PATCH_MARKER)) {
|
|
121
|
+
return { ok: true, message: 'Already patched (marker found).' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const idx = findBugPatternIndex(content);
|
|
125
|
+
if (idx === -1) {
|
|
126
|
+
return { ok: false, message: 'Bug pattern not found (.includes("\\x7f")). Claude may already be fixed upstream.' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { start, end, block } = findIfBlock(content, idx);
|
|
130
|
+
const vars = extractVariables(block);
|
|
131
|
+
const fixCode = generateFix(vars);
|
|
132
|
+
const patched = content.slice(0, start) + fixCode + content.slice(end);
|
|
133
|
+
|
|
134
|
+
if (dryRun) {
|
|
135
|
+
log('DRY RUN: would create backup and patch file.');
|
|
136
|
+
log(`Detected vars: input=${vars.input}, state=${vars.state}, cur=${vars.curState}`);
|
|
137
|
+
return { ok: true, message: 'Dry run OK.' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const backupPath = createBackup(filePath);
|
|
141
|
+
ok(`Backup created: ${backupPath}`);
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(filePath, patched, 'utf-8');
|
|
144
|
+
const verify = readFileSync(filePath, 'utf-8');
|
|
145
|
+
if (!verify.includes(PATCH_MARKER)) throw new Error('verify failed (marker missing after write)');
|
|
146
|
+
return { ok: true, message: `Patch applied. Backup: ${backupPath}` };
|
|
147
|
+
} catch (e) {
|
|
148
|
+
warn(`Patch failed: ${e.message}`);
|
|
149
|
+
warn('Rolling back from backup.');
|
|
150
|
+
try {
|
|
151
|
+
copyFileSync(backupPath, filePath);
|
|
152
|
+
} catch (rollbackErr) {
|
|
153
|
+
return { ok: false, message: `Patch failed AND rollback failed: ${rollbackErr.message}. Manual restore from: ${backupPath}` };
|
|
154
|
+
}
|
|
155
|
+
return { ok: false, message: `Patch failed and rolled back: ${e.message}` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function findBugPatternIndex(content) {
|
|
160
|
+
// Claude builds may contain either:
|
|
161
|
+
// - literal escape sequence: ".includes(\"\\x7f\")"
|
|
162
|
+
// - actual DEL char 0x7f inside the string: ".includes(\"\")"
|
|
163
|
+
const literal = content.indexOf('.includes("\\x7f")');
|
|
164
|
+
if (literal !== -1) return literal;
|
|
165
|
+
return content.indexOf(`.includes("${DEL_CHAR}")`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createBackup(filePath) {
|
|
169
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
170
|
+
const backupPath = `${filePath}.backup-${ts}`;
|
|
171
|
+
copyFileSync(filePath, backupPath);
|
|
172
|
+
return backupPath;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function restoreLatestBackup(filePath, { dryRun }) {
|
|
176
|
+
const dir = dirname(filePath);
|
|
177
|
+
const base = filePath.split(/[\\/]/).pop();
|
|
178
|
+
const backups = readdirSync(dir)
|
|
179
|
+
.filter((f) => f.startsWith(`${base}.backup-`))
|
|
180
|
+
.map((f) => join(dir, f))
|
|
181
|
+
.map((p) => ({ p, t: safeMtime(p) }))
|
|
182
|
+
.sort((a, b) => b.t - a.t);
|
|
183
|
+
|
|
184
|
+
const latest = backups[0]?.p;
|
|
185
|
+
if (!latest) {
|
|
186
|
+
err('No backups found to restore.');
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (dryRun) {
|
|
191
|
+
log(`DRY RUN: would restore from ${latest}`);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
copyFileSync(latest, filePath);
|
|
196
|
+
ok(`Restored from: ${latest}`);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function findIfBlock(content, idx) {
|
|
201
|
+
// Search backward from idx within a 500-char window to find the nearest if(
|
|
202
|
+
const windowStart = Math.max(0, idx - 500);
|
|
203
|
+
const searchSlice = content.slice(windowStart, idx);
|
|
204
|
+
const localOffset = searchSlice.lastIndexOf('if(');
|
|
205
|
+
if (localOffset === -1) throw new Error(`Could not find containing if(...) block near index ${idx}`);
|
|
206
|
+
const start = windowStart + localOffset;
|
|
207
|
+
|
|
208
|
+
let depth = 0;
|
|
209
|
+
let end = -1;
|
|
210
|
+
for (let i = start; i < content.length; i++) {
|
|
211
|
+
const c = content[i];
|
|
212
|
+
if (c === '{') depth++;
|
|
213
|
+
else if (c === '}') {
|
|
214
|
+
depth--;
|
|
215
|
+
if (depth === 0) { end = i + 1; break; }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (end === -1) throw new Error('Could not find end of if block (brace mismatch)');
|
|
219
|
+
if (idx < start || idx > end) throw new Error('Bug pattern found outside expected if block');
|
|
220
|
+
return { start, end, block: content.slice(start, end) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractVariables(block) {
|
|
224
|
+
// Normalize DEL char for regex matching
|
|
225
|
+
const normalized = block.replaceAll(DEL_CHAR, '\\x7f');
|
|
226
|
+
|
|
227
|
+
// Match: let COUNT=(INPUT.match(/\x7f/g)||[]).length,STATE=CURSTATE;
|
|
228
|
+
const m = normalized.match(/let ([\w$]+)=\(\w+\.match\(\/\\x7f\/g\)\|\|\[\]\)\.length[,;]([\w$]+)=([\w$]+)[;,]/);
|
|
229
|
+
if (!m) throw new Error('Could not extract variables (count/state/curState)');
|
|
230
|
+
const state = m[2];
|
|
231
|
+
const curState = m[3];
|
|
232
|
+
|
|
233
|
+
const m2 = block.match(new RegExp(`([\\w$]+)\\(${escapeRegex(state)}\\.text\\);([\\w$]+)\\(${escapeRegex(state)}\\.offset\\)`));
|
|
234
|
+
if (!m2) throw new Error('Could not extract update functions');
|
|
235
|
+
|
|
236
|
+
const m3 = block.match(/([\w$]+)\.includes\("/);
|
|
237
|
+
if (!m3) throw new Error('Could not extract input variable');
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
input: m3[1],
|
|
241
|
+
state,
|
|
242
|
+
curState,
|
|
243
|
+
updateText: m2[1],
|
|
244
|
+
updateOffset: m2[2],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function generateFix(v) {
|
|
249
|
+
// This mirrors the known fix: backspace N times, then insert replacement text.
|
|
250
|
+
return (
|
|
251
|
+
`${PATCH_MARKER}` +
|
|
252
|
+
`if(${v.input}.includes("\\x7f")){` +
|
|
253
|
+
`let _n=(${v.input}.match(/\\x7f/g)||[]).length,` +
|
|
254
|
+
`_vn=${v.input}.replace(/\\x7f/g,""),` +
|
|
255
|
+
`${v.state}=${v.curState};` +
|
|
256
|
+
`for(let _i=0;_i<_n;_i++)${v.state}=${v.state}.backspace();` +
|
|
257
|
+
`for(const _c of _vn)${v.state}=${v.state}.insert(_c);` +
|
|
258
|
+
`if(!${v.curState}.equals(${v.state})){` +
|
|
259
|
+
`if(${v.curState}.text!==${v.state}.text)${v.updateText}(${v.state}.text);` +
|
|
260
|
+
`${v.updateOffset}(${v.state}.offset)` +
|
|
261
|
+
`}return;}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function escapeRegex(s) {
|
|
266
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
267
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -276,7 +276,7 @@ function createRuntimeDirs(projectDir) {
|
|
|
276
276
|
|
|
277
277
|
function updateGitignore(projectDir) {
|
|
278
278
|
const gitignorePath = join(projectDir, '.gitignore');
|
|
279
|
-
const entriesToAdd = ['.
|
|
279
|
+
const entriesToAdd = ['CLAUDE.local.md', '.claude/settings.local.json'];
|
|
280
280
|
|
|
281
281
|
if (existsSync(gitignorePath)) {
|
|
282
282
|
const content = readFileSync(gitignorePath, 'utf-8');
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { header, info, ok, warn, err, log } from '../lib/ui.mjs';
|
|
2
|
+
import { copyToClipboard } from '../lib/clipboard.mjs';
|
|
3
|
+
import { getSuggestions, isVague, expandTemplate } from '../lib/prompt-suggest.mjs';
|
|
4
|
+
import { detectPlatform } from '../lib/platform.mjs';
|
|
5
|
+
|
|
6
|
+
export async function promptCommand(opts) {
|
|
7
|
+
header('dw-kit Prompt Builder');
|
|
8
|
+
|
|
9
|
+
const adapter = detectPlatform(process.cwd());
|
|
10
|
+
|
|
11
|
+
// Non-interactive mode: --text <text>
|
|
12
|
+
if (opts.text !== undefined) {
|
|
13
|
+
if (!opts.text.trim()) {
|
|
14
|
+
err('--text cannot be empty.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const result = await buildPrompt(opts.text, { interactive: false });
|
|
18
|
+
outputResult(result, adapter);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Interactive mode
|
|
23
|
+
const result = await buildPrompt('', { interactive: true });
|
|
24
|
+
outputResult(result, adapter);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function buildPrompt(initialText, { interactive }) {
|
|
28
|
+
let description = initialText;
|
|
29
|
+
|
|
30
|
+
if (interactive) {
|
|
31
|
+
description = await runAutocompleteStep();
|
|
32
|
+
if (!description.trim()) {
|
|
33
|
+
err('No description provided.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let area = '';
|
|
39
|
+
let outcome = '';
|
|
40
|
+
|
|
41
|
+
if (isVague(description)) {
|
|
42
|
+
if (interactive) {
|
|
43
|
+
info('Description seems short — a couple of quick questions (Enter to skip):');
|
|
44
|
+
({ area, outcome } = await runWizardStep());
|
|
45
|
+
}
|
|
46
|
+
// In non-interactive mode: expand with just the description (no wizard data)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return expandTemplate(description, { area, outcome });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runAutocompleteStep() {
|
|
53
|
+
const { AutoComplete } = await import('enquirer');
|
|
54
|
+
const suggestions = getSuggestions(process.cwd());
|
|
55
|
+
|
|
56
|
+
const prompt = new AutoComplete({
|
|
57
|
+
name: 'description',
|
|
58
|
+
message: 'Describe your task:',
|
|
59
|
+
limit: 7,
|
|
60
|
+
choices: suggestions.length ? suggestions : ['(no suggestions — type your task)'],
|
|
61
|
+
suggest(typed, choices) {
|
|
62
|
+
const lower = typed.toLowerCase();
|
|
63
|
+
const filtered = choices.filter((c) => c.message.toLowerCase().includes(lower));
|
|
64
|
+
// Always offer the raw typed text as first selectable option
|
|
65
|
+
if (typed && !filtered.find((c) => c.message === typed)) {
|
|
66
|
+
return [{ message: typed, value: typed }, ...filtered];
|
|
67
|
+
}
|
|
68
|
+
return filtered.length ? filtered : [{ message: typed || '(empty)', value: typed }];
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return prompt.run();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runWizardStep() {
|
|
76
|
+
const { Input } = await import('enquirer');
|
|
77
|
+
|
|
78
|
+
const area = await new Input({
|
|
79
|
+
name: 'area',
|
|
80
|
+
message: 'Which area/files? (e.g. auth middleware, src/login/)',
|
|
81
|
+
initial: '',
|
|
82
|
+
}).run().catch(() => '');
|
|
83
|
+
|
|
84
|
+
const outcome = await new Input({
|
|
85
|
+
name: 'outcome',
|
|
86
|
+
message: 'Expected outcome? (e.g. user redirected to /dashboard)',
|
|
87
|
+
initial: '',
|
|
88
|
+
}).run().catch(() => '');
|
|
89
|
+
|
|
90
|
+
return { area, outcome };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function outputResult(text, adapter) {
|
|
94
|
+
log('');
|
|
95
|
+
log('─── Result ───────────────────────────────────────────');
|
|
96
|
+
log(text);
|
|
97
|
+
log('──────────────────────────────────────────────────────');
|
|
98
|
+
log('');
|
|
99
|
+
|
|
100
|
+
const shouldCopy = adapter === 'claude-cli' || adapter === 'cursor';
|
|
101
|
+
if (shouldCopy) {
|
|
102
|
+
const copied = copyToClipboard(text);
|
|
103
|
+
if (copied) {
|
|
104
|
+
ok('Copied to clipboard. Paste into Claude CLI or your IDE.');
|
|
105
|
+
} else {
|
|
106
|
+
warn('Clipboard copy failed. Copy the text above manually.');
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// generic adapter: just output to stdout
|
|
110
|
+
info('(generic adapter — copy the text above manually)');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:process';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copy text to system clipboard.
|
|
6
|
+
* @returns {boolean} true if succeeded
|
|
7
|
+
*/
|
|
8
|
+
export function copyToClipboard(text) {
|
|
9
|
+
const candidates = platform === 'win32'
|
|
10
|
+
? [['clip']]
|
|
11
|
+
: platform === 'darwin'
|
|
12
|
+
? [['pbcopy']]
|
|
13
|
+
: [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '--clipboard', '--input']];
|
|
14
|
+
|
|
15
|
+
for (const [cmd, ...args] of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
const result = spawnSync(cmd, args, { input: text, encoding: 'utf-8' });
|
|
18
|
+
if (result.status === 0) return true;
|
|
19
|
+
} catch {
|
|
20
|
+
// Try next candidate.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const TEMPLATE_SUGGESTIONS = [
|
|
4
|
+
'fix: ',
|
|
5
|
+
'fix authentication redirect after login',
|
|
6
|
+
'fix null pointer / undefined error in ',
|
|
7
|
+
'fix performance issue in ',
|
|
8
|
+
'feat: add ',
|
|
9
|
+
'feat: implement ',
|
|
10
|
+
'feat: support ',
|
|
11
|
+
'refactor: simplify ',
|
|
12
|
+
'refactor: extract ',
|
|
13
|
+
'refactor: rename ',
|
|
14
|
+
'perf: optimize ',
|
|
15
|
+
'perf: reduce load time of ',
|
|
16
|
+
'chore: update dependencies',
|
|
17
|
+
'docs: update README',
|
|
18
|
+
'test: add tests for ',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Common verbs to detect whether a description has intent
|
|
22
|
+
const INTENT_VERBS = [
|
|
23
|
+
'fix', 'add', 'update', 'remove', 'delete', 'create', 'implement', 'refactor',
|
|
24
|
+
'rename', 'move', 'improve', 'optimize', 'support', 'extract', 'migrate',
|
|
25
|
+
'replace', 'upgrade', 'enable', 'disable', 'integrate', 'patch',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function getTemplateSuggestions() {
|
|
29
|
+
return [...TEMPLATE_SUGGESTIONS];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getGitSuggestions(cwd) {
|
|
33
|
+
try {
|
|
34
|
+
const out = execSync('git log --oneline -50 --no-merges', {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
timeout: 3000,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
});
|
|
40
|
+
return out
|
|
41
|
+
.trim()
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((line) => line.replace(/^[a-f0-9]+ /, '').trim()) // strip hash
|
|
45
|
+
.filter((msg) => msg.length > 5)
|
|
46
|
+
.slice(0, 30);
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getSuggestions(cwd = process.cwd()) {
|
|
53
|
+
const git = getGitSuggestions(cwd);
|
|
54
|
+
const templates = getTemplateSuggestions();
|
|
55
|
+
// git log first (most relevant to this repo), then templates
|
|
56
|
+
const merged = [...git, ...templates];
|
|
57
|
+
// dedupe
|
|
58
|
+
return [...new Set(merged)].slice(0, 20);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true if the description is likely too vague to give Claude good context.
|
|
63
|
+
*/
|
|
64
|
+
export function isVague(text) {
|
|
65
|
+
const trimmed = (text || '').trim();
|
|
66
|
+
if (trimmed.length < 50) return true;
|
|
67
|
+
const lower = trimmed.toLowerCase();
|
|
68
|
+
const hasVerb = INTENT_VERBS.some((v) => lower.startsWith(v) || lower.includes(` ${v} `));
|
|
69
|
+
return !hasVerb;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Expand a short description into a structured prompt.
|
|
74
|
+
* @param {string} text - core task description
|
|
75
|
+
* @param {{ area?: string, outcome?: string }} extras
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function expandTemplate(text, { area = '', outcome = '' } = {}) {
|
|
79
|
+
const base = text.trim();
|
|
80
|
+
const parts = [base];
|
|
81
|
+
if (area) parts.push(`Scope: ${area.trim()}.`);
|
|
82
|
+
if (outcome) parts.push(`Expected: ${outcome.trim()}.`);
|
|
83
|
+
return parts.join('\n');
|
|
84
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
6
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/dw-kit/latest';
|
|
7
|
+
const CACHE_DIR = join(homedir(), '.dw-kit');
|
|
8
|
+
const CACHE_FILE = join(CACHE_DIR, 'update-cache.json');
|
|
9
|
+
|
|
10
|
+
function parseSemver(v) {
|
|
11
|
+
const parts = String(v).replace(/^v/, '').split('.').map(Number);
|
|
12
|
+
return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isNewer(latest, current) {
|
|
16
|
+
const l = parseSemver(latest);
|
|
17
|
+
const c = parseSemver(current);
|
|
18
|
+
if (l.major !== c.major) return l.major > c.major;
|
|
19
|
+
if (l.minor !== c.minor) return l.minor > c.minor;
|
|
20
|
+
return l.patch > c.patch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readCache() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeCache(data) {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
34
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8');
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore write errors (permission, disk full, etc.)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchLatestVersion() {
|
|
41
|
+
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
|
|
42
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
return data.version;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns latest version string if an update is available (from cache), null otherwise.
|
|
49
|
+
* Never throws, never makes network calls.
|
|
50
|
+
*/
|
|
51
|
+
export function getUpdateNotice(currentVersion) {
|
|
52
|
+
if (process.env.DW_NO_UPDATE_CHECK) return null;
|
|
53
|
+
const cache = readCache();
|
|
54
|
+
if (!cache?.latest) return null;
|
|
55
|
+
return isNewer(cache.latest, currentVersion) ? cache.latest : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fires off an async check against npm registry and updates the cache.
|
|
60
|
+
* Non-blocking — caller should NOT await this.
|
|
61
|
+
* Skips the check if cache is still fresh (< 24h).
|
|
62
|
+
*/
|
|
63
|
+
export function scheduleUpdateCheck(currentVersion) {
|
|
64
|
+
if (process.env.DW_NO_UPDATE_CHECK) return;
|
|
65
|
+
|
|
66
|
+
const cache = readCache();
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
if (cache?.checkedAt && now - cache.checkedAt < CACHE_TTL_MS) return;
|
|
69
|
+
|
|
70
|
+
fetchLatestVersion()
|
|
71
|
+
.then((latest) => writeCache({ latest, checkedAt: now, current: currentVersion }))
|
|
72
|
+
.catch(() => {});
|
|
73
|
+
}
|
package/src/smoke-test.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
|
11
11
|
import { join, resolve } from 'node:path';
|
|
12
12
|
import { execSync } from 'node:child_process';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { copyFileSync } from 'node:fs';
|
|
14
15
|
|
|
15
16
|
const __dirname = resolve(fileURLToPath(import.meta.url), '..');
|
|
16
17
|
const DW_BIN = resolve(__dirname, '..', 'bin', 'dw.mjs');
|
|
@@ -72,7 +73,7 @@ test('--version returns semver', () => {
|
|
|
72
73
|
|
|
73
74
|
test('--help lists all commands', () => {
|
|
74
75
|
const out = dw('--help', TEMP_BASE);
|
|
75
|
-
for (const cmd of ['init', 'upgrade', 'validate', 'doctor']) {
|
|
76
|
+
for (const cmd of ['init', 'upgrade', 'validate', 'doctor', 'prompt', 'claude-vn-fix']) {
|
|
76
77
|
assert(out.includes(cmd), `Missing command: ${cmd}`);
|
|
77
78
|
}
|
|
78
79
|
});
|
|
@@ -262,6 +263,51 @@ test('doctor reports issues on empty project', () => {
|
|
|
262
263
|
}
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
// ── Test: dw prompt ──────────────────────────────────────────────────────────
|
|
267
|
+
console.log();
|
|
268
|
+
console.log('▶ dw prompt');
|
|
269
|
+
|
|
270
|
+
test('prompt --text outputs structured result', () => {
|
|
271
|
+
const out = dw('prompt --text "fix login redirect after OAuth in auth middleware"', TEMP_BASE);
|
|
272
|
+
assert(out.includes('fix login redirect'), 'Should include description in output');
|
|
273
|
+
assert(!out.includes('Description seems short'), 'Long description should skip wizard');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('prompt --text with short input expands without error', () => {
|
|
277
|
+
const out = dw('prompt --text "fix login"', TEMP_BASE);
|
|
278
|
+
assert(out.includes('fix login'), 'Should include description in output');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('prompt --text empty string exits with error', () => {
|
|
282
|
+
try {
|
|
283
|
+
dw('prompt --text ""', TEMP_BASE);
|
|
284
|
+
assert(false, 'Should have thrown');
|
|
285
|
+
} catch (e) {
|
|
286
|
+
assert(e.status === 1, 'Should exit with code 1');
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('prompt --help shows options', () => {
|
|
291
|
+
const out = dw('prompt --help', TEMP_BASE);
|
|
292
|
+
assert(out.includes('--text'), 'Missing --text option');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ── Test: dw claude-vn-fix (fixture patch) ───────────────────────────────────
|
|
296
|
+
console.log();
|
|
297
|
+
console.log('▶ dw claude-vn-fix');
|
|
298
|
+
|
|
299
|
+
test('claude-vn-fix patches known bug fixture', () => {
|
|
300
|
+
const dir = freshDir('claude-vn-fix-fixture');
|
|
301
|
+
const fixtureSrc = resolve(__dirname, '__fixtures__', 'claude-cli-bug-snippet.js');
|
|
302
|
+
const target = join(dir, 'cli.js');
|
|
303
|
+
copyFileSync(fixtureSrc, target);
|
|
304
|
+
|
|
305
|
+
const out = dw(`claude-vn-fix --path "${target}"`, dir);
|
|
306
|
+
assert(out.includes('Patch applied') || out.includes('Already patched'), 'Should apply patch');
|
|
307
|
+
const patched = readFileSync(target, 'utf-8');
|
|
308
|
+
assert(patched.includes('dw-kit Vietnamese IME fix'), 'Patch marker missing in output file');
|
|
309
|
+
});
|
|
310
|
+
|
|
265
311
|
// ── Test: dw upgrade ─────────────────────────────────────────────────────────
|
|
266
312
|
console.log();
|
|
267
313
|
console.log('▶ dw upgrade');
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"WebFetch(domain:alexop.dev)",
|
|
5
|
-
"WebFetch(domain:github.com)",
|
|
6
|
-
"WebFetch(domain:docs.anthropic.com)",
|
|
7
|
-
"Bash(grep -rL \"^name:\" .claude/skills/dw-*/SKILL.md)",
|
|
8
|
-
"Bash(grep -c '\\\\$ARGUMENTS\\\\|context: fork\\\\|allowed-tools' .dw/adapters/generic/AGENT.md)",
|
|
9
|
-
"Bash(grep [v1.0.0] CHANGELOG.md)"
|
|
10
|
-
]
|
|
11
|
-
}
|
|
12
|
-
}
|