codeep 3.3.3 → 3.4.1

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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -6,6 +6,12 @@ export interface SkillStep {
6
6
  type: 'prompt' | 'command' | 'confirm' | 'notify' | 'agent';
7
7
  content: string;
8
8
  optional?: boolean;
9
+ /**
10
+ * A parameter that stands in for this step. When the user gives it, the
11
+ * step does not run and the parameter's value becomes the step's output —
12
+ * `${_prev}` for the steps after it.
13
+ */
14
+ skipIf?: string;
9
15
  }
10
16
  export interface SkillParameter {
11
17
  name: string;
@@ -41,10 +47,25 @@ export interface SkillChain {
41
47
  * Get all built-in skills
42
48
  */
43
49
  export declare function getBuiltInSkills(): Skill[];
50
+ /** A custom skill file that could not be used, and why. */
51
+ export interface SkippedSkillFile {
52
+ file: string;
53
+ problem: string;
54
+ }
44
55
  /**
45
56
  * Load custom skills from disk
46
57
  */
47
58
  export declare function loadCustomSkills(): Skill[];
59
+ /** Custom skill files the latest load could not use. */
60
+ export declare function getSkippedCustomSkills(): SkippedSkillFile[];
61
+ /** One line per skipped file, naming where it is and what is wrong with it. */
62
+ export declare function formatSkippedCustomSkills(skipped: SkippedSkillFile[]): string;
63
+ /**
64
+ * Whether ~/.codeep/skills/<name>.json exists, whether or not it loads. A
65
+ * file that fails to load is invisible to findSkill, and creating a skill of
66
+ * that name would write over what the user wrote by hand.
67
+ */
68
+ export declare function customSkillFileExists(name: string): boolean;
48
69
  /**
49
70
  * Get all skills (built-in + custom)
50
71
  */
@@ -99,14 +120,15 @@ export interface SkillExecutionCallbacks {
99
120
  }
100
121
  /**
101
122
  * Execute a skill's steps sequentially.
102
- * Each step's output is available as ${_prev} in the next step's content.
123
+ * The latest command, prompt or agent output is available as ${_prev} in later
124
+ * steps' content; confirm and notify steps leave it unchanged.
103
125
  * Returns the collected results from all steps.
104
126
  */
105
127
  export declare function executeSkill(skill: Skill, params: Record<string, string>, callbacks: SkillExecutionCallbacks): Promise<SkillExecutionResult>;
106
128
  /**
107
129
  * Format skills list for display
108
130
  */
109
- export declare function formatSkillsList(skills: Skill[]): string;
131
+ export declare function formatSkillsList(skills: Skill[], skipped?: SkippedSkillFile[]): string;
110
132
  /**
111
133
  * Format skill help
112
134
  */
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Skills System - predefined workflows and commands
3
3
  */
4
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
5
- import { join } from 'path';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
5
+ import { dirname, join, resolve } from 'path';
6
6
  import { homedir } from 'os';
7
+ import { logger } from './logger.js';
7
8
  // Skills directory
8
9
  const SKILLS_DIR = join(homedir(), '.codeep', 'skills');
9
10
  const SKILLS_HISTORY_FILE = join(homedir(), '.codeep', 'skill-history.json');
@@ -21,8 +22,8 @@ const BUILT_IN_SKILLS = [
21
22
  ],
22
23
  steps: [
23
24
  { type: 'command', content: 'git diff --cached --stat || git diff --stat' },
24
- { type: 'prompt', content: 'Based on this git diff, generate ONLY a conventional commit message (no explanation, no markdown). Format: type(scope): description. Types: feat, fix, docs, style, refactor, test, chore. Be concise. One line only.\n\n${_prev}' },
25
- { type: 'confirm', content: 'Commit with this message?' },
25
+ { type: 'prompt', content: 'Based on this git diff, generate ONLY a conventional commit message (no explanation, no markdown). Format: type(scope): description. Types: feat, fix, docs, style, refactor, test, chore. Be concise. One line only.\n\n${_prev}', skipIf: 'message' },
26
+ { type: 'confirm', content: 'Commit with this message? ${_prev}' },
26
27
  { type: 'command', content: 'git add -A && git commit -m "${_prev}"' },
27
28
  { type: 'notify', content: 'Changes committed successfully!' },
28
29
  ],
@@ -88,9 +89,9 @@ const BUILT_IN_SKILLS = [
88
89
  { name: 'description', description: 'What the branch is for', required: true },
89
90
  ],
90
91
  steps: [
91
- { type: 'prompt', content: 'Based on the description "${description}", suggest a branch name following convention: type/short-description. Types: feature, fix, hotfix, refactor, chore.' },
92
+ { type: 'prompt', content: 'Based on the description "${description}", suggest a branch name following convention: type/short-description. Types: feature, fix, hotfix, refactor, chore. Reply with ONLY the branch name (no explanation, no markdown).' },
92
93
  { type: 'confirm', content: 'Create this branch?' },
93
- { type: 'command', content: 'git checkout -b ${branch}' },
94
+ { type: 'command', content: 'git checkout -b "${_prev}"' },
94
95
  ],
95
96
  },
96
97
  {
@@ -99,8 +100,8 @@ const BUILT_IN_SKILLS = [
99
100
  category: 'git',
100
101
  requiresGit: true,
101
102
  steps: [
102
- { type: 'prompt', content: 'Analyze the current changes and suggest a meaningful stash message.' },
103
- { type: 'command', content: 'git stash push -m "${message}"' },
103
+ { type: 'prompt', content: 'Analyze the current changes and suggest a meaningful stash message. Reply with ONLY the message, one line (no explanation, no markdown).' },
104
+ { type: 'command', content: 'git stash push -m "${_prev}"' },
104
105
  { type: 'notify', content: 'Changes stashed!' },
105
106
  ],
106
107
  },
@@ -607,27 +608,104 @@ function ensureSkillsDir() {
607
608
  export function getBuiltInSkills() {
608
609
  return BUILT_IN_SKILLS;
609
610
  }
611
+ function isValidCustomSkill(value) {
612
+ if (!value || typeof value !== 'object' || Array.isArray(value))
613
+ return false;
614
+ const s = value;
615
+ return typeof s.name === 'string'
616
+ && typeof s.description === 'string'
617
+ && Array.isArray(s.steps)
618
+ && (s.shortcut === undefined || typeof s.shortcut === 'string');
619
+ }
620
+ // What the latest load passed over. Kept so the places a user looks — the
621
+ // "Unknown command" reply, /skills — can name the file; the log alone is
622
+ // somewhere nobody reads while wondering where their skill went.
623
+ let skippedSkillFiles = [];
624
+ // Every lookup reloads the directory; log each bad file once, not per lookup.
625
+ const loggedSkippedFiles = new Set();
610
626
  /**
611
627
  * Load custom skills from disk
612
628
  */
613
629
  export function loadCustomSkills() {
614
630
  ensureSkillsDir();
615
631
  const skills = [];
632
+ const skipped = [];
633
+ const skip = (file, problem) => {
634
+ skipped.push({ file, problem });
635
+ if (!loggedSkippedFiles.has(file)) {
636
+ loggedSkippedFiles.add(file);
637
+ logger.warn(`Skipping custom skill ${file}: ${problem}`);
638
+ }
639
+ };
616
640
  try {
617
641
  const files = readdirSync(SKILLS_DIR).filter(f => f.endsWith('.json'));
618
642
  for (const file of files) {
643
+ let content;
644
+ let skill;
645
+ try {
646
+ content = readFileSync(join(SKILLS_DIR, file), 'utf-8');
647
+ }
648
+ catch {
649
+ skip(file, 'could not be read');
650
+ continue;
651
+ }
619
652
  try {
620
- const content = readFileSync(join(SKILLS_DIR, file), 'utf-8');
621
- const skill = JSON.parse(content);
622
- skill.category = 'custom';
623
- skills.push(skill);
653
+ skill = JSON.parse(content);
624
654
  }
625
- catch { }
655
+ catch {
656
+ skip(file, 'not valid JSON');
657
+ continue;
658
+ }
659
+ // These files are edited by hand. One entry without a string name
660
+ // would make every lookup that reaches it throw, taking down all
661
+ // custom skills and the "Unknown command" reply with it.
662
+ if (!isValidCustomSkill(skill)) {
663
+ skip(file, 'needs a string "name" and "description" and a "steps" array');
664
+ continue;
665
+ }
666
+ skill.category = 'custom';
667
+ skills.push(skill);
626
668
  }
627
669
  }
628
670
  catch { }
671
+ skippedSkillFiles = skipped;
629
672
  return skills;
630
673
  }
674
+ /** Custom skill files the latest load could not use. */
675
+ export function getSkippedCustomSkills() {
676
+ return [...skippedSkillFiles];
677
+ }
678
+ /** One line per skipped file, naming where it is and what is wrong with it. */
679
+ export function formatSkippedCustomSkills(skipped) {
680
+ return skipped
681
+ .map(s => `Skipped ~/.codeep/skills/${s.file} — ${s.problem}. Fix or delete the file.`)
682
+ .join('\n');
683
+ }
684
+ /**
685
+ * Whether ~/.codeep/skills/<name>.json exists, whether or not it loads. A
686
+ * file that fails to load is invisible to findSkill, and creating a skill of
687
+ * that name would write over what the user wrote by hand.
688
+ */
689
+ export function customSkillFileExists(name) {
690
+ const filepath = customSkillFile(name);
691
+ return filepath !== null && existsSync(filepath);
692
+ }
693
+ /**
694
+ * ~/.codeep/skills/<name>.json, or null when the name would put the file
695
+ * anywhere else. Names come straight from `/skill create|delete <name>`, and
696
+ * `../mcp_servers` would otherwise reach ~/.codeep/mcp_servers.json.
697
+ */
698
+ function customSkillFile(name) {
699
+ if (typeof name !== 'string' || !name.trim())
700
+ return null;
701
+ if (/[\\/]/.test(name))
702
+ return null;
703
+ // eslint-disable-next-line no-control-regex
704
+ if (/[\x00-\x1f]/.test(name))
705
+ return null;
706
+ const filepath = join(SKILLS_DIR, `${name}.json`);
707
+ return dirname(resolve(filepath)) === resolve(SKILLS_DIR) ? filepath : null;
708
+ }
631
709
  /**
632
710
  * Get all skills (built-in + custom)
633
711
  */
@@ -672,19 +750,29 @@ export function parseSkillChain(input) {
672
750
  export function parseSkillArgs(args, skill) {
673
751
  const result = {};
674
752
  // Pattern to match key=value or key="value with spaces" or just "value"
675
- const keyValuePattern = /(\w+)=(?:"([^"]+)"|'([^']+)'|(\S+))/g;
753
+ const keyValuePattern = /(?<!\S)(\w+)=(?:"([^"]+)"|'([^']+)'|(\S+))/g;
676
754
  const quotedPattern = /^["'](.+)["']$/;
755
+ // A skill that declares parameters takes only those as key=value; any other
756
+ // `word=value` is part of the text for its first parameter — "fix: set
757
+ // retries=3 by default" is a commit message, not a `retries` parameter.
758
+ const declared = skill.parameters && skill.parameters.length > 0
759
+ ? new Set(skill.parameters.map(p => p.name))
760
+ : null;
677
761
  // First, try to parse key=value pairs
678
762
  let match;
679
763
  let remainingArgs = args;
680
764
  while ((match = keyValuePattern.exec(args)) !== null) {
681
765
  const key = match[1];
766
+ if (declared && !declared.has(key))
767
+ continue;
682
768
  const value = match[2] || match[3] || match[4];
683
769
  result[key] = value;
684
770
  remainingArgs = remainingArgs.replace(match[0], '').trim();
685
771
  }
686
- // If there's remaining text and skill has parameters, use as first param
687
- if (remainingArgs.trim() && skill.parameters && skill.parameters.length > 0) {
772
+ // If there's remaining text and skill has parameters, use as first param —
773
+ // unless that one was given as key=value, which is the more explicit of the two
774
+ if (remainingArgs.trim() && skill.parameters && skill.parameters.length > 0
775
+ && result[skill.parameters[0].name] === undefined) {
688
776
  const firstParam = skill.parameters[0];
689
777
  // Check if it's quoted
690
778
  const quotedMatch = remainingArgs.match(quotedPattern);
@@ -705,22 +793,53 @@ export function parseSkillArgs(args, skill) {
705
793
  }
706
794
  return result;
707
795
  }
796
+ // Only the first line is used, and a commit message, branch name or stash
797
+ // message never needs more than this. The cap also bounds the regexes below:
798
+ // an unclosed fence or a run of unclosed `$(` in a long reply made them
799
+ // quadratic.
800
+ const SHELL_TEXT_MAX = 8192;
708
801
  /**
709
- * Sanitize text for safe use inside shell commands.
710
- * Strips markdown formatting and removes shell metacharacters to prevent
711
- * command injection via $(), backtick subshells, semicolons, pipes, etc.
802
+ * Sanitize model output for use inside a double-quoted shell string —
803
+ * `"${_prev}"`. Takes the first line of the reply (markdown removed), drops
804
+ * shell syntax and escapes it for that position.
805
+ *
806
+ * Only the double quotes make it safe. Unquoted, the result can still carry
807
+ * spaces, globs, redirects and (outside Windows) `&`.
712
808
  */
713
- function sanitizeForShell(text) {
714
- const firstLine = text
715
- // Strip markdown code blocks
716
- .replace(/```[\s\S]*?```/g, '')
809
+ function sanitizeForShell(text, platform = process.platform) {
810
+ return quoteForDoubleQuotes(modelTextForShell(text, platform), platform);
811
+ }
812
+ /** The part of a model reply that `"${_prev}"` hands to a command, unescaped. */
813
+ function modelTextForShell(text, platform) {
814
+ const capped = text.slice(0, SHELL_TEXT_MAX);
815
+ const firstLine = capped
816
+ // Unwrap fenced code blocks, keeping their content — models often fence a
817
+ // one-line answer even when told not to, and dropping the block would
818
+ // leave nothing but the raw fallback below
819
+ .replace(/```[^\n]{0,100}\n?([\s\S]*?)```/g, '$1')
717
820
  // Strip inline backtick code spans (remove content too, not just markers)
718
821
  .replace(/`[^`]*`/g, '')
719
822
  // Strip bold/italic markers
720
823
  .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
721
824
  // Take only the first non-empty line
722
- .split('\n').map(l => l.trim()).filter(Boolean)[0] || text.trim();
723
- return firstLine
825
+ .split('\n').map(l => l.trim()).filter(Boolean)[0] || capped.trim();
826
+ // The escaping must hold on its own: the fallback above hands back the raw
827
+ // text, so nothing can rely on the markdown stripping having happened.
828
+ return plainTextForShell(stripShellSyntax(firstLine, platform), platform);
829
+ }
830
+ /**
831
+ * Remove shell syntax from model output. On POSIX this is not what makes it
832
+ * safe — the escaping does that — but kept for templates that leave
833
+ * `${_prev}` unquoted, which it only partly protects.
834
+ *
835
+ * On Windows it is what makes it safe. `shell: true` runs cmd.exe there,
836
+ * which takes no backslash escapes: every `"` opens or closes quoting, so a
837
+ * reply could close the string and chain a command. Model output loses the
838
+ * characters cmd.exe acts on outright.
839
+ */
840
+ function stripShellSyntax(text, platform) {
841
+ const stripped = text
842
+ .slice(0, SHELL_TEXT_MAX)
724
843
  // Remove $(...) subshell expansion
725
844
  .replace(/\$\([^)]*\)/g, '')
726
845
  // Remove ${...} variable/subshell expansion
@@ -729,11 +848,39 @@ function sanitizeForShell(text) {
729
848
  .replace(/\$\w+/g, '')
730
849
  // Remove command chaining operators
731
850
  .replace(/[;|]/g, '')
732
- // Remove newlines and null bytes
733
- .replace(/[\n\r\0]/g, ' ')
734
- // Escape double quotes for safe embedding in "..." shell strings
735
- .replace(/"/g, '\\"')
736
- .trim();
851
+ // Backticks run a command even inside "..." shell strings
852
+ .replace(/`/g, '');
853
+ return platform === 'win32' ? stripped.replace(/["&|<>^%]/g, '') : stripped;
854
+ }
855
+ /**
856
+ * The text a double-quoted `"${_prev}"` passes on: one line, and on Windows
857
+ * without `"` and `%`. cmd.exe cannot keep those inside quotes — a `"` ends
858
+ * them and `%NAME%` expands even there. With no `"` left to end the quotes,
859
+ * the rest (`&`, `|`, `<`, `>`, `^`) is plain text to cmd.exe.
860
+ */
861
+ function plainTextForShell(text, platform) {
862
+ const line = text.slice(0, SHELL_TEXT_MAX).replace(/[\n\r\0]/g, ' ');
863
+ return (platform === 'win32' ? line.replace(/["%]/g, '') : line).trim();
864
+ }
865
+ /** Escape text from plainTextForShell for the inside of a double-quoted shell string. */
866
+ function quoteForDoubleQuotes(text, platform) {
867
+ if (platform === 'win32') {
868
+ // cmd.exe passes backslashes on as they are, and the program's own
869
+ // argument parsing reads backslashes right before the closing quote as
870
+ // escaping it. Doubled, they stay backslashes and the quote closes.
871
+ let end = text.length;
872
+ while (end > 0 && text[end - 1] === '\\')
873
+ end--;
874
+ return text + text.slice(end);
875
+ }
876
+ // Escape what is still special inside "..." for sh. A backslash left alone
877
+ // would escape the backslash added before a quote, and that quote would
878
+ // then close the string early.
879
+ return text.replace(/[\\"$`]/g, '\\$&');
880
+ }
881
+ /** Escape text so a double-quoted shell string holds it literally. */
882
+ function escapeForDoubleQuotes(text, platform = process.platform) {
883
+ return quoteForDoubleQuotes(plainTextForShell(text, platform), platform);
737
884
  }
738
885
  /**
739
886
  * Interpolate parameters into skill step content
@@ -753,18 +900,23 @@ export function interpolateParams(content, params) {
753
900
  * Save a custom skill
754
901
  */
755
902
  export function saveCustomSkill(skill) {
903
+ const filepath = customSkillFile(skill.name);
904
+ if (!filepath) {
905
+ throw new Error(`Skill name "${skill.name}" is not allowed: it cannot be empty or contain "/", "\\" or control characters.`);
906
+ }
756
907
  ensureSkillsDir();
757
908
  skill.category = 'custom';
758
- const filename = `${skill.name}.json`;
759
- writeFileSync(join(SKILLS_DIR, filename), JSON.stringify(skill, null, 2));
909
+ writeFileSync(filepath, JSON.stringify(skill, null, 2));
760
910
  }
761
911
  /**
762
912
  * Delete a custom skill
763
913
  */
764
914
  export function deleteCustomSkill(name) {
765
- const filepath = join(SKILLS_DIR, `${name}.json`);
915
+ const filepath = customSkillFile(name);
916
+ if (!filepath)
917
+ return false;
766
918
  if (existsSync(filepath)) {
767
- require('fs').unlinkSync(filepath);
919
+ unlinkSync(filepath);
768
920
  return true;
769
921
  }
770
922
  return false;
@@ -802,16 +954,43 @@ export function getExecutableSteps(skill) {
802
954
  }
803
955
  /**
804
956
  * Execute a skill's steps sequentially.
805
- * Each step's output is available as ${_prev} in the next step's content.
957
+ * The latest command, prompt or agent output is available as ${_prev} in later
958
+ * steps' content; confirm and notify steps leave it unchanged.
806
959
  * Returns the collected results from all steps.
807
960
  */
808
961
  export async function executeSkill(skill, params, callbacks) {
809
962
  const stepResults = [];
810
963
  let lastOutput = '';
811
- for (const step of skill.steps) {
964
+ // Whether lastOutput is text the user typed rather than a model reply.
965
+ let lastOutputIsUsers = false;
966
+ for (let i = 0; i < skill.steps.length; i++) {
967
+ const step = skill.steps[i];
968
+ // A parameter the user gave replaces the step outright — `/commit "msg"`
969
+ // commits "msg" instead of asking the model for a message.
970
+ const standIn = step.skipIf ? params[step.skipIf] : undefined;
971
+ if (standIn) {
972
+ if (step.type === 'command' || step.type === 'prompt' || step.type === 'agent') {
973
+ lastOutput = standIn;
974
+ lastOutputIsUsers = true;
975
+ }
976
+ stepResults.push({ step, result: standIn, success: true });
977
+ continue;
978
+ }
812
979
  // Interpolate params and ${_prev} into step content
813
- // For command steps, sanitize _prev for safe shell usage
814
- const sanitizedPrev = step.type === 'command' ? sanitizeForShell(lastOutput) : lastOutput;
980
+ // For command steps, sanitize _prev for safe shell usage. The user's own
981
+ // text is only escaped: it is not markdown to strip, and a `$5` or a `;`
982
+ // in a commit message is part of the message.
983
+ // A confirm step right before a command that uses ${_prev} shows the text
984
+ // that command will be given, so what the user approves is what runs.
985
+ const platform = process.platform;
986
+ const next = skill.steps[i + 1];
987
+ const approvesCommand = step.type === 'confirm'
988
+ && next?.type === 'command' && next.content.includes('${_prev}');
989
+ const sanitizedPrev = step.type === 'command'
990
+ ? (lastOutputIsUsers ? escapeForDoubleQuotes(lastOutput, platform) : sanitizeForShell(lastOutput, platform))
991
+ : approvesCommand
992
+ ? (lastOutputIsUsers ? plainTextForShell(lastOutput, platform) : modelTextForShell(lastOutput, platform))
993
+ : lastOutput;
815
994
  const allParams = { ...params, _prev: sanitizedPrev };
816
995
  const content = interpolateParams(step.content, allParams);
817
996
  try {
@@ -840,7 +1019,14 @@ export async function executeSkill(skill, params, callbacks) {
840
1019
  result = 'notified';
841
1020
  break;
842
1021
  }
843
- lastOutput = result;
1022
+ // Only steps that produce something feed ${_prev}. A confirm or notify
1023
+ // in between must pass the earlier output through — otherwise
1024
+ // "generate message → confirm → git commit -m ${_prev}" commits the
1025
+ // word "confirmed" instead of the message the user just approved.
1026
+ if (step.type === 'command' || step.type === 'prompt' || step.type === 'agent') {
1027
+ lastOutput = result;
1028
+ lastOutputIsUsers = false;
1029
+ }
844
1030
  stepResults.push({ step, result, success: true });
845
1031
  }
846
1032
  catch (err) {
@@ -856,7 +1042,7 @@ export async function executeSkill(skill, params, callbacks) {
856
1042
  /**
857
1043
  * Format skills list for display
858
1044
  */
859
- export function formatSkillsList(skills) {
1045
+ export function formatSkillsList(skills, skipped = getSkippedCustomSkills()) {
860
1046
  const byCategory = new Map();
861
1047
  for (const skill of skills) {
862
1048
  const existing = byCategory.get(skill.category) || [];
@@ -893,6 +1079,11 @@ export function formatSkillsList(skills) {
893
1079
  lines.push('## Skill Chaining');
894
1080
  lines.push('Chain multiple skills with `+`: `/commit+push`, `/test+commit+push`');
895
1081
  lines.push('');
1082
+ if (skipped.length > 0) {
1083
+ lines.push('## Not loaded');
1084
+ lines.push(formatSkippedCustomSkills(skipped));
1085
+ lines.push('');
1086
+ }
896
1087
  return lines.join('\n');
897
1088
  }
898
1089
  /**
@@ -926,7 +1117,8 @@ export function formatSkillHelp(skill) {
926
1117
  lines.push('## Steps:');
927
1118
  for (let i = 0; i < skill.steps.length; i++) {
928
1119
  const step = skill.steps[i];
929
- const optional = step.optional ? ' (optional)' : '';
1120
+ const optional = (step.optional ? ' (optional)' : '')
1121
+ + (step.skipIf ? ` (skipped when ${step.skipIf} is given)` : '');
930
1122
  switch (step.type) {
931
1123
  case 'prompt':
932
1124
  lines.push(`${i + 1}. AI Analysis${optional}`);
@@ -979,7 +1171,7 @@ export const WIZARD_STEPS = [
979
1171
  if (!input.match(/^[a-z][a-z0-9-]*$/)) {
980
1172
  return 'Name must be lowercase letters, numbers, and hyphens. Must start with a letter.';
981
1173
  }
982
- if (findSkill(input)) {
1174
+ if (findSkill(input) || customSkillFileExists(input)) {
983
1175
  return 'A skill with this name already exists.';
984
1176
  }
985
1177
  return null;
@@ -1213,7 +1405,7 @@ export function getSkillStats() {
1213
1405
  export function clearSkillHistory() {
1214
1406
  try {
1215
1407
  if (existsSync(SKILLS_HISTORY_FILE)) {
1216
- require('fs').unlinkSync(SKILLS_HISTORY_FILE);
1408
+ unlinkSync(SKILLS_HISTORY_FILE);
1217
1409
  }
1218
1410
  }
1219
1411
  catch {
@@ -1,13 +1,77 @@
1
1
  /**
2
2
  * Smart Context - automatically gather relevant files for better understanding
3
3
  */
4
- import { existsSync, readFileSync, statSync } from 'fs';
4
+ import { existsSync, statSync, openSync, readSync, closeSync } from 'fs';
5
5
  import { join, dirname, basename, extname, relative } from 'path';
6
+ import { StringDecoder } from 'string_decoder';
6
7
  import { loadIgnoreRules, isIgnored } from './gitignore.js';
8
+ import { isRefusedMention, isSensitiveFile, looksLikeKeyMaterial, resolvesWithin } from './mentions.js';
7
9
  import { logger } from './logger.js';
8
10
  // Max context size (characters)
9
11
  const MAX_CONTEXT_SIZE = 50000;
10
12
  const MAX_FILES = 15;
13
+ /**
14
+ * Read at most `maxBytes` of a file for the context block, or null when it
15
+ * isn't a regular text file or may hold secrets.
16
+ *
17
+ * statSync follows symlinks, so a repo that commits `tsconfig.json ->
18
+ * /dev/zero` (or a FIFO) is refused here: readFileSync on either never
19
+ * returns, and every agent run would freeze while memory climbs. Reading only
20
+ * the budget keeps a multi-MB log out of memory and out of the system prompt.
21
+ * The budget is in characters and a UTF-8 character is at least one byte, so
22
+ * the cut never overshoots it.
23
+ *
24
+ * Nothing here was named by the user as an attachment, and the result goes
25
+ * into the system prompt unseen. So a symlink is judged by what it points at
26
+ * (`.env.example -> .env`), it must stay inside the project (`tsconfig.json
27
+ * -> ~/.zsh_history`), and key material is refused whatever the file is called.
28
+ */
29
+ function readContextFile(filePath, maxBytes, projectRoot) {
30
+ let fd;
31
+ try {
32
+ const stat = statSync(filePath);
33
+ if (!stat.isFile())
34
+ return null;
35
+ if (isSensitiveFile(filePath) || !resolvesWithin(filePath, projectRoot))
36
+ return null;
37
+ fd = openSync(filePath, 'r');
38
+ const buf = Buffer.alloc(Math.min(stat.size, Math.max(0, maxBytes)));
39
+ let read = 0;
40
+ while (read < buf.length) {
41
+ const n = readSync(fd, buf, read, buf.length - read, read);
42
+ if (n === 0)
43
+ break;
44
+ read += n;
45
+ }
46
+ const data = buf.subarray(0, read);
47
+ // Same binary sniff as @-mentions: a NUL byte in the first 8 KB.
48
+ if (data.subarray(0, 8192).includes(0))
49
+ return null;
50
+ // StringDecoder holds back a multi-byte character split by the cut
51
+ // instead of emitting a replacement character.
52
+ const content = new StringDecoder('utf8').write(data);
53
+ if (looksLikeKeyMaterial(content))
54
+ return null;
55
+ return {
56
+ content,
57
+ size: stat.size,
58
+ truncated: stat.size > read,
59
+ };
60
+ }
61
+ catch (err) {
62
+ logger.debug('smartContext: failed to read context file', { path: filePath, err: String(err) });
63
+ return null;
64
+ }
65
+ finally {
66
+ if (fd !== undefined) {
67
+ try {
68
+ closeSync(fd);
69
+ }
70
+ catch { /* already closed */ }
71
+ }
72
+ }
73
+ }
74
+ const TRUNCATED_MARKER = '\n... (truncated)';
11
75
  // File extensions we care about
12
76
  /**
13
77
  * Extract imports/requires from file content
@@ -280,14 +344,22 @@ export function gatherSmartContext(targetFile, projectContext, taskDescription)
280
344
  const projectRoot = projectContext.root || process.cwd();
281
345
  const ignoreRules = loadIgnoreRules(projectRoot);
282
346
  const allRelated = new Map();
347
+ let targetTruncated = false;
283
348
  // If we have a target file, analyze it
284
349
  if (targetFile) {
285
350
  const targetPath = join(projectRoot, targetFile);
286
- if (existsSync(targetPath)) {
351
+ // A secrets file is never pulled in implicitly, not even read for its
352
+ // imports. The target is any path-like word in the prompt, including one
353
+ // left behind after the @-mention guard refused it, so `.env.local` would
354
+ // otherwise reach the provider without the user ever seeing it.
355
+ const target = existsSync(targetPath) && !isRefusedMention(targetPath)
356
+ ? readContextFile(targetPath, MAX_CONTEXT_SIZE, projectRoot)
357
+ : null;
358
+ if (target) {
287
359
  try {
288
- const content = readFileSync(targetPath, 'utf-8');
360
+ const content = target.truncated ? target.content + TRUNCATED_MARKER : target.content;
289
361
  const ext = extname(targetPath);
290
- const stat = statSync(targetPath);
362
+ targetTruncated = target.truncated;
291
363
  // Add the target file itself
292
364
  allRelated.set(targetPath, {
293
365
  path: targetPath,
@@ -295,13 +367,13 @@ export function gatherSmartContext(targetFile, projectContext, taskDescription)
295
367
  reason: 'target file',
296
368
  priority: 10,
297
369
  content,
298
- size: stat.size,
370
+ size: target.size,
299
371
  });
300
372
  // Extract and resolve imports
301
373
  const imports = extractImports(content, ext);
302
374
  for (const imp of imports) {
303
375
  const resolved = resolveImportPath(imp, targetPath, projectRoot);
304
- if (resolved && !allRelated.has(resolved)) {
376
+ if (resolved && !allRelated.has(resolved) && !isSensitiveFile(resolved)) {
305
377
  try {
306
378
  const impStat = statSync(resolved);
307
379
  allRelated.set(resolved, {
@@ -365,31 +437,33 @@ export function gatherSmartContext(targetFile, projectContext, taskDescription)
365
437
  .slice(0, MAX_FILES);
366
438
  // Load content for files that don't have it
367
439
  let totalSize = 0;
368
- let truncated = false;
440
+ let truncated = targetTruncated;
441
+ const unreadable = new Set();
369
442
  for (const file of files) {
370
443
  if (!file.content && totalSize < MAX_CONTEXT_SIZE) {
371
- try {
372
- const content = readFileSync(file.path, 'utf-8');
373
- if (totalSize + content.length <= MAX_CONTEXT_SIZE) {
374
- file.content = content;
375
- totalSize += content.length;
376
- }
377
- else {
378
- // Truncate this file
379
- const remaining = MAX_CONTEXT_SIZE - totalSize;
380
- file.content = content.slice(0, remaining) + '\n... (truncated)';
381
- totalSize = MAX_CONTEXT_SIZE;
382
- truncated = true;
383
- }
444
+ const read = readContextFile(file.path, MAX_CONTEXT_SIZE - totalSize, projectRoot);
445
+ if (!read) {
446
+ unreadable.add(file);
384
447
  }
385
- catch (err) {
386
- logger.debug('smartContext: readFileSync failed for context file', { path: file.path, err: String(err) });
448
+ else if (!read.truncated) {
449
+ file.content = read.content;
450
+ totalSize += read.content.length;
451
+ }
452
+ else {
453
+ // Truncate this file
454
+ file.content = read.content + TRUNCATED_MARKER;
455
+ totalSize = MAX_CONTEXT_SIZE;
456
+ truncated = true;
387
457
  }
388
458
  }
389
459
  else if (file.content) {
390
460
  totalSize += file.content.length;
391
461
  }
392
462
  }
463
+ // Devices, FIFOs and binaries were never readable context
464
+ if (unreadable.size > 0) {
465
+ files = files.filter(f => !unreadable.has(f));
466
+ }
393
467
  // Remove files without content if we're at limit
394
468
  if (truncated) {
395
469
  files = files.filter(f => f.content);
@@ -411,7 +485,7 @@ function extractMentionedFiles(task, projectRoot) {
411
485
  for (const match of matches) {
412
486
  const filePath = match[1];
413
487
  const fullPath = join(projectRoot, filePath);
414
- if (existsSync(fullPath)) {
488
+ if (existsSync(fullPath) && !isRefusedMention(fullPath)) {
415
489
  try {
416
490
  const stat = statSync(fullPath);
417
491
  if (stat.isFile()) {