@cspeach/cli 1.1.9 → 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
- const token = m[1] ?? m[2] ?? '';
559
+ let token = m[1] ?? m[2] ?? '';
560
+ let match = m[0];
549
561
  if (token.length === 0)
550
562
  continue;
551
- matches.push({ match: m[0], token, index: m.index });
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;
@@ -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 — top-level .txt/.md/.docx/.pdf only. Never throws.
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-17T16:36:01.136Z
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
- key: 'naming',
39
- heading: 'Naming style',
40
- message: 'Naming style for classes / methods / variables — e.g. "zcl_<area>_<noun>", Hungarian lv_/ls_/lt_ (Enter to skip):',
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
- const src = (await p.input({
150
- key: 'source',
151
- message: 'Path to the standards document (.md or .txt):',
152
- })).trim();
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 fs.readFile(src, 'utf8');
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)}`));
package/dist/ui/footer.js CHANGED
@@ -27,6 +27,8 @@ function appendHistory(line, history) {
27
27
  const PEACH = '#FFCBA4';
28
28
  /** The input prompt — its width feeds the display budget below. */
29
29
  const PROMPT = 'CSPeach › ';
30
+ /** Held multi-line draft: how many of the newest lines are shown above the input. */
31
+ const HELD_LINES_SHOWN = 3;
30
32
  export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange }) {
31
33
  const [value, setValue] = useState('');
32
34
  // Wave1 live-fix (Bug 1, statusline duplication) — the input row must
@@ -203,5 +205,5 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
203
205
  if (disabled) {
204
206
  return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
205
207
  }
206
- return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length, " line", buffer.length === 1 ? '' : 's', " pending (Enter sends, \\ continues)"] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
208
+ return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
207
209
  }
@@ -181,11 +181,22 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
181
181
  nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
182
182
  }
183
183
  else {
184
+ // 2026-09-17 — a single-line field (question form, question modal:
185
+ // no onPasteLines) receiving a multi-line paste. Windows Terminal
186
+ // delivers pasted newlines as bare \r in raw mode; left in the value
187
+ // they render as carriage returns (each line drawn over the last, so
188
+ // only the tail of the final line is visible) and reach the model as
189
+ // stray control characters. Join the lines with one space instead:
190
+ // the whole paste is visible and the answer is clean. Fields that do
191
+ // handle lines (the main prompt) never take this branch.
192
+ const insert = (input.includes('\n') || input.includes('\r'))
193
+ ? input.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+/g, ' ')
194
+ : input;
184
195
  nextValue =
185
196
  currentValue.slice(0, cursorRef.current) +
186
- input +
197
+ insert +
187
198
  currentValue.slice(cursorRef.current, currentValue.length);
188
- nextCursorOffset += input.length;
199
+ nextCursorOffset += insert.length;
189
200
  }
190
201
  if (nextCursorOffset < 0)
191
202
  nextCursorOffset = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.9",
3
+ "version": "1.1.11",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",