@cspeach/cli 1.1.10 → 1.1.11
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.
|
@@ -373,6 +373,12 @@ export function resolveAtToken(args) {
|
|
|
373
373
|
if (matches.length === 0) {
|
|
374
374
|
return { kind: 'notFound', token, workspace };
|
|
375
375
|
}
|
|
376
|
+
// 2026-09-17 — an EXACT filename beats substring siblings: `@spec.md`
|
|
377
|
+
// must not be "ambiguous" just because `old-spec.md` sits beside it.
|
|
378
|
+
const exact = matches.filter((f) => f.filename.toLowerCase() === lower);
|
|
379
|
+
if (exact.length === 1) {
|
|
380
|
+
return { kind: 'path', path: exact[0].path };
|
|
381
|
+
}
|
|
376
382
|
return { kind: 'ambiguous', matches };
|
|
377
383
|
}
|
|
378
384
|
/**
|
|
@@ -442,6 +448,11 @@ export function resolveBareTokenInCwd(token, cwd) {
|
|
|
442
448
|
return { kind: 'path', path: matchedFiles[0].path };
|
|
443
449
|
}
|
|
444
450
|
if (matchedFiles.length > 1) {
|
|
451
|
+
// 2026-09-17 — exact filename wins over substring siblings (see resolveAtToken).
|
|
452
|
+
const exact = matchedFiles.filter((f) => f.filename.toLowerCase() === lower);
|
|
453
|
+
if (exact.length === 1) {
|
|
454
|
+
return { kind: 'path', path: exact[0].path };
|
|
455
|
+
}
|
|
445
456
|
return { kind: 'ambiguous', matches: matchedFiles };
|
|
446
457
|
}
|
|
447
458
|
return null;
|
|
@@ -545,10 +556,21 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
545
556
|
const matches = [];
|
|
546
557
|
let m;
|
|
547
558
|
while ((m = tokenRe.exec(userMessage)) !== null) {
|
|
548
|
-
|
|
559
|
+
let token = m[1] ?? m[2] ?? '';
|
|
560
|
+
let match = m[0];
|
|
549
561
|
if (token.length === 0)
|
|
550
562
|
continue;
|
|
551
|
-
|
|
563
|
+
// 2026-09-17 — an unquoted token keeps the sentence punctuation that
|
|
564
|
+
// follows it ("review @spec.docx, then …"); strip it so the file still
|
|
565
|
+
// resolves, and leave it in the message by shortening the match.
|
|
566
|
+
if (m[1] === undefined) {
|
|
567
|
+
const trail = /[,;:.!?)]+$/.exec(token);
|
|
568
|
+
if (trail && trail[0].length < token.length) {
|
|
569
|
+
token = token.slice(0, -trail[0].length);
|
|
570
|
+
match = match.slice(0, -trail[0].length);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
matches.push({ match, token, index: m.index });
|
|
552
574
|
}
|
|
553
575
|
if (matches.length === 0)
|
|
554
576
|
return userMessage;
|
|
@@ -557,6 +579,23 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
557
579
|
const substitutions = [];
|
|
558
580
|
for (const { match, token, index } of matches) {
|
|
559
581
|
const resolved = await resolveAtTokenAsync(token, cwd);
|
|
582
|
+
// 2026-09-17 — say so when a reference does not attach. Before, an
|
|
583
|
+
// ambiguous or missing @file was left as literal text with no message,
|
|
584
|
+
// and the model guessed. Missing-file notes only for tokens that look
|
|
585
|
+
// like a supported file, so `user@company.com` stays silent.
|
|
586
|
+
if (resolved.kind === 'ambiguous') {
|
|
587
|
+
if (log) {
|
|
588
|
+
const names = resolved.matches.map((m) => m.filename).join(', ');
|
|
589
|
+
log(`(@${token} matches ${resolved.matches.length} files: ${names} — use the full name)`);
|
|
590
|
+
}
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (resolved.kind === 'notFound') {
|
|
594
|
+
if (log && isAttachableFilename(token.split(/[\\/]/).pop() ?? token)) {
|
|
595
|
+
log(`(@${token} not found — looked in ${cwd} and the workspace)`);
|
|
596
|
+
}
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
560
599
|
if (resolved.kind !== 'path')
|
|
561
600
|
continue;
|
|
562
601
|
const path = resolved.path;
|
package/dist/repl/at-picker.js
CHANGED
|
@@ -24,6 +24,8 @@ import { withInquirer } from './inquirer-guard.js';
|
|
|
24
24
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
25
25
|
import { ensureWorkspaceSync, listProjectFilesSync, isAttachableFilename } from '../projects/workspace.js';
|
|
26
26
|
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
27
|
+
/** Subfolders never worth listing in the `@` picker (generated or vendored). */
|
|
28
|
+
const SKIPPED_DIRS = new Set(['node_modules', 'cspeach-out', 'dist', 'build', 'target']);
|
|
27
29
|
/**
|
|
28
30
|
* 2026-06-06 (Ink picker parity): workspace listing + substring filter,
|
|
29
31
|
* exported so the Ink FilePalette shares the same data + seeding
|
|
@@ -57,16 +59,15 @@ export function listWorkspaceFileChoices(opts) {
|
|
|
57
59
|
catch {
|
|
58
60
|
// fall through — still return whatever the cwd half yields
|
|
59
61
|
}
|
|
60
|
-
// cwd half —
|
|
62
|
+
// cwd half — .txt/.md/.docx/.pdf at the top level AND one level of
|
|
63
|
+
// subfolders (2026-09-17: a spec in `docs\` never showed in the picker).
|
|
64
|
+
// Subfolder entries are listed as `docs/spec.docx`, which the resolver
|
|
65
|
+
// already treats as a cwd-relative path. Never throws.
|
|
61
66
|
const cwd = opts?.cwd ?? process.cwd();
|
|
62
67
|
try {
|
|
63
68
|
for (const name of readdirSync(cwd)) {
|
|
64
69
|
if (name.startsWith('.'))
|
|
65
70
|
continue; // dotfiles/dirs (incl. .cspeach)
|
|
66
|
-
if (!isAttachableFilename(name))
|
|
67
|
-
continue;
|
|
68
|
-
if (seen.has(name))
|
|
69
|
-
continue; // workspace already has it
|
|
70
71
|
const full = join(cwd, name);
|
|
71
72
|
let stat;
|
|
72
73
|
try {
|
|
@@ -75,6 +76,42 @@ export function listWorkspaceFileChoices(opts) {
|
|
|
75
76
|
catch {
|
|
76
77
|
continue;
|
|
77
78
|
}
|
|
79
|
+
if (stat.isDirectory()) {
|
|
80
|
+
if (SKIPPED_DIRS.has(name.toLowerCase()))
|
|
81
|
+
continue;
|
|
82
|
+
let inner;
|
|
83
|
+
try {
|
|
84
|
+
inner = readdirSync(full);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const child of inner) {
|
|
90
|
+
if (child.startsWith('.'))
|
|
91
|
+
continue;
|
|
92
|
+
if (!isAttachableFilename(child))
|
|
93
|
+
continue;
|
|
94
|
+
const rel = `${name}/${child}`;
|
|
95
|
+
if (seen.has(rel))
|
|
96
|
+
continue;
|
|
97
|
+
let cstat;
|
|
98
|
+
try {
|
|
99
|
+
cstat = statSync(join(full, child));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!cstat.isFile())
|
|
105
|
+
continue;
|
|
106
|
+
seen.add(rel);
|
|
107
|
+
out.push({ filename: rel, mtimeMs: cstat.mtimeMs });
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!isAttachableFilename(name))
|
|
112
|
+
continue;
|
|
113
|
+
if (seen.has(name))
|
|
114
|
+
continue; // workspace already has it
|
|
78
115
|
if (!stat.isFile())
|
|
79
116
|
continue;
|
|
80
117
|
seen.add(name);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-09-
|
|
3
|
+
// Generated: 2026-09-17T18:08:02.382Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|
|
@@ -34,47 +34,21 @@ export const STANDARDS_INTERVIEW_QUESTIONS = [
|
|
|
34
34
|
heading: 'Packages for new objects',
|
|
35
35
|
message: 'Package(s) new objects belong in, e.g. ZFI_CORE (Enter to skip):',
|
|
36
36
|
},
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
key: 'forbidden',
|
|
44
|
-
heading: 'Forbidden or discouraged statements',
|
|
45
|
-
message: 'Forbidden or discouraged statements, e.g. "no SELECT *", "no CALL TRANSACTION" (Enter to skip):',
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
key: 'atc_variant',
|
|
49
|
-
heading: 'ATC check variant',
|
|
50
|
-
message: 'ATC check variant name (Enter to skip):',
|
|
51
|
-
},
|
|
37
|
+
// 2026-09-17 (Laeeq): cut from ten questions to four. Naming style,
|
|
38
|
+
// forbidden statements, ATC variant, transport process, header template
|
|
39
|
+
// and unit-test expectation were prose for the model only — nothing read
|
|
40
|
+
// them — and modern ABAP defaults already cover them. Only what the
|
|
41
|
+
// customer alone knows, and that changes the generated code, is asked.
|
|
52
42
|
{
|
|
53
43
|
key: 'clean_core',
|
|
54
44
|
heading: 'Clean core',
|
|
55
45
|
message: 'Clean-core requirement:',
|
|
56
46
|
choices: ['ABAP Cloud only', 'released APIs preferred', 'classic allowed', '(skip)'],
|
|
57
47
|
},
|
|
58
|
-
{
|
|
59
|
-
key: 'transport_process',
|
|
60
|
-
heading: 'Transport / change process',
|
|
61
|
-
message: 'Transport / change process, e.g. "ChaRM, one TR per ticket" (Enter to skip):',
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
key: 'header_template',
|
|
65
|
-
heading: 'Code header template',
|
|
66
|
-
message: 'Required code header / comment template (Enter to skip):',
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
key: 'unit_tests',
|
|
70
|
-
heading: 'Unit tests',
|
|
71
|
-
message: 'Unit-test expectation:',
|
|
72
|
-
choices: ['required for all classes', 'business logic only', 'none', '(skip)'],
|
|
73
|
-
},
|
|
74
48
|
{
|
|
75
49
|
key: 'other',
|
|
76
50
|
heading: 'Anything else',
|
|
77
|
-
message: 'Anything else CSPeach should follow (Enter to skip):',
|
|
51
|
+
message: 'Anything else CSPeach should follow, one line (Enter to skip):',
|
|
78
52
|
},
|
|
79
53
|
];
|
|
80
54
|
/** Header written at the top of an IMPORTED standards document. */
|
|
@@ -121,6 +95,66 @@ export async function writeStandardsFile(cwd, content) {
|
|
|
121
95
|
return homePath;
|
|
122
96
|
}
|
|
123
97
|
}
|
|
98
|
+
/** Sentinel choice in the import dropdown: fall back to a typed path. */
|
|
99
|
+
export const TYPE_A_PATH = '__type_a_path__';
|
|
100
|
+
const STANDARDS_SOURCE_EXTS = ['.md', '.txt', '.docx', '.pdf'];
|
|
101
|
+
const SKIPPED_DIRS = new Set(['node_modules', 'cspeach-out', 'dist', 'build', 'target']);
|
|
102
|
+
function isStandardsSource(name) {
|
|
103
|
+
const lower = name.toLowerCase();
|
|
104
|
+
if (lower === 'cspeach-standards.md')
|
|
105
|
+
return false; // the file we are about to write
|
|
106
|
+
return STANDARDS_SOURCE_EXTS.some((e) => lower.endsWith(e));
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Files a team's standards document could plausibly be, relative to `cwd`:
|
|
110
|
+
* .md/.txt/.docx/.pdf at the top level and one level of subfolders
|
|
111
|
+
* (`docs/abap-guidelines.docx`), sorted by name. Never throws.
|
|
112
|
+
*/
|
|
113
|
+
export async function listStandardsCandidates(cwd) {
|
|
114
|
+
const out = [];
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = await fs.readdir(cwd, { withFileTypes: true });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
for (const e of entries) {
|
|
123
|
+
if (e.name.startsWith('.'))
|
|
124
|
+
continue;
|
|
125
|
+
if (e.isFile()) {
|
|
126
|
+
if (isStandardsSource(e.name))
|
|
127
|
+
out.push(e.name);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!e.isDirectory() || SKIPPED_DIRS.has(e.name.toLowerCase()))
|
|
131
|
+
continue;
|
|
132
|
+
let inner;
|
|
133
|
+
try {
|
|
134
|
+
inner = await fs.readdir(path.join(cwd, e.name), { withFileTypes: true });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const c of inner) {
|
|
140
|
+
if (c.name.startsWith('.') || !c.isFile() || !isStandardsSource(c.name))
|
|
141
|
+
continue;
|
|
142
|
+
out.push(`${e.name}/${c.name}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
146
|
+
}
|
|
147
|
+
/** Read a standards source as text: Word / PDF through document extraction, otherwise UTF-8. */
|
|
148
|
+
export async function readStandardsSource(src) {
|
|
149
|
+
const lower = src.toLowerCase();
|
|
150
|
+
if (lower.endsWith('.docx') || lower.endsWith('.pdf')) {
|
|
151
|
+
const { extractDocument } = await import('../tools/filesystem/extract-document.js');
|
|
152
|
+
const buf = await fs.readFile(src);
|
|
153
|
+
const res = await extractDocument(buf, lower.endsWith('.pdf') ? '.pdf' : '.docx', { maxPages: 150 });
|
|
154
|
+
return res.text;
|
|
155
|
+
}
|
|
156
|
+
return fs.readFile(src, 'utf8');
|
|
157
|
+
}
|
|
124
158
|
async function defaultPrompts() {
|
|
125
159
|
const { input, select } = await import('@inquirer/prompts');
|
|
126
160
|
return {
|
|
@@ -146,12 +180,34 @@ export async function runStandardsInit(cwd, prompts) {
|
|
|
146
180
|
default: 'defaults',
|
|
147
181
|
});
|
|
148
182
|
if (route === 'import') {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
183
|
+
// 2026-09-17 — offer the files we can see (cwd + one level of
|
|
184
|
+
// subfolders) as a dropdown before falling back to a typed path, and
|
|
185
|
+
// accept Word / PDF standards documents, not only .md/.txt.
|
|
186
|
+
const candidates = await listStandardsCandidates(cwd);
|
|
187
|
+
let src = '';
|
|
188
|
+
if (candidates.length > 0) {
|
|
189
|
+
const picked = await p.select({
|
|
190
|
+
key: 'source-pick',
|
|
191
|
+
message: 'Which file holds your standards?',
|
|
192
|
+
choices: [
|
|
193
|
+
...candidates.map((f) => ({ value: f, name: f })),
|
|
194
|
+
{ value: TYPE_A_PATH, name: 'Type a path…' },
|
|
195
|
+
],
|
|
196
|
+
default: candidates[0],
|
|
197
|
+
});
|
|
198
|
+
// Anything that is not one of the offered files (or the sentinel) falls
|
|
199
|
+
// through to the typed-path prompt — never treat a stray answer as a path.
|
|
200
|
+
if (picked !== TYPE_A_PATH && candidates.includes(picked))
|
|
201
|
+
src = path.resolve(cwd, picked);
|
|
202
|
+
}
|
|
203
|
+
if (src.length === 0) {
|
|
204
|
+
src = (await p.input({
|
|
205
|
+
key: 'source',
|
|
206
|
+
message: 'Path to the standards document (.md, .txt, .docx or .pdf):',
|
|
207
|
+
})).trim();
|
|
208
|
+
}
|
|
153
209
|
try {
|
|
154
|
-
const body = await
|
|
210
|
+
const body = await readStandardsSource(src);
|
|
155
211
|
const content = `${importedStandardsHeader(src)}\n\n${body.trim()}\n`;
|
|
156
212
|
const written = await writeStandardsFile(cwd, content);
|
|
157
213
|
console.log(chalk.green(`✓ Standards imported to ${displayStandardsPath(written, cwd)}`));
|