@alia-codea/cli 1.1.0 → 2.0.0

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/src/index.ts CHANGED
@@ -8,15 +8,14 @@ import { login } from './commands/auth.js';
8
8
  import { listSessions, resumeSession } from './commands/sessions.js';
9
9
  import chalk from 'chalk';
10
10
 
11
- const VERSION = '1.0.0';
11
+ const VERSION = '2.0.0';
12
12
 
13
13
  const program = new Command();
14
14
 
15
- // ASCII art banner
16
15
  const banner = `
17
16
  ${chalk.cyan(' ____ _ ')}
18
17
  ${chalk.cyan(' / ___|___ __| | ___ __ _ ')}
19
- ${chalk.cyan(' | | / _ \\ / _\` |/ _ \\/ _\` |')}
18
+ ${chalk.cyan(' | | / _ \\ / _` |/ _ \\/ _` |')}
20
19
  ${chalk.cyan(' | |__| (_) | (_| | __/ (_| |')}
21
20
  ${chalk.cyan(' \\____\\___/ \\__,_|\\___|\\__,_|')}
22
21
  ${chalk.gray(' AI Coding Assistant by Alia')}
@@ -41,14 +40,15 @@ program
41
40
  }
42
41
  });
43
42
 
44
- // Default command - start REPL
43
+ // Default command - start REPL with Ink TUI
45
44
  program
46
45
  .command('chat', { isDefault: true })
47
46
  .description('Start an interactive chat session')
48
47
  .option('-m, --model <model>', 'Model to use (codea, codea-pro, codea-thinking)', 'alia-v1-codea')
48
+ .option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
49
49
  .option('--no-context', 'Disable automatic codebase context')
50
+ .option('--no-instructions', 'Disable CODEA.md project instructions')
50
51
  .action(async (options) => {
51
- console.log(banner);
52
52
  await startRepl(options);
53
53
  });
54
54
 
@@ -58,12 +58,26 @@ program
58
58
  .alias('r')
59
59
  .description('Run a single prompt and exit')
60
60
  .option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
61
- .option('-y, --yes', 'Auto-approve all file changes')
61
+ .option('-y, --yes', 'Auto-approve all actions (full-auto mode)')
62
+ .option('-a, --approval-mode <mode>', 'Approval mode: suggest, auto-edit, full-auto', 'suggest')
63
+ .option('-q, --quiet', 'Suppress UI, output only response text')
64
+ .option('--json', 'Output structured JSON')
62
65
  .option('--no-context', 'Disable automatic codebase context')
63
66
  .action(async (prompt, options) => {
64
67
  await runPrompt(prompt, options);
65
68
  });
66
69
 
70
+ // Exec command - shorthand for run --json --quiet --yes
71
+ program
72
+ .command('exec <prompt>')
73
+ .alias('x')
74
+ .description('Execute a prompt in full-auto mode with JSON output')
75
+ .option('-m, --model <model>', 'Model to use', 'alia-v1-codea')
76
+ .option('--no-context', 'Disable automatic codebase context')
77
+ .action(async (prompt, options) => {
78
+ await runPrompt(prompt, { ...options, yes: true, quiet: false, json: true });
79
+ });
80
+
67
81
  // Login/configure
68
82
  program
69
83
  .command('login')
@@ -2,8 +2,8 @@ import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import { exec } from 'child_process';
4
4
  import { promisify } from 'util';
5
- import { glob } from 'fs/promises';
6
5
  import chalk from 'chalk';
6
+ import { applyPatch } from './patch.js';
7
7
 
8
8
  const execAsync = promisify(exec);
9
9
 
@@ -21,10 +21,12 @@ export async function executeTool(name: string, args: Record<string, any>): Prom
21
21
  return await writeFile(args.path, args.content);
22
22
  case 'edit_file':
23
23
  return await editFile(args.path, args.old_text, args.new_text);
24
+ case 'apply_patch':
25
+ return await applyPatchTool(args.patch);
24
26
  case 'list_files':
25
27
  return await listFiles(args.path, args.recursive);
26
28
  case 'search_files':
27
- return await searchFiles(args.pattern, args.path, args.file_pattern);
29
+ return await searchFiles(args.pattern, args.path, args.file_pattern, args.context_lines, args.max_results);
28
30
  case 'run_command':
29
31
  return await runCommand(args.command, args.cwd);
30
32
  default:
@@ -55,14 +57,46 @@ async function editFile(filePath: string, oldText: string, newText: string): Pro
55
57
  const absolutePath = path.resolve(process.cwd(), filePath);
56
58
  const content = await fs.readFile(absolutePath, 'utf-8');
57
59
 
58
- if (!content.includes(oldText)) {
59
- return { success: false, result: `Text not found in file: "${oldText.slice(0, 50)}..."` };
60
+ // Try exact match first
61
+ if (content.includes(oldText)) {
62
+ const newContent = content.replace(oldText, newText);
63
+ await fs.writeFile(absolutePath, newContent, 'utf-8');
64
+ return { success: true, result: `File edited: ${filePath}` };
60
65
  }
61
66
 
62
- const newContent = content.replace(oldText, newText);
63
- await fs.writeFile(absolutePath, newContent, 'utf-8');
67
+ // Try whitespace-normalized match
68
+ const normalizedOld = oldText.replace(/\s+/g, ' ').trim();
69
+ const lines = content.split('\n');
70
+ let matchStart = -1;
71
+ let matchEnd = -1;
64
72
 
65
- return { success: true, result: `File edited: ${filePath}` };
73
+ for (let i = 0; i < lines.length; i++) {
74
+ for (let j = i; j < lines.length; j++) {
75
+ const block = lines.slice(i, j + 1).join('\n');
76
+ if (block.replace(/\s+/g, ' ').trim() === normalizedOld) {
77
+ matchStart = i;
78
+ matchEnd = j;
79
+ break;
80
+ }
81
+ }
82
+ if (matchStart >= 0) break;
83
+ }
84
+
85
+ if (matchStart >= 0) {
86
+ const newLines = [...lines.slice(0, matchStart), ...newText.split('\n'), ...lines.slice(matchEnd + 1)];
87
+ await fs.writeFile(absolutePath, newLines.join('\n'), 'utf-8');
88
+ return { success: true, result: `File edited (fuzzy match): ${filePath}` };
89
+ }
90
+
91
+ return { success: false, result: `Text not found in file: "${oldText.slice(0, 50)}..."` };
92
+ }
93
+
94
+ async function applyPatchTool(patchText: string): Promise<ToolResult> {
95
+ const result = await applyPatch(patchText, process.cwd());
96
+ const summary = result.results
97
+ .map((r) => `${r.success ? '✓' : '✗'} ${r.file}: ${r.message}`)
98
+ .join('\n');
99
+ return { success: result.success, result: summary };
66
100
  }
67
101
 
68
102
  async function listFiles(dirPath: string = '.', recursive: boolean = false): Promise<ToolResult> {
@@ -98,10 +132,71 @@ async function listFiles(dirPath: string = '.', recursive: boolean = false): Pro
98
132
  }
99
133
  }
100
134
 
101
- async function searchFiles(pattern: string, dirPath: string = '.', filePattern?: string): Promise<ToolResult> {
135
+ async function searchFiles(
136
+ pattern: string,
137
+ dirPath: string = '.',
138
+ filePattern?: string,
139
+ contextLines: number = 2,
140
+ maxResults: number = 50
141
+ ): Promise<ToolResult> {
102
142
  const absolutePath = path.resolve(process.cwd(), dirPath);
143
+
144
+ // Try ripgrep first
145
+ try {
146
+ const rgArgs = [
147
+ '--json',
148
+ '-C', String(contextLines),
149
+ '-m', String(maxResults),
150
+ '--no-heading',
151
+ ];
152
+
153
+ if (filePattern) {
154
+ rgArgs.push('-g', filePattern);
155
+ }
156
+
157
+ rgArgs.push('--', pattern, absolutePath);
158
+
159
+ const { stdout } = await execAsync(`rg ${rgArgs.map(a => `'${a}'`).join(' ')}`, {
160
+ maxBuffer: 2 * 1024 * 1024,
161
+ timeout: 30000,
162
+ });
163
+
164
+ // Parse rg --json output
165
+ const results: string[] = [];
166
+ const lines = stdout.trim().split('\n');
167
+
168
+ for (const line of lines) {
169
+ try {
170
+ const data = JSON.parse(line);
171
+ if (data.type === 'match') {
172
+ const relPath = path.relative(absolutePath, data.data.path.text);
173
+ const lineNum = data.data.line_number;
174
+ const text = data.data.lines.text.trimEnd();
175
+ results.push(`${relPath}:${lineNum}: ${text}`);
176
+ } else if (data.type === 'context') {
177
+ const relPath = path.relative(absolutePath, data.data.path.text);
178
+ const lineNum = data.data.line_number;
179
+ const text = data.data.lines.text.trimEnd();
180
+ results.push(`${relPath}:${lineNum} ${text}`);
181
+ }
182
+ } catch {
183
+ // Skip malformed JSON lines
184
+ }
185
+ }
186
+
187
+ if (results.length === 0) {
188
+ return { success: true, result: 'No matches found.' };
189
+ }
190
+
191
+ return { success: true, result: results.join('\n') };
192
+ } catch {
193
+ // ripgrep not available or failed, use built-in
194
+ }
195
+
196
+ // Built-in fallback
103
197
  const regex = new RegExp(pattern, 'gi');
104
- const results: string[] = [];
198
+ const results: Array<{ file: string; line: number; text: string; isMatch: boolean }> = [];
199
+ const fileMatchCounts = new Map<string, number>();
105
200
 
106
201
  async function searchDir(dir: string) {
107
202
  const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -109,7 +204,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
109
204
  for (const entry of entries) {
110
205
  const fullPath = path.join(dir, entry.name);
111
206
 
112
- // Skip common ignored directories
113
207
  if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') {
114
208
  continue;
115
209
  }
@@ -117,7 +211,6 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
117
211
  if (entry.isDirectory()) {
118
212
  await searchDir(fullPath);
119
213
  } else {
120
- // Check file pattern
121
214
  if (filePattern) {
122
215
  const ext = path.extname(entry.name);
123
216
  const patternExt = filePattern.replace('*', '');
@@ -128,14 +221,38 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
128
221
 
129
222
  try {
130
223
  const content = await fs.readFile(fullPath, 'utf-8');
224
+
225
+ // Skip binary files
226
+ if (content.includes('\0')) continue;
227
+
131
228
  const lines = content.split('\n');
229
+ const matchIndices: number[] = [];
132
230
 
133
231
  lines.forEach((line, index) => {
232
+ regex.lastIndex = 0;
134
233
  if (regex.test(line)) {
135
- const relativePath = path.relative(absolutePath, fullPath);
136
- results.push(`${relativePath}:${index + 1}: ${line.trim()}`);
234
+ matchIndices.push(index);
137
235
  }
138
236
  });
237
+
238
+ if (matchIndices.length > 0) {
239
+ const relativePath = path.relative(absolutePath, fullPath);
240
+ fileMatchCounts.set(relativePath, matchIndices.length);
241
+
242
+ for (const idx of matchIndices) {
243
+ const start = Math.max(0, idx - contextLines);
244
+ const end = Math.min(lines.length - 1, idx + contextLines);
245
+
246
+ for (let i = start; i <= end; i++) {
247
+ results.push({
248
+ file: relativePath,
249
+ line: i + 1,
250
+ text: lines[i].trimEnd(),
251
+ isMatch: i === idx,
252
+ });
253
+ }
254
+ }
255
+ }
139
256
  } catch {
140
257
  // Skip unreadable files
141
258
  }
@@ -149,7 +266,15 @@ async function searchFiles(pattern: string, dirPath: string = '.', filePattern?:
149
266
  return { success: true, result: 'No matches found.' };
150
267
  }
151
268
 
152
- return { success: true, result: results.slice(0, 100).join('\n') + (results.length > 100 ? `\n... and ${results.length - 100} more` : '') };
269
+ const formatted = results
270
+ .slice(0, maxResults * (1 + contextLines * 2))
271
+ .map((r) => `${r.file}:${r.line}${r.isMatch ? ':' : ' '} ${r.text}`)
272
+ .join('\n');
273
+
274
+ const totalMatches = Array.from(fileMatchCounts.values()).reduce((a, b) => a + b, 0);
275
+ const footer = `\n(${totalMatches} matches in ${fileMatchCounts.size} files)`;
276
+
277
+ return { success: true, result: formatted + footer };
153
278
  }
154
279
 
155
280
  async function runCommand(command: string, cwd?: string): Promise<ToolResult> {
@@ -177,6 +302,7 @@ export function formatToolCall(name: string, args: Record<string, any>): string
177
302
  read_file: 'Reading file',
178
303
  write_file: 'Writing file',
179
304
  edit_file: 'Editing file',
305
+ apply_patch: 'Applying patch',
180
306
  list_files: 'Listing files',
181
307
  search_files: 'Searching files',
182
308
  run_command: 'Running command'
@@ -0,0 +1,167 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+
4
+ interface Hunk {
5
+ oldStart: number;
6
+ oldLines: string[];
7
+ newLines: string[];
8
+ }
9
+
10
+ interface FilePatch {
11
+ filePath: string;
12
+ hunks: Hunk[];
13
+ }
14
+
15
+ interface PatchResult {
16
+ success: boolean;
17
+ results: Array<{ file: string; success: boolean; message: string }>;
18
+ }
19
+
20
+ export function parsePatch(patchText: string): FilePatch[] {
21
+ const files: FilePatch[] = [];
22
+ const lines = patchText.split('\n');
23
+ let currentFile: FilePatch | null = null;
24
+ let currentHunk: Hunk | null = null;
25
+
26
+ for (let i = 0; i < lines.length; i++) {
27
+ const line = lines[i];
28
+
29
+ // File header: --- a/path or --- path
30
+ if (line.startsWith('--- ')) {
31
+ // Next line should be +++ b/path
32
+ const nextLine = lines[i + 1];
33
+ if (nextLine && nextLine.startsWith('+++ ')) {
34
+ let filePath = nextLine.slice(4).trim();
35
+ // Remove b/ prefix
36
+ if (filePath.startsWith('b/')) {
37
+ filePath = filePath.slice(2);
38
+ }
39
+ currentFile = { filePath, hunks: [] };
40
+ files.push(currentFile);
41
+ i++; // skip the +++ line
42
+ continue;
43
+ }
44
+ }
45
+
46
+ // Hunk header: @@ -start,count +start,count @@
47
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/);
48
+ if (hunkMatch && currentFile) {
49
+ currentHunk = {
50
+ oldStart: parseInt(hunkMatch[1], 10),
51
+ oldLines: [],
52
+ newLines: [],
53
+ };
54
+ currentFile.hunks.push(currentHunk);
55
+ continue;
56
+ }
57
+
58
+ // Hunk content
59
+ if (currentHunk) {
60
+ if (line.startsWith('-')) {
61
+ currentHunk.oldLines.push(line.slice(1));
62
+ } else if (line.startsWith('+')) {
63
+ currentHunk.newLines.push(line.slice(1));
64
+ } else if (line.startsWith(' ')) {
65
+ // Context line - appears in both old and new
66
+ currentHunk.oldLines.push(line.slice(1));
67
+ currentHunk.newLines.push(line.slice(1));
68
+ }
69
+ }
70
+ }
71
+
72
+ return files;
73
+ }
74
+
75
+ function findHunkPosition(fileLines: string[], hunkOldLines: string[], expectedStart: number, drift: number = 20): number {
76
+ // Try exact position first (0-indexed)
77
+ const start = expectedStart - 1;
78
+ if (matchesAt(fileLines, hunkOldLines, start)) {
79
+ return start;
80
+ }
81
+
82
+ // Fuzzy search within ±drift lines
83
+ for (let offset = 1; offset <= drift; offset++) {
84
+ if (matchesAt(fileLines, hunkOldLines, start + offset)) {
85
+ return start + offset;
86
+ }
87
+ if (matchesAt(fileLines, hunkOldLines, start - offset)) {
88
+ return start - offset;
89
+ }
90
+ }
91
+
92
+ return -1;
93
+ }
94
+
95
+ function matchesAt(fileLines: string[], hunkOldLines: string[], position: number): boolean {
96
+ if (position < 0 || position + hunkOldLines.length > fileLines.length) {
97
+ return false;
98
+ }
99
+
100
+ for (let i = 0; i < hunkOldLines.length; i++) {
101
+ // Normalize whitespace for comparison
102
+ const fileLine = fileLines[position + i].trimEnd();
103
+ const hunkLine = hunkOldLines[i].trimEnd();
104
+ if (fileLine !== hunkLine) {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ return true;
110
+ }
111
+
112
+ export async function applyPatch(patchText: string, basePath: string): Promise<PatchResult> {
113
+ const filePatches = parsePatch(patchText);
114
+ const results: PatchResult['results'] = [];
115
+ let allSuccess = true;
116
+
117
+ for (const filePatch of filePatches) {
118
+ const absolutePath = path.resolve(basePath, filePatch.filePath);
119
+
120
+ try {
121
+ let content: string;
122
+ try {
123
+ content = await fs.readFile(absolutePath, 'utf-8');
124
+ } catch {
125
+ // File doesn't exist - if all hunks are additions, create it
126
+ const allAdditions = filePatch.hunks.every((h) => h.oldLines.length === 0);
127
+ if (allAdditions) {
128
+ const newContent = filePatch.hunks.map((h) => h.newLines.join('\n')).join('\n');
129
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
130
+ await fs.writeFile(absolutePath, newContent, 'utf-8');
131
+ results.push({ file: filePatch.filePath, success: true, message: 'Created new file' });
132
+ continue;
133
+ }
134
+ throw new Error(`File not found: ${filePatch.filePath}`);
135
+ }
136
+
137
+ let fileLines = content.split('\n');
138
+
139
+ // Apply hunks in reverse order to preserve line numbers
140
+ const sortedHunks = [...filePatch.hunks].sort((a, b) => b.oldStart - a.oldStart);
141
+
142
+ for (const hunk of sortedHunks) {
143
+ const position = findHunkPosition(fileLines, hunk.oldLines, hunk.oldStart);
144
+ if (position === -1) {
145
+ throw new Error(
146
+ `Could not find match for hunk at line ${hunk.oldStart} in ${filePatch.filePath}`
147
+ );
148
+ }
149
+
150
+ // Replace old lines with new lines
151
+ fileLines.splice(position, hunk.oldLines.length, ...hunk.newLines);
152
+ }
153
+
154
+ await fs.writeFile(absolutePath, fileLines.join('\n'), 'utf-8');
155
+ results.push({
156
+ file: filePatch.filePath,
157
+ success: true,
158
+ message: `Applied ${filePatch.hunks.length} hunk(s)`,
159
+ });
160
+ } catch (error: any) {
161
+ allSuccess = false;
162
+ results.push({ file: filePatch.filePath, success: false, message: error.message });
163
+ }
164
+ }
165
+
166
+ return { success: allSuccess, results };
167
+ }
package/src/utils/api.ts CHANGED
@@ -59,7 +59,7 @@ export const fileTools = [
59
59
  type: 'function',
60
60
  function: {
61
61
  name: 'edit_file',
62
- description: 'Make targeted edits to a file by replacing specific text',
62
+ description: 'Make targeted edits to a file by replacing specific text. For small single-location changes.',
63
63
  parameters: {
64
64
  type: 'object',
65
65
  properties: {
@@ -71,6 +71,23 @@ export const fileTools = [
71
71
  }
72
72
  }
73
73
  },
74
+ {
75
+ type: 'function',
76
+ function: {
77
+ name: 'apply_patch',
78
+ description: 'Apply a unified diff patch to one or more files. Preferred for multi-line or multi-file changes. Uses standard unified diff format with fuzzy line matching (±20 line drift).',
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ patch: {
83
+ type: 'string',
84
+ description: 'The unified diff patch text. Must include --- a/file and +++ b/file headers and @@ hunk headers.'
85
+ }
86
+ },
87
+ required: ['patch']
88
+ }
89
+ }
90
+ },
74
91
  {
75
92
  type: 'function',
76
93
  function: {
@@ -89,13 +106,15 @@ export const fileTools = [
89
106
  type: 'function',
90
107
  function: {
91
108
  name: 'search_files',
92
- description: 'Search for text patterns across files',
109
+ description: 'Search for text patterns across files. Uses ripgrep when available for fast results with context lines.',
93
110
  parameters: {
94
111
  type: 'object',
95
112
  properties: {
96
113
  pattern: { type: 'string', description: 'The search pattern (regex supported)' },
97
114
  path: { type: 'string', description: 'Directory to search in (default: current)' },
98
- file_pattern: { type: 'string', description: 'File glob pattern (e.g., "*.ts")' }
115
+ file_pattern: { type: 'string', description: 'File glob pattern (e.g., "*.ts")' },
116
+ context_lines: { type: 'number', description: 'Number of context lines around matches (default: 2)' },
117
+ max_results: { type: 'number', description: 'Maximum number of matches to return (default: 50)' }
99
118
  },
100
119
  required: ['pattern']
101
120
  }
@@ -0,0 +1,31 @@
1
+ export type ApprovalMode = 'suggest' | 'auto-edit' | 'full-auto';
2
+ export type ToolCategory = 'read_only' | 'file_write' | 'shell';
3
+
4
+ const TOOL_CATEGORIES: Record<string, ToolCategory> = {
5
+ read_file: 'read_only',
6
+ list_files: 'read_only',
7
+ search_files: 'read_only',
8
+ write_file: 'file_write',
9
+ edit_file: 'file_write',
10
+ apply_patch: 'file_write',
11
+ run_command: 'shell',
12
+ };
13
+
14
+ export function categorize(toolName: string): ToolCategory {
15
+ return TOOL_CATEGORIES[toolName] || 'shell';
16
+ }
17
+
18
+ export function needsApproval(toolName: string, mode: ApprovalMode): boolean {
19
+ const category = categorize(toolName);
20
+
21
+ switch (mode) {
22
+ case 'full-auto':
23
+ return false;
24
+ case 'auto-edit':
25
+ return category === 'shell';
26
+ case 'suggest':
27
+ return category === 'file_write' || category === 'shell';
28
+ default:
29
+ return true;
30
+ }
31
+ }
@@ -5,7 +5,7 @@ import { promisify } from 'util';
5
5
 
6
6
  const execAsync = promisify(exec);
7
7
 
8
- export function buildSystemMessage(model: string, codebaseContext: string): string {
8
+ export function buildSystemMessage(model: string, codebaseContext: string, projectInstructions?: string): string {
9
9
  let systemMessage = `You are Codea, an expert AI coding assistant created by Alia. You help developers write, debug, refactor, and understand code directly in their terminal.
