@miphamai/cli 0.33.0 → 0.33.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/package.json +1 -1
- package/src/core/engine.ts +3 -0
- package/src/shared/types.ts +2 -0
- package/src/skills/fork-executor.ts +6 -2
- package/src/skills/loader.ts +37 -4
- package/src/skills/sanitizer.ts +214 -0
- package/src/tools/agent/skill.ts +6 -1
- package/src/tools/file/edit.ts +4 -0
- package/src/tools/file/read.ts +3 -0
- package/src/tools/file/write.ts +20 -3
package/package.json
CHANGED
package/src/core/engine.ts
CHANGED
|
@@ -55,6 +55,8 @@ export class QueryEngine {
|
|
|
55
55
|
private _preflightChecker?: PreFlightChecker
|
|
56
56
|
private _autoCorrector?: AutoCorrector
|
|
57
57
|
private _metaRuleEngine?: MetaRuleEngine
|
|
58
|
+
/** Files read this session — tracks what the Read tool has loaded */
|
|
59
|
+
private readFiles = new Set<string>()
|
|
58
60
|
private goal?: string
|
|
59
61
|
private maxGoalLoops = 20
|
|
60
62
|
private lastAssistantContent?: string
|
|
@@ -943,6 +945,7 @@ export class QueryEngine {
|
|
|
943
945
|
backgroundAgentRegistry: getBackgroundAgentRegistry(),
|
|
944
946
|
permissionSystem: this.permission,
|
|
945
947
|
ruleEngine: this.ruleEngine,
|
|
948
|
+
readFiles: this.readFiles,
|
|
946
949
|
})
|
|
947
950
|
|
|
948
951
|
// Track touched files for rules matching
|
package/src/shared/types.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface ToolContext {
|
|
|
73
73
|
backgroundAgentRegistry?: import('../agent/background-registry').BackgroundAgentRegistry
|
|
74
74
|
permissionSystem?: import('../core/permission').PermissionSystem
|
|
75
75
|
ruleEngine?: import('../core/rule-engine').ExperienceRuleEngine
|
|
76
|
+
/** Files read this session — used by Write tool to check read-before-write */
|
|
77
|
+
readFiles?: Set<string>
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
// ── Artifact Types ──
|
|
@@ -3,6 +3,7 @@ import type { ProviderRegistry } from '../providers/registry'
|
|
|
3
3
|
import type { ToolDefinition, SkillDefinition } from '../shared/index.ts'
|
|
4
4
|
import type { AgentDefinition } from '../agent/types'
|
|
5
5
|
import type { PermissionSystem } from '../core/permission'
|
|
6
|
+
import { sanitizeSkillDescription } from './sanitizer.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Execute a skill in an isolated subagent context (context: fork).
|
|
@@ -10,6 +11,8 @@ import type { PermissionSystem } from '../core/permission'
|
|
|
10
11
|
* The skill's markdown body becomes the subagent's system prompt.
|
|
11
12
|
* The skill's allowed-tools become the subagent's tool whitelist.
|
|
12
13
|
* Results are returned to the AI as internal context (not shown directly to user).
|
|
14
|
+
*
|
|
15
|
+
* Safety: skill description is sanitized before injection into subagent prompt.
|
|
13
16
|
*/
|
|
14
17
|
export async function executeForkedSkill(
|
|
15
18
|
skill: SkillDefinition,
|
|
@@ -20,7 +23,7 @@ export async function executeForkedSkill(
|
|
|
20
23
|
): Promise<string> {
|
|
21
24
|
const agentDef: AgentDefinition = {
|
|
22
25
|
name: `skill:${skill.name}`,
|
|
23
|
-
description: skill.description,
|
|
26
|
+
description: sanitizeSkillDescription(skill.description, skill.type),
|
|
24
27
|
systemPrompt: buildSkillSystemPrompt(skill),
|
|
25
28
|
tools: skill.allowedTools?.join(', '),
|
|
26
29
|
model: skill.model || 'inherit',
|
|
@@ -38,5 +41,6 @@ export async function executeForkedSkill(
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function buildSkillSystemPrompt(skill: SkillDefinition): string {
|
|
41
|
-
|
|
44
|
+
const safeDescription = sanitizeSkillDescription(skill.description, skill.type)
|
|
45
|
+
return `You are executing the "${skill.name}" skill (v${skill.version}).\n\n${safeDescription}\n\nFollow the skill instructions precisely and return results.`
|
|
42
46
|
}
|
package/src/skills/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { parse as parseYaml } from 'yaml'
|
|
5
5
|
import type { SkillDefinition } from '../shared/index.ts'
|
|
6
|
+
import { sanitizeSkillDescription, sanitizeSkillBody, checkSkillShadow } from './sanitizer.js'
|
|
6
7
|
|
|
7
8
|
interface FrontmatterResult {
|
|
8
9
|
data: Record<string, unknown>
|
|
@@ -100,12 +101,43 @@ export class SkillsLoader {
|
|
|
100
101
|
const raw = readFileSync(path, 'utf-8')
|
|
101
102
|
const { data, content } = parseFrontmatter(raw)
|
|
102
103
|
|
|
104
|
+
const rawDescription = (data.description as string) || ''
|
|
105
|
+
const rawBody = content.trim() || undefined
|
|
106
|
+
const skillName = (data.name as string) || this.nameFromPath(path)
|
|
107
|
+
|
|
108
|
+
// ── Safety: check for command/MCP shadowing ──
|
|
109
|
+
const shadowCheck = checkSkillShadow(skillName, rawDescription)
|
|
110
|
+
if (shadowCheck.shadowed) {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`⚠️ Skill "${skillName}" shadows ${shadowCheck.conflictType} "${shadowCheck.conflictsWith}" — skipped for safety. Rename the skill and reload.\n`,
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Safety: sanitize description ──
|
|
118
|
+
const description = sanitizeSkillDescription(
|
|
119
|
+
rawDescription,
|
|
120
|
+
type === 'mipham' ? undefined : type,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
// ── Safety: sanitize body ──
|
|
124
|
+
let body: string | undefined
|
|
125
|
+
if (rawBody) {
|
|
126
|
+
const bodyResult = sanitizeSkillBody(rawBody)
|
|
127
|
+
body = bodyResult.text
|
|
128
|
+
if (bodyResult.warnings.length > 0) {
|
|
129
|
+
process.stderr.write(
|
|
130
|
+
`⚠️ Skill "${skillName}" body sanitized: ${bodyResult.warnings.join('; ')}\n`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
const skill: SkillDefinition = {
|
|
104
|
-
name:
|
|
105
|
-
description
|
|
136
|
+
name: skillName,
|
|
137
|
+
description,
|
|
106
138
|
version: (data.version as string) || '0.1.0',
|
|
107
139
|
type,
|
|
108
|
-
body
|
|
140
|
+
body,
|
|
109
141
|
tools: data.tools as SkillDefinition['tools'],
|
|
110
142
|
hooks: data.hooks as SkillDefinition['hooks'],
|
|
111
143
|
prompts: data.prompts as SkillDefinition['prompts'],
|
|
@@ -143,7 +175,8 @@ export class SkillsLoader {
|
|
|
143
175
|
|
|
144
176
|
let tokenBudget = 0
|
|
145
177
|
for (const skill of skills) {
|
|
146
|
-
const
|
|
178
|
+
const safeDesc = sanitizeSkillDescription(skill.description, skill.type)
|
|
179
|
+
const entry = `- ${skill.name}: ${safeDesc}`
|
|
147
180
|
const entryTokens = Math.ceil(entry.length / 4) + 1 // rough estimate
|
|
148
181
|
if (tokenBudget + entryTokens > maxTokens) break
|
|
149
182
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Safety Sanitizer
|
|
3
|
+
*
|
|
4
|
+
* Claude Code v2.1.228 security hardening for skills:
|
|
5
|
+
* 1. Strip/escape `!` command prefixes from skill body text
|
|
6
|
+
* 2. Block `@` file expansion references in skill body
|
|
7
|
+
* 3. Sanitize skill descriptions (strip markdown formatting, shell injection chars)
|
|
8
|
+
* 4. Tag synced skills with source identifier
|
|
9
|
+
* 5. Detect shadowing of local commands and MCP tool names
|
|
10
|
+
*
|
|
11
|
+
* Applied at skill load time (loader.ts) and execution time (fork-executor.ts, skill.ts).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Types ──
|
|
15
|
+
|
|
16
|
+
export interface SanitizeResult {
|
|
17
|
+
/** Sanitized text */
|
|
18
|
+
text: string
|
|
19
|
+
/** Warnings generated during sanitization */
|
|
20
|
+
warnings: string[]
|
|
21
|
+
/** Whether the text was modified */
|
|
22
|
+
modified: boolean
|
|
23
|
+
/** Whether the text was blocked entirely */
|
|
24
|
+
blocked: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShadowCheck {
|
|
28
|
+
/** Whether a shadowing conflict was detected */
|
|
29
|
+
shadowed: boolean
|
|
30
|
+
/** Name of the conflicting command/tool */
|
|
31
|
+
conflictsWith: string
|
|
32
|
+
/** Type of conflict: 'command' | 'mcp-tool' */
|
|
33
|
+
conflictType: 'command' | 'mcp-tool'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Constants ──
|
|
37
|
+
|
|
38
|
+
/** Known local command names that skills should not shadow */
|
|
39
|
+
const BUILTIN_COMMANDS = new Set([
|
|
40
|
+
'/help',
|
|
41
|
+
'/model',
|
|
42
|
+
'/models',
|
|
43
|
+
'/switch',
|
|
44
|
+
'/compact',
|
|
45
|
+
'/clear',
|
|
46
|
+
'/resume',
|
|
47
|
+
'/config',
|
|
48
|
+
'/skills',
|
|
49
|
+
'/browse-skills',
|
|
50
|
+
'/install-skill',
|
|
51
|
+
'/reload-skills',
|
|
52
|
+
'/remove-skill',
|
|
53
|
+
'/crsi',
|
|
54
|
+
'/sis',
|
|
55
|
+
'/plan',
|
|
56
|
+
'/no-plan',
|
|
57
|
+
'/triage',
|
|
58
|
+
'/workflows',
|
|
59
|
+
'/tasks',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** Source tags for synced skills */
|
|
63
|
+
const SOURCE_TAGS: Record<string, string> = {
|
|
64
|
+
'claude.ai': '🔗 [claude.ai synced]',
|
|
65
|
+
community: '📦 [community]',
|
|
66
|
+
standard: '',
|
|
67
|
+
mipham: '🏔️ [Mipham]',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Sanitizer ──
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize a skill body (markdown instructions) for safe execution.
|
|
74
|
+
*
|
|
75
|
+
* Security measures:
|
|
76
|
+
* - `! command` patterns → escaped to `! command` (prevents AI from treating as shell command)
|
|
77
|
+
* - `@file` references → prefixed with safety note
|
|
78
|
+
* - Trailing backticks that could break markdown fences → escaped
|
|
79
|
+
* - Excessively long lines → truncated
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeSkillBody(body: string): SanitizeResult {
|
|
82
|
+
const warnings: string[] = []
|
|
83
|
+
let text = body
|
|
84
|
+
let modified = false
|
|
85
|
+
|
|
86
|
+
// ── 1. Detect and warn about `!` shell command patterns ──
|
|
87
|
+
// Pattern: line starting with `!` (Bash command marker in some AI contexts)
|
|
88
|
+
const bangCommandRegex = /^!\s*(\S+)/gm
|
|
89
|
+
let bangMatch: RegExpExecArray | null
|
|
90
|
+
const bangCommands: string[] = []
|
|
91
|
+
while ((bangMatch = bangCommandRegex.exec(text)) !== null) {
|
|
92
|
+
bangCommands.push(bangMatch[1] || '')
|
|
93
|
+
}
|
|
94
|
+
if (bangCommands.length > 0) {
|
|
95
|
+
// Escape `!` → `! ` (zero-width space after bang to neutralize)
|
|
96
|
+
text = text.replace(/^!(?=\s*\S)/gm, '! ')
|
|
97
|
+
modified = true
|
|
98
|
+
warnings.push(
|
|
99
|
+
`Skill body contained ${bangCommands.length} shell-command-like patterns (${bangCommands.slice(0, 3).join(', ')}${bangCommands.length > 3 ? '...' : ''}). Escaped to prevent unintended command execution.`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 2. Detect and warn about `@file` references ──
|
|
104
|
+
const atFileRegex = /@(\S+\.(?:md|ts|js|json|yml|yaml|txt|csv|py|rs|go|java|html|css|sh))/gi
|
|
105
|
+
let atMatch: RegExpExecArray | null
|
|
106
|
+
const atFiles: string[] = []
|
|
107
|
+
while ((atMatch = atFileRegex.exec(text)) !== null) {
|
|
108
|
+
atFiles.push(atMatch[0])
|
|
109
|
+
}
|
|
110
|
+
if (atFiles.length > 0) {
|
|
111
|
+
// Replace @file with `@ file` (space after @ to neutralize expansion)
|
|
112
|
+
text = text.replace(/@(\S+\.\w{1,6})\b/gi, '@ $1')
|
|
113
|
+
modified = true
|
|
114
|
+
warnings.push(
|
|
115
|
+
`Skill body contained ${atFiles.length} file reference patterns (${atFiles.slice(0, 3).join(', ')}${atFiles.length > 3 ? '...' : ''}). Neutralized to prevent unintended file expansion.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 3. Sanitize markdown fence break attempts ──
|
|
120
|
+
// Prevent skills from breaking out of their markdown code block context
|
|
121
|
+
if (text.includes('```')) {
|
|
122
|
+
const fenceCount = (text.match(/```/g) || []).length
|
|
123
|
+
if (fenceCount % 2 !== 0) {
|
|
124
|
+
// Odd number: append closing fence
|
|
125
|
+
text += '\n```'
|
|
126
|
+
modified = true
|
|
127
|
+
warnings.push('Skill body had unclosed markdown fence — auto-closed for safety.')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { text, warnings, modified, blocked: false }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sanitize a skill description for display in system reminders.
|
|
136
|
+
*
|
|
137
|
+
* - Strip shell-injection characters ($, `, ;, |, &, <, >)
|
|
138
|
+
* - Strip markdown links that could be misleading
|
|
139
|
+
* - Truncate to max length
|
|
140
|
+
* - Add source tag for synced skills
|
|
141
|
+
*/
|
|
142
|
+
export function sanitizeSkillDescription(
|
|
143
|
+
description: string,
|
|
144
|
+
source?: string,
|
|
145
|
+
maxLength: number = 200,
|
|
146
|
+
): string {
|
|
147
|
+
let text = description
|
|
148
|
+
|
|
149
|
+
// Strip shell injection characters
|
|
150
|
+
text = text.replace(/[`$;|&<>]/g, '')
|
|
151
|
+
|
|
152
|
+
// Strip markdown links — keep text, drop URL
|
|
153
|
+
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
154
|
+
|
|
155
|
+
// Strip HTML tags
|
|
156
|
+
text = text.replace(/<[^>]*>/g, '')
|
|
157
|
+
|
|
158
|
+
// Collapse whitespace
|
|
159
|
+
text = text.replace(/\s+/g, ' ').trim()
|
|
160
|
+
|
|
161
|
+
// Truncate
|
|
162
|
+
if (text.length > maxLength) {
|
|
163
|
+
text = text.slice(0, maxLength - 3) + '...'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Add source tag
|
|
167
|
+
if (source && SOURCE_TAGS[source]) {
|
|
168
|
+
text = `${SOURCE_TAGS[source]} ${text}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return text
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if a skill name or description shadows a local command or MCP tool.
|
|
176
|
+
*
|
|
177
|
+
* @returns ShadowCheck with conflict info, or { shadowed: false }
|
|
178
|
+
*/
|
|
179
|
+
export function checkSkillShadow(
|
|
180
|
+
skillName: string,
|
|
181
|
+
description: string,
|
|
182
|
+
mcpToolNames?: string[],
|
|
183
|
+
): ShadowCheck {
|
|
184
|
+
// Check against builtin commands
|
|
185
|
+
if (BUILTIN_COMMANDS.has(skillName)) {
|
|
186
|
+
return { shadowed: true, conflictsWith: skillName, conflictType: 'command' }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check description for embedded command names
|
|
190
|
+
for (const cmd of BUILTIN_COMMANDS) {
|
|
191
|
+
const cmdPattern = cmd.replace(/\//g, '')
|
|
192
|
+
if (description.toLowerCase().includes(cmdPattern.toLowerCase())) {
|
|
193
|
+
// Only flag if the description could be confused as a command invocation
|
|
194
|
+
if (
|
|
195
|
+
description.includes(`\`${cmd}\``) ||
|
|
196
|
+
description.includes(` ${cmd} `) ||
|
|
197
|
+
description.startsWith(cmd)
|
|
198
|
+
) {
|
|
199
|
+
return { shadowed: true, conflictsWith: cmd, conflictType: 'command' }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check against MCP tool names
|
|
205
|
+
if (mcpToolNames) {
|
|
206
|
+
for (const toolName of mcpToolNames) {
|
|
207
|
+
if (skillName === toolName || description.includes(`\`${toolName}\``)) {
|
|
208
|
+
return { shadowed: true, conflictsWith: toolName, conflictType: 'mcp-tool' }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { shadowed: false, conflictsWith: '', conflictType: 'command' }
|
|
214
|
+
}
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
2
|
import { executeForkedSkill } from '../../skills/fork-executor'
|
|
3
|
+
import { sanitizeSkillBody } from '../../skills/sanitizer.js'
|
|
3
4
|
|
|
4
5
|
export const skillTool: ToolDefinition = {
|
|
5
6
|
name: 'Skill',
|
|
@@ -66,15 +67,19 @@ export const skillTool: ToolDefinition = {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// Standard inline execution — return skill body for AI to follow
|
|
70
|
+
const safeBodyResult = skill.body ? sanitizeSkillBody(skill.body) : undefined
|
|
71
|
+
const bodyText = safeBodyResult?.text || skill.body || '(no instructions body)'
|
|
72
|
+
|
|
69
73
|
const lines: string[] = [
|
|
70
74
|
`── Skill Invoked: ${skill.name} ──`,
|
|
71
75
|
`Type: ${skill.type} | Version: ${skill.version}`,
|
|
72
76
|
skill.description ? `Description: ${skill.description}` : '',
|
|
73
77
|
args ? `Arguments: ${args}` : '',
|
|
78
|
+
safeBodyResult?.warnings.length ? `⚠️ Safety: ${safeBodyResult.warnings.join('; ')}` : '',
|
|
74
79
|
'',
|
|
75
80
|
'The AI should now follow these instructions:',
|
|
76
81
|
'',
|
|
77
|
-
|
|
82
|
+
bodyText,
|
|
78
83
|
].filter(Boolean)
|
|
79
84
|
|
|
80
85
|
return { success: true, content: lines.join('\n') }
|
package/src/tools/file/edit.ts
CHANGED
|
@@ -90,6 +90,10 @@ export const editTool: ToolDefinition = {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
const content = readFileSync(filePath, 'utf-8')
|
|
93
|
+
|
|
94
|
+
// ── Read tracking: mark as read before editing ──
|
|
95
|
+
ctx.readFiles?.add(filePath)
|
|
96
|
+
|
|
93
97
|
if (content.length > MAX_FILE_SIZE) {
|
|
94
98
|
return {
|
|
95
99
|
success: false,
|
package/src/tools/file/read.ts
CHANGED
|
@@ -45,6 +45,9 @@ export const readTool: ToolDefinition = {
|
|
|
45
45
|
const limit = (params.limit as number) || 2000
|
|
46
46
|
const content = readFileSync(filePath, 'utf-8')
|
|
47
47
|
|
|
48
|
+
// ── Read tracking: mark file as read for Write tool safety ──
|
|
49
|
+
ctx.readFiles?.add(filePath)
|
|
50
|
+
|
|
48
51
|
// ── Credential masking ──
|
|
49
52
|
if (credentialConfig) {
|
|
50
53
|
const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
|
package/src/tools/file/write.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
4
|
import { resolveSafe } from '../../security/path'
|
|
5
5
|
|
|
6
6
|
export const writeTool: ToolDefinition = {
|
|
7
7
|
name: 'Write',
|
|
8
|
-
description:
|
|
8
|
+
description:
|
|
9
|
+
'Write a file to the local filesystem. Overwrites if one exists. You must have Read the file before overwriting it — this prevents accidental data loss.',
|
|
9
10
|
category: 'file',
|
|
10
11
|
permission: 'ask',
|
|
11
12
|
parameters: {
|
|
@@ -29,9 +30,25 @@ export const writeTool: ToolDefinition = {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// ── Read-before-write safety check ──
|
|
34
|
+
const fileExists = existsSync(filePath)
|
|
35
|
+
const wasRead = ctx.readFiles?.has(filePath) ?? true // if no tracking, allow
|
|
36
|
+
if (fileExists && !wasRead) {
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
content: '',
|
|
40
|
+
error: `File "${filePath}" already exists but has not been read this session. Read the file first before overwriting it. This prevents accidental data loss.`,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
33
45
|
writeFileSync(filePath, content, 'utf-8')
|
|
46
|
+
|
|
47
|
+
// Track as read since we just wrote it (future writes allowed)
|
|
48
|
+
ctx.readFiles?.add(filePath)
|
|
49
|
+
|
|
34
50
|
const lines = content.split('\n').length
|
|
35
|
-
|
|
51
|
+
const action = fileExists ? 'Updated' : 'Wrote'
|
|
52
|
+
return { success: true, content: `${action} ${lines} lines to ${filePath}` }
|
|
36
53
|
},
|
|
37
54
|
}
|