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.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/skills.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
*/
|
package/dist/utils/skills.js
CHANGED
|
@@ -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 ${
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
710
|
-
*
|
|
711
|
-
*
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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] ||
|
|
723
|
-
|
|
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
|
-
//
|
|
733
|
-
.replace(
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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 =
|
|
915
|
+
const filepath = customSkillFile(name);
|
|
916
|
+
if (!filepath)
|
|
917
|
+
return false;
|
|
766
918
|
if (existsSync(filepath)) {
|
|
767
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
360
|
+
const content = target.truncated ? target.content + TRUNCATED_MARKER : target.content;
|
|
289
361
|
const ext = extname(targetPath);
|
|
290
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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()) {
|