10
10
 
11
11
  ## Core Principles
@@ -19,16 +19,18 @@ You have powerful tools to interact with the user's workspace:
19
19
 
20
20
  - **read_file**: Read file contents. Use to understand existing code before making changes.
21
21
  - **write_file**: Create new files or completely rewrite existing ones.
22
- - **edit_file**: Make precise, targeted changes to existing files. Preferred for small modifications.
22
+ - **edit_file**: Make precise, targeted changes using exact text match and replace.
23
+ - **apply_patch**: Apply unified diff patches to files. Preferred for multi-line or multi-file changes. Supports fuzzy line matching.
23
24
  - **list_files**: Explore directory structure. Use to understand project layout.
24
- - **search_files**: Find text/patterns across the codebase. Great for finding usages, definitions, etc.
25
+ - **search_files**: Find text/patterns across the codebase with context lines. Uses ripgrep when available.
25
26
  - **run_command**: Execute shell commands (build, test, git, npm, etc.)
26
27
 
27
28
  ## Best Practices
28
29
  1. **Read before writing**: Always read relevant files before modifying them.
29
30
  2. **Minimal changes**: Make the smallest change necessary to accomplish the task.
30
31
  3. **Preserve style**: Match existing formatting, naming conventions, and patterns.
31
- 4. **Explain when helpful**: For complex changes, briefly explain the approach.
32
+ 4. **Prefer apply_patch**: For multi-line edits, use apply_patch with unified diff format.
33
+ 5. **Explain when helpful**: For complex changes, briefly explain the approach.
32
34
 
