aica-cli 0.0.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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # aica
2
+
3
+ AI-ассистент мост между LLM в чате и локальной файловой системой разработчика.
4
+
5
+ ## Установка
6
+
7
+ ```bash
8
+ git clone <repo> aica
9
+ cd aica
10
+ npm install
11
+ npm link # для глобальной команды `aica`
package/bin/aica.js ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from 'chalk';
4
+ import { generatePassword } from '../lib/password.js';
5
+ import { startServer } from '../lib/server.js';
6
+ import { setupUPnP, createCloudflareTunnel, getExternalIP } from '../lib/tunnel.js';
7
+ import { showStartupUI } from '../lib/ui.js';
8
+
9
+ async function main() {
10
+ const args = process.argv.slice(2);
11
+
12
+ const role = args.find(arg => !arg.startsWith('--'));
13
+ const useUPnP = args.includes('--upnp');
14
+ const useIP = args.includes('--ip');
15
+ const useCloudflared = args.includes('--cloudflared');
16
+ const useQPost = args.includes('--q-post');
17
+ const useQGet = args.includes('--q-get');
18
+ const useQMix = args.includes('--q-mix');
19
+ const useAuto = args.includes('--auto');
20
+
21
+ // Проверка что только один режим
22
+ const modes = [useQPost, useQGet, useQMix].filter(Boolean).length;
23
+ if (modes > 1) {
24
+ console.error('❌ Можно указать только один режим: --q-post, --q-get или --q-mix');
25
+ process.exit(1);
26
+ }
27
+
28
+ // По умолчанию mixed
29
+ const requestMode = useQPost ? 'post' : useQGet ? 'get' : 'mixed';
30
+
31
+ const portIndex = args.indexOf('--port');
32
+ let customPort = null;
33
+ if (portIndex !== -1) {
34
+ const portArg = args[portIndex + 1];
35
+ customPort = parseInt(portArg, 10);
36
+ if (isNaN(customPort) || customPort < 1 || customPort > 65535) {
37
+ console.error('❌ Неверный порт');
38
+ process.exit(1);
39
+ }
40
+ }
41
+
42
+ const urlIndex = args.indexOf('--url');
43
+ let publicUrl = null;
44
+ if (urlIndex !== -1) {
45
+ publicUrl = args[urlIndex + 1];
46
+ }
47
+
48
+ if (!role) {
49
+ console.error('❌ Укажите роль: aica <role> [flags]');
50
+ console.error('');
51
+ console.error('Флаги:');
52
+ console.error(' --port <N> Порт сервера (по умолчанию 3000)');
53
+ console.error(' --url <URL> Публичный URL для промпта');
54
+ console.error(' --upnp Внешний IP + UPnP проброс порта');
55
+ console.error(' --ip Только внешний IP');
56
+ console.error(' --cloudflared Туннель через cloudflared');
57
+ console.error(' --q-mix Режим mixed: GET для чтения, POST для изменений (по умолчанию)');
58
+ console.error(' --q-get Режим get: всё через GET');
59
+ console.error(' --q-post Режим post: всё через POST');
60
+ console.error(' --auto Автономный режим: без подтверждений');
61
+ process.exit(1);
62
+ }
63
+
64
+ const workdir = process.cwd();
65
+ const password = generatePassword();
66
+ const startPort = customPort || 3000;
67
+ const port = await findAvailablePort(startPort);
68
+
69
+ const listenHost = (useUPnP || useIP) ? '0.0.0.0' : '127.0.0.1';
70
+ const server = await startServer({ workdir, role, password, port, listenHost, requestMode, autoMode: useAuto, publicUrl });
71
+
72
+ let upnpClient = null;
73
+ let tunnelUrl = null;
74
+ let tunnelProcess = null;
75
+ let externalIP = null;
76
+
77
+ if (useUPnP) {
78
+ try {
79
+ externalIP = await getExternalIP();
80
+ console.log(chalk.green(` ✅ Внешний IP: ${externalIP}`));
81
+ } catch (e) {
82
+ console.log(chalk.yellow(' ⚠️ Не удалось определить внешний IP'));
83
+ }
84
+ try {
85
+ upnpClient = await setupUPnP(port);
86
+ } catch (e) {
87
+ console.log(chalk.yellow(' ⚠️ UPnP не сработал'));
88
+ }
89
+ }
90
+
91
+ if (useIP) {
92
+ try {
93
+ externalIP = await getExternalIP();
94
+ console.log(chalk.green(` ✅ Внешний IP: ${externalIP}`));
95
+ } catch (e) {
96
+ console.log(chalk.yellow(' ⚠️ Не удалось определить внешний IP'));
97
+ }
98
+ }
99
+
100
+ if (useCloudflared) {
101
+ try {
102
+ const result = await createCloudflareTunnel(port);
103
+ tunnelUrl = result.url;
104
+ tunnelProcess = result.process;
105
+ } catch (e) {
106
+ console.log(chalk.yellow(' ⚠️ Туннель не создан'));
107
+ }
108
+ }
109
+
110
+ showStartupUI({
111
+ role, workdir, port, password, tunnelUrl, externalIP, publicUrl,
112
+ useUPnP, useIP, useCloudflared, requestMode, autoMode: useAuto
113
+ });
114
+
115
+ const cleanup = () => {
116
+ if (upnpClient) {
117
+ try { upnpClient.portUnmapping({ public: port }); } catch {}
118
+ }
119
+ if (tunnelProcess) {
120
+ try { tunnelProcess.kill(); } catch {}
121
+ }
122
+ process.exit(0);
123
+ };
124
+
125
+ process.on('SIGINT', cleanup);
126
+ process.on('SIGTERM', cleanup);
127
+ }
128
+
129
+ async function findAvailablePort(startPort) {
130
+ const net = await import('net');
131
+ for (let port = startPort; port < startPort + 100; port++) {
132
+ try {
133
+ await new Promise((resolve, reject) => {
134
+ const server = net.createServer();
135
+ server.listen(port, () => server.close(() => resolve()));
136
+ server.on('error', reject);
137
+ });
138
+ return port;
139
+ } catch (e) {}
140
+ }
141
+ throw new Error('Нет свободных портов');
142
+ }
143
+
144
+ main().catch(err => {
145
+ console.error('❌ Ошибка запуска:', err.message);
146
+ process.exit(1);
147
+ });
package/lib/actions.js ADDED
@@ -0,0 +1,131 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { applyPatch } from 'diff';
4
+ import { execSync } from 'child_process';
5
+ import { validatePath, validateCommand, loadAllowedCommands } from './security.js';
6
+ import { BackupManager } from './backup.js';
7
+
8
+ export class ActionExecutor {
9
+ constructor(workdir, logDir) {
10
+ this.workdir = workdir;
11
+ this.backup = new BackupManager(logDir);
12
+ this.allowedCommands = loadAllowedCommands(workdir);
13
+ }
14
+
15
+ execute(action) {
16
+ if (action.action === 'sequence') {
17
+ return this.executeSequence(action.steps);
18
+ }
19
+
20
+ return this.executeSingle(action);
21
+ }
22
+
23
+ executeSingle(action) {
24
+ if (action.action === 'exec') {
25
+ return this.executeCommand(action.command);
26
+ }
27
+
28
+ const filePath = action.file || action.path;
29
+ const abs = validatePath(filePath, this.workdir);
30
+
31
+ if (!['create', 'delete'].includes(action.action)) {
32
+ this.backup.create(abs);
33
+ }
34
+
35
+ switch (action.action) {
36
+ case 'patch':
37
+ return this.applyPatch(abs, action.diff);
38
+ case 'replace':
39
+ return this.replaceFile(abs, action.content);
40
+ case 'create':
41
+ return this.createFile(abs, action.content);
42
+ case 'delete':
43
+ return this.deleteFile(abs);
44
+ case 'rename':
45
+ return this.renameFile(abs, action.to);
46
+ case 'append':
47
+ return this.appendFile(abs, action.content);
48
+ default:
49
+ throw new Error(`unknown_action: ${action.action}`);
50
+ }
51
+ }
52
+
53
+ executeSequence(steps) {
54
+ const results = [];
55
+
56
+ for (const step of steps) {
57
+ try {
58
+ const result = this.executeSingle(step);
59
+ results.push({ step: step.stepIndex, action: step.action, status: 'success', result });
60
+ } catch (e) {
61
+ results.push({ step: step.stepIndex, action: step.action, status: 'failed', error: e.message });
62
+ throw new SequenceError(results, e.message);
63
+ }
64
+ }
65
+
66
+ return { sequence: true, results };
67
+ }
68
+
69
+ executeCommand(command) {
70
+ validateCommand(command, this.allowedCommands);
71
+
72
+ try {
73
+ const out = execSync(command, {
74
+ encoding: 'utf8',
75
+ timeout: 120000,
76
+ cwd: this.workdir,
77
+ maxBuffer: 10 * 1024 * 1024
78
+ });
79
+ return { success: true, output: out };
80
+ } catch (e) {
81
+ return {
82
+ success: false,
83
+ output: (e.stdout || '') + (e.stderr || ''),
84
+ exitCode: e.status
85
+ };
86
+ }
87
+ }
88
+
89
+ applyPatch(abs, diff) {
90
+ const original = fs.readFileSync(abs, 'utf8');
91
+ const result = applyPatch(original, diff);
92
+ if (result === false) throw new Error('patch_conflict');
93
+ fs.writeFileSync(abs, result);
94
+ return 'patched';
95
+ }
96
+
97
+ replaceFile(abs, content) {
98
+ fs.writeFileSync(abs, content);
99
+ return 'replaced';
100
+ }
101
+
102
+ createFile(abs, content) {
103
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
104
+ fs.writeFileSync(abs, content);
105
+ return 'created';
106
+ }
107
+
108
+ deleteFile(abs) {
109
+ fs.unlinkSync(abs);
110
+ return 'deleted';
111
+ }
112
+
113
+ renameFile(abs, to) {
114
+ const toAbs = validatePath(to, this.workdir);
115
+ fs.renameSync(abs, toAbs);
116
+ return 'renamed';
117
+ }
118
+
119
+ appendFile(abs, content) {
120
+ fs.appendFileSync(abs, content);
121
+ return 'appended';
122
+ }
123
+ }
124
+
125
+ export class SequenceError extends Error {
126
+ constructor(results, message) {
127
+ super(message);
128
+ this.name = 'SequenceError';
129
+ this.results = results;
130
+ }
131
+ }
package/lib/backup.js ADDED
@@ -0,0 +1,40 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ export class BackupManager {
5
+ constructor(logDir) {
6
+ this.logDir = logDir;
7
+ }
8
+
9
+ create(filePath) {
10
+ if (!fs.existsSync(filePath)) return null;
11
+
12
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
13
+ const name = path.basename(filePath);
14
+ const backupPath = path.join(this.logDir, `.backup_${name}_${ts}`);
15
+
16
+ fs.copyFileSync(filePath, backupPath);
17
+ return backupPath;
18
+ }
19
+
20
+ getLatest(filePath) {
21
+ const name = path.basename(filePath);
22
+ const pattern = `.backup_${name}_`;
23
+
24
+ const backups = fs.readdirSync(this.logDir)
25
+ .filter(f => f.startsWith(pattern))
26
+ .sort()
27
+ .reverse();
28
+
29
+ if (backups.length === 0) return null;
30
+ return path.join(this.logDir, backups[0]);
31
+ }
32
+
33
+ restore(filePath) {
34
+ const latest = this.getLatest(filePath);
35
+ if (!latest) throw new Error('No backup found');
36
+
37
+ fs.copyFileSync(latest, filePath);
38
+ return latest;
39
+ }
40
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+
5
+ export class Logger {
6
+ constructor(workdir) {
7
+ this.workdir = workdir;
8
+ this.logDir = path.join(workdir, '.ai-log');
9
+ this.requestsLog = path.join(this.logDir, 'requests.log');
10
+ this.counterFile = path.join(this.logDir, 'counter.txt');
11
+
12
+ fs.mkdirSync(this.logDir, { recursive: true });
13
+ this.ensureGitignore();
14
+ }
15
+
16
+ ensureGitignore() {
17
+ const gi = path.join(this.workdir, '.gitignore');
18
+ let content = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
19
+ const additions = [];
20
+ if (!content.includes('.ai-log')) additions.push('.ai-log/');
21
+ if (!content.includes('ai-patch-')) additions.push('ai-patch-*.txt');
22
+
23
+ if (additions.length > 0) {
24
+ content += '\n# aica agent\n' + additions.join('\n') + '\n';
25
+ fs.writeFileSync(gi, content);
26
+ }
27
+ }
28
+
29
+ logRequest(method, endpoint, body) {
30
+ const timestamp = new Date().toISOString();
31
+ const entry = {
32
+ time: timestamp,
33
+ method,
34
+ endpoint,
35
+ path: body.path,
36
+ reason: body.reason,
37
+ context: body.context
38
+ };
39
+
40
+ fs.appendFileSync(this.requestsLog, JSON.stringify(entry) + '\n');
41
+
42
+ console.log(chalk.gray(`[${timestamp.slice(11, 19)}] ${method} ${endpoint} ${body.path || ''}`));
43
+ if (body.reason) console.log(chalk.gray(` 💭 "${body.reason}"`));
44
+ }
45
+
46
+ getNextId() {
47
+ let counter = 0;
48
+ if (fs.existsSync(this.counterFile)) {
49
+ counter = parseInt(fs.readFileSync(this.counterFile, 'utf8'), 10) || 0;
50
+ }
51
+ counter++;
52
+ fs.writeFileSync(this.counterFile, String(counter));
53
+ return counter;
54
+ }
55
+
56
+ getRecentRequests(limit = 5) {
57
+ if (!fs.existsSync(this.requestsLog)) return [];
58
+ const lines = fs.readFileSync(this.requestsLog, 'utf8').trim().split('\n');
59
+ return lines.slice(-limit).map(l => {
60
+ try { return JSON.parse(l); } catch { return null; }
61
+ }).filter(Boolean);
62
+ }
63
+ }
package/lib/parser.js ADDED
@@ -0,0 +1,94 @@
1
+ export function parseActionFile(text) {
2
+ const splitIndex = text.search(/\n\s*\n/);
3
+ const headerPart = splitIndex === -1 ? text : text.slice(0, splitIndex);
4
+ const body = splitIndex === -1 ? '' : text.slice(splitIndex).replace(/^\s*\n/, '');
5
+
6
+ const headers = {};
7
+ for (const line of headerPart.split('\n')) {
8
+ const m = line.match(/^([A-Za-z-]+):\s*(.*)$/);
9
+ if (m) headers[m[1].toLowerCase()] = m[2].trim();
10
+ }
11
+
12
+ if (!headers.action) throw new Error('parse_error: missing Action header');
13
+
14
+ if (headers.action === 'sequence') {
15
+ return parseSequence(body, headers);
16
+ }
17
+
18
+ return parseSingleAction(body, headers);
19
+ }
20
+
21
+ function parseSequence(body, headers) {
22
+ const parts = body.split(/\n---\n/).map(p => p.trim()).filter(Boolean);
23
+
24
+ if (parts.length === 0) {
25
+ throw new Error('parse_error: empty sequence');
26
+ }
27
+
28
+ const steps = [];
29
+ for (let i = 0; i < parts.length; i++) {
30
+ const part = parts[i];
31
+ const stepSplitIndex = part.search(/\n\s*\n/);
32
+ const stepHeaderPart = stepSplitIndex === -1 ? part : part.slice(0, stepSplitIndex);
33
+ const stepBody = stepSplitIndex === -1 ? '' : part.slice(stepSplitIndex).replace(/^\s*\n/, '');
34
+
35
+ const stepHeaders = {};
36
+ for (const line of stepHeaderPart.split('\n')) {
37
+ const m = line.match(/^([A-Za-z-]+):\s*(.*)$/);
38
+ if (m) stepHeaders[m[1].toLowerCase()] = m[2].trim();
39
+ }
40
+
41
+ if (!stepHeaders.action) {
42
+ throw new Error(`parse_error: step ${i + 1} missing Action`);
43
+ }
44
+
45
+ const step = parseSingleAction(stepBody, stepHeaders);
46
+ step.stepIndex = i + 1;
47
+ steps.push(step);
48
+ }
49
+
50
+ return {
51
+ action: 'sequence',
52
+ description: headers.description,
53
+ reason: headers.reason,
54
+ notify: headers.notify !== undefined ? headers.notify.toLowerCase() === 'true' : true,
55
+ steps
56
+ };
57
+ }
58
+
59
+ function parseSingleAction(body, headers) {
60
+ const validActions = ['patch', 'replace', 'create', 'delete', 'rename', 'append', 'exec'];
61
+
62
+ if (!validActions.includes(headers.action)) {
63
+ throw new Error(`parse_error: unknown action "${headers.action}"`);
64
+ }
65
+
66
+ const result = { ...headers };
67
+
68
+ if (headers.notify !== undefined) {
69
+ result.notify = headers.notify.toLowerCase() === 'true';
70
+ } else {
71
+ result.notify = true;
72
+ }
73
+
74
+ if (['patch', 'replace', 'create', 'delete', 'rename', 'append'].includes(headers.action)) {
75
+ if (!headers.file && !headers.path) {
76
+ throw new Error(`parse_error: missing File header for ${headers.action}`);
77
+ }
78
+ }
79
+
80
+ if (headers.action === 'exec') {
81
+ if (!headers.command) {
82
+ throw new Error('parse_error: missing Command header for exec');
83
+ }
84
+ }
85
+
86
+ if (headers.action === 'patch') {
87
+ result.diff = body;
88
+ if (!body.trim()) throw new Error('parse_error: empty diff');
89
+ } else if (['replace', 'create', 'append'].includes(headers.action)) {
90
+ result.content = body;
91
+ }
92
+
93
+ return result;
94
+ }
@@ -0,0 +1,8 @@
1
+ export function generatePassword() {
2
+ const chars = 'abcdefghijkmnpqrstuvwxyz23456789';
3
+ let password = '';
4
+ for (let i = 0; i < 8; i++) {
5
+ password += chars[Math.floor(Math.random() * chars.length)];
6
+ }
7
+ return password;
8
+ }
@@ -0,0 +1,121 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const FORBIDDEN_PATTERNS = [
5
+ /node_modules/i,
6
+ /\.env(\..*)?$/i,
7
+ /\.git\//i,
8
+ /\.ai-log/i,
9
+ /ai-patch-/i,
10
+ /agent\.js/i,
11
+ /\.key$/i,
12
+ /secret/i,
13
+ /private.*key/i,
14
+ /token/i
15
+ ];
16
+
17
+ const DEFAULT_ALLOWED_COMMANDS = [
18
+ 'npm test',
19
+ 'npm run *',
20
+ 'npx jest',
21
+ 'npx vitest',
22
+ 'npx eslint *',
23
+ 'npx tsc',
24
+ 'npx tsc --*',
25
+ 'yarn test',
26
+ 'yarn run *',
27
+ 'pnpm test',
28
+ 'pnpm run *'
29
+ ];
30
+
31
+ export function validatePath(filePath, workdir) {
32
+ if (!filePath) throw new Error('forbidden_path: path is required');
33
+
34
+ const abs = path.resolve(workdir, filePath);
35
+
36
+ if (!abs.startsWith(path.resolve(workdir))) {
37
+ throw new Error('forbidden_path: outside workspace');
38
+ }
39
+
40
+ // Исправлено: исключаем lib/ из проверки password
41
+ const relativePath = path.relative(workdir, abs);
42
+ if (!relativePath.startsWith('lib' + path.sep) && !relativePath.startsWith('lib/')) {
43
+ for (const pattern of FORBIDDEN_PATTERNS) {
44
+ if (pattern.test(abs)) {
45
+ throw new Error(`forbidden_path: matches ${pattern}`);
46
+ }
47
+ }
48
+ } else {
49
+ // Для lib/ проверяем все паттерны кроме password
50
+ const libPatterns = FORBIDDEN_PATTERNS.filter(p => p.source !== 'password');
51
+ for (const pattern of libPatterns) {
52
+ if (pattern.test(abs)) {
53
+ throw new Error(`forbidden_path: matches ${pattern}`);
54
+ }
55
+ }
56
+ }
57
+
58
+ return abs;
59
+ }
60
+
61
+ export function isGitignored(absPath, workdir) {
62
+ try {
63
+ const gitignorePath = path.join(workdir, '.gitignore');
64
+ if (!fs.existsSync(gitignorePath)) return false;
65
+
66
+ const content = fs.readFileSync(gitignorePath, 'utf8');
67
+ const relPath = path.relative(workdir, absPath);
68
+
69
+ for (const line of content.split('\n')) {
70
+ const pattern = line.trim();
71
+ if (!pattern || pattern.startsWith('#')) continue;
72
+
73
+ if (relPath.includes(pattern.replace(/\*/g, ''))) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export function loadAllowedCommands(workdir) {
84
+ const configPath = path.join(workdir, 'aica.config.json');
85
+
86
+ if (fs.existsSync(configPath)) {
87
+ try {
88
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
89
+ if (Array.isArray(config.allowedCommands)) {
90
+ return config.allowedCommands;
91
+ }
92
+ } catch (e) {
93
+ console.warn(`⚠️ Ошибка чтения aica.config.json: ${e.message}`);
94
+ }
95
+ }
96
+
97
+ return DEFAULT_ALLOWED_COMMANDS;
98
+ }
99
+
100
+ export function validateCommand(command, allowedCommands) {
101
+ if (!command || typeof command !== 'string') {
102
+ throw new Error('forbidden_command: empty command');
103
+ }
104
+
105
+ const cmd = command.trim();
106
+
107
+ if (/[;&|`$]/.test(cmd)) {
108
+ throw new Error('forbidden_command: shell metacharacters not allowed');
109
+ }
110
+
111
+ for (const pattern of allowedCommands) {
112
+ if (pattern.includes('*')) {
113
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
114
+ if (regex.test(cmd)) return true;
115
+ } else if (cmd === pattern) {
116
+ return true;
117
+ }
118
+ }
119
+
120
+ throw new Error(`forbidden_command: "${cmd}" not in whitelist`);
121
+ }