33
35
  ## Response Style
34
36
  - Use markdown for formatting code blocks, lists, and emphasis.
@@ -36,6 +38,10 @@ You have powerful tools to interact with the user's workspace:
36
38
  - For code changes, be precise and action-oriented.
37
39
  - If unsure about requirements, ask clarifying questions.`;
38
40
 
41
+ if (projectInstructions) {
42
+ systemMessage += `\n\n## Project Instructions (from CODEA.md)\n${projectInstructions}`;
43
+ }
44
+
39
45
  if (codebaseContext) {
40
46
  systemMessage += `\n\n## Current Codebase Context\n${codebaseContext}`;
41
47
  }
@@ -43,6 +49,61 @@ You have powerful tools to interact with the user's workspace:
43
49
  return systemMessage;
44
50
  }
45
51
 
52
+ export async function loadProjectInstructions(): Promise<string> {
53
+ const parts: string[] = [];
54
+
55
+ // 1. Global: ~/.codea/CODEA.md
56
+ const home = process.env.HOME || '';
57
+ if (home) {
58
+ const globalPath = path.join(home, '.codea', 'CODEA.md');
59
+ try {
60
+ const content = await fs.readFile(globalPath, 'utf-8');
61
+ if (content.trim()) {
62
+ parts.push(`# Global Instructions (~/.codea/CODEA.md)\n${content.trim()}`);
63
+ }
64
+ } catch {
65
+ // No global instructions
66
+ }
67
+ }
68
+
69
+ // 2. Project root: {git_root}/CODEA.md
70
+ let gitRoot = '';
71
+ try {
72
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', { cwd: process.cwd() });
73
+ gitRoot = stdout.trim();
74
+ } catch {
75
+ // Not a git repo
76
+ }
77
+
78
+ if (gitRoot) {
79
+ const projectPath = path.join(gitRoot, 'CODEA.md');
80
+ try {
81
+ const content = await fs.readFile(projectPath, 'utf-8');
82
+ if (content.trim()) {
83
+ parts.push(`# Project Instructions (CODEA.md)\n${content.trim()}`);
84
+ }
85
+ } catch {
86
+ // No project instructions
87
+ }
88
+ }
89
+
90
+ // 3. Directory-level: {cwd}/CODEA.md (if different from git root)
91
+ const cwd = process.cwd();
92
+ if (cwd !== gitRoot) {
93
+ const dirPath = path.join(cwd, 'CODEA.md');
94
+ try {
95
+ const content = await fs.readFile(dirPath, 'utf-8');
96
+ if (content.trim()) {
97
+ parts.push(`# Directory Instructions (./CODEA.md)\n${content.trim()}`);
98
+ }
99
+ } catch {
100
+ // No directory instructions
101
+ }
102
+ }
103
+
104
+ return parts.join('\n\n---\n\n');
105
+ }
106
+
46
107
  export async function getCodebaseContext(): Promise<string> {
47
108
  const cwd = process.cwd();
48
109
  const contextParts: string[] = [];