@iola_adm/iola-cli 0.1.24 → 0.1.25

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 CHANGED
@@ -52,6 +52,8 @@ iola agent
52
52
  ```bash
53
53
  iola skills list
54
54
  iola tools toolsets
55
+ iola files mode read-only
56
+ iola files tree .
55
57
  iola context init
56
58
  iola cron list
57
59
  iola daemon status
@@ -79,6 +81,7 @@ iola version --check
79
81
  - [AI-профили](https://github.com/adm-iola/iola-cli/wiki/AI-профили)
80
82
  - [Локальный инструментальный агент](https://github.com/adm-iola/iola-cli/wiki/Локальный-инструментальный-агент)
81
83
  - [Skills и toolsets](https://github.com/adm-iola/iola-cli/wiki/Skills-и-toolsets)
84
+ - [Локальные файлы](https://github.com/adm-iola/iola-cli/wiki/Локальные-файлы)
82
85
  - [Daemon, RPC и cron](https://github.com/adm-iola/iola-cli/wiki/Daemon-RPC-и-cron)
83
86
  - [Контекст и память](https://github.com/adm-iola/iola-cli/wiki/Контекст-и-память)
84
87
  - [Команды](https://github.com/adm-iola/iola-cli/wiki/Команды)
@@ -91,6 +94,7 @@ iola version --check
91
94
  - AI-профили для Ollama, OpenAI, OpenRouter и Codex CLI;
92
95
  - локальный tool-agent для слабых моделей;
93
96
  - skills, toolsets, permissions, memory, hooks и готовые agents;
97
+ - управляемые локальные файловые операции с режимами `locked`, `read-only`, `workspace-write`, `full-access`;
94
98
  - cron-задачи, локальный daemon и RPC для автоматизаций;
95
99
  - контекстные файлы `IOLA.md` и `.iola/context.md`;
96
100
  - интеграция с публичным MCP-сервером Йошкар-Олы.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "CLI и AI-агент для работы с открытыми данными городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -19,6 +19,8 @@ const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
19
19
  const DB_FILE = path.join(CONFIG_DIR, "iola.db");
20
20
  const DB_SCHEMA_VERSION = 4;
21
21
  const LOCAL_TOOLS = ["search_local", "get_card", "export_data", "run_report", "save_view"];
22
+ const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
23
+ const ALL_LOCAL_TOOLS = [...LOCAL_TOOLS, ...FILE_TOOLS];
22
24
  const HOOK_EVENTS = ["SessionStart", "BeforeTool", "AfterTool", "AfterSync", "BeforeExport", "SessionEnd"];
23
25
  const DAEMON_PORT = Number(process.env.IOLA_DAEMON_PORT || 18790);
24
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -43,9 +45,17 @@ const TOOLSETS = {
43
45
  description: "Внешние AI-провайдеры и Codex CLI.",
44
46
  permissions: { externalAi: true, codex: true },
45
47
  },
48
+ "local-files-read": {
49
+ description: "Чтение файлов, дерево папок и поиск внутри workspace.",
50
+ permissions: { readFiles: true, localTools: { files_tree: true, files_read: true, files_search: true } },
51
+ },
52
+ "local-files-write": {
53
+ description: "Запись и patch файлов внутри workspace с учетом approvals.",
54
+ permissions: { readFiles: true, writeFiles: true, editFiles: true, localTools: { files_write: true, files_patch: true } },
55
+ },
46
56
  safe: {
47
57
  description: "Безопасный режим: чтение данных без записи файлов и без sync.",
48
- permissions: { writeFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
58
+ permissions: { readFiles: true, writeFiles: false, editFiles: false, deleteFiles: false, sync: false, externalApi: true, externalAi: true, codex: false },
49
59
  },
50
60
  full: {
51
61
  description: "Полный локальный режим для доверенного пользователя.",
@@ -55,7 +65,10 @@ const TOOLSETS = {
55
65
  externalApi: true,
56
66
  externalAi: true,
57
67
  codex: true,
58
- localTools: Object.fromEntries(LOCAL_TOOLS.map((tool) => [tool, true])),
68
+ readFiles: true,
69
+ editFiles: true,
70
+ deleteFiles: false,
71
+ localTools: Object.fromEntries(ALL_LOCAL_TOOLS.map((tool) => [tool, true])),
59
72
  },
60
73
  },
61
74
  };
@@ -109,8 +122,16 @@ const DEFAULT_AI_CONFIG = {
109
122
  export_data: true,
110
123
  run_report: true,
111
124
  save_view: true,
125
+ files_tree: false,
126
+ files_read: false,
127
+ files_search: false,
128
+ files_write: false,
129
+ files_patch: false,
112
130
  },
131
+ readFiles: false,
113
132
  writeFiles: true,
133
+ editFiles: false,
134
+ deleteFiles: false,
114
135
  sync: true,
115
136
  externalApi: true,
116
137
  externalAi: true,
@@ -119,6 +140,13 @@ const DEFAULT_AI_CONFIG = {
119
140
  toolsets: {
120
141
  enabled: ["data-read", "reports", "sync", "ai"],
121
142
  },
143
+ files: {
144
+ mode: "locked",
145
+ approvals: "on-write",
146
+ workspaceRoot: ".",
147
+ maxReadBytes: 200000,
148
+ blockedGlobs: [".env", "*.pem", "*.key", "secrets", ".git", ".ssh", "AppData", "node_modules"],
149
+ },
122
150
  memory: {
123
151
  enabled: true,
124
152
  suggestions: true,
@@ -213,6 +241,7 @@ const COMMANDS = new Map([
213
241
  ["context", handleContext],
214
242
  ["skills", handleSkills],
215
243
  ["tools", handleTools],
244
+ ["files", handleFiles],
216
245
  ["cron", handleCron],
217
246
  ["daemon", handleDaemon],
218
247
  ["rpc", handleRpc],
@@ -315,6 +344,7 @@ Usage:
315
344
  iola context list|show|init
316
345
  iola skills list|show|paths|enable|disable
317
346
  iola tools list|toolsets|enable|disable|profile
347
+ iola files status|mode|approvals|tree|read|search|write|patch
318
348
  iola cron list|add|delete|run|tick
319
349
  iola daemon start|status
320
350
  iola rpc call METHOD [ARGS] [--json]
@@ -344,7 +374,7 @@ Usage:
344
374
  iola config set api.mcpBaseUrl URL
345
375
  iola config reset
346
376
  iola update
347
- iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
377
+ iola ask TEXT [--profile NAME] [--model MODEL] [--tools] [--files] [--reasoning fast|verify|vote] [--output FILE] [--schema json|table] [--events] [--no-history] [--bare] [--quiet] [--no-color] [--fail-on-empty]
348
378
  iola data LAYER [--limit 10] [--search TEXT] [--where FIELD=VALUE] [--columns a,b,c] [--format table|json|csv]
349
379
  iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
350
380
  iola ai context TEXT [--json]
@@ -555,6 +585,11 @@ async function handleAgentLine(line, state) {
555
585
  return false;
556
586
  }
557
587
 
588
+ if (command === "files") {
589
+ await handleFiles(args);
590
+ return false;
591
+ }
592
+
558
593
  if (command === "cron") {
559
594
  await handleCron(args);
560
595
  return false;
@@ -677,6 +712,7 @@ async function handleAgentLine(line, state) {
677
712
  wiki: ["wiki", args],
678
713
  context: ["context", args],
679
714
  skills: ["skills", args],
715
+ files: ["files", args],
680
716
  cron: ["cron", args],
681
717
  daemon: ["daemon", args],
682
718
  rpc: ["rpc", args],
@@ -724,6 +760,7 @@ function printAgentHelp() {
724
760
  /skills list
725
761
  /permissions
726
762
  /tools
763
+ /files status
727
764
  /cron list
728
765
  /daemon status
729
766
  /rpc call status
@@ -1453,6 +1490,7 @@ async function handleWiki(args) {
1453
1490
  ["AI-профили", `${base}/AI-профили`],
1454
1491
  ["Локальный инструментальный агент", `${base}/Локальный-инструментальный-агент`],
1455
1492
  ["Skills и toolsets", `${base}/Skills-и-toolsets`],
1493
+ ["Локальные файлы", `${base}/Локальные-файлы`],
1456
1494
  ["Daemon, RPC и cron", `${base}/Daemon-RPC-и-cron`],
1457
1495
  ["Контекст и память", `${base}/Контекст-и-память`],
1458
1496
  ["Команды", `${base}/Команды`],
@@ -1617,6 +1655,84 @@ async function handleTools(args) {
1617
1655
  throw new Error("Команды tools: list, toolsets, enable NAME, disable NAME, profile NAME.");
1618
1656
  }
1619
1657
 
1658
+ async function handleFiles(args) {
1659
+ const [action = "status", target, ...rest] = args;
1660
+ const options = parseOptions(rest);
1661
+ const config = await loadConfig();
1662
+
1663
+ if (action === "status") {
1664
+ printKeyValue({
1665
+ mode: config.files?.mode || "locked",
1666
+ approvals: config.files?.approvals || "on-write",
1667
+ workspaceRoot: resolveWorkspaceRoot(config),
1668
+ maxReadBytes: config.files?.maxReadBytes || 200000,
1669
+ readFiles: config.permissions?.readFiles ? "allow" : "deny",
1670
+ writeFiles: config.permissions?.writeFiles ? "allow" : "deny",
1671
+ editFiles: config.permissions?.editFiles ? "allow" : "deny",
1672
+ });
1673
+ return;
1674
+ }
1675
+
1676
+ if (action === "mode") {
1677
+ if (!["locked", "read-only", "workspace-write", "full-access"].includes(target)) {
1678
+ throw new Error("Режимы файлов: locked, read-only, workspace-write, full-access.");
1679
+ }
1680
+ await setFilesMode(target, config);
1681
+ console.log(`Файловый режим: ${target}`);
1682
+ return;
1683
+ }
1684
+
1685
+ if (action === "approvals") {
1686
+ if (!["never", "on-write", "on-danger", "always"].includes(target)) {
1687
+ throw new Error("Политики approvals: never, on-write, on-danger, always.");
1688
+ }
1689
+ await saveConfig({ files: { ...(config.files || {}), approvals: target } });
1690
+ console.log(`Файловые подтверждения: ${target}`);
1691
+ return;
1692
+ }
1693
+
1694
+ if (action === "tree") {
1695
+ const rows = await filesTree(target || ".", options);
1696
+ if (options.json) printJson(rows);
1697
+ else printTable(rows, [["type", "Тип"], ["path", "Путь"], ["size", "Размер"]]);
1698
+ return;
1699
+ }
1700
+
1701
+ if (action === "read") {
1702
+ if (!target) throw new Error("Пример: iola files read README.md");
1703
+ console.log(await filesRead(target, options));
1704
+ return;
1705
+ }
1706
+
1707
+ if (action === "search") {
1708
+ const query = target;
1709
+ if (!query) throw new Error('Пример: iola files search "Петрова" --path .');
1710
+ const rows = await filesSearch(query, options);
1711
+ if (options.json) printJson(rows);
1712
+ else printTable(rows, [["file", "Файл"], ["line", "Строка"], ["text", "Текст"]]);
1713
+ return;
1714
+ }
1715
+
1716
+ if (action === "write") {
1717
+ if (!target) throw new Error('Пример: iola files write report.md --text "..."');
1718
+ const text = options.text ?? rest.join(" ");
1719
+ if (!text) throw new Error('Для записи нужен --text "..." или текст после пути.');
1720
+ await filesWrite(target, text, { append: Boolean(options.append) });
1721
+ console.log(`Файл записан: ${target}`);
1722
+ return;
1723
+ }
1724
+
1725
+ if (action === "patch") {
1726
+ if (!target) throw new Error('Пример: iola files patch README.md --search old --replace new');
1727
+ if (!options.search || options.replace === undefined) throw new Error("Для patch нужны --search и --replace.");
1728
+ const result = await filesPatch(target, options.search, options.replace);
1729
+ printKeyValue(result);
1730
+ return;
1731
+ }
1732
+
1733
+ throw new Error("Команды files: status, mode MODE, approvals POLICY, tree [PATH], read FILE, search TEXT, write FILE --text TEXT, patch FILE --search OLD --replace NEW.");
1734
+ }
1735
+
1620
1736
  async function handleCron(args) {
1621
1737
  const [action = "list", ...rest] = args;
1622
1738
  const options = parseOptions(rest);
@@ -1722,6 +1838,14 @@ async function handlePermissions(args) {
1722
1838
  value: permissions.localTools?.[tool] === false ? "deny" : "allow",
1723
1839
  scope: "local-tool",
1724
1840
  })),
1841
+ ...FILE_TOOLS.map((tool) => ({
1842
+ permission: `localTools.${tool}`,
1843
+ value: permissions.localTools?.[tool] === true ? "allow" : "deny",
1844
+ scope: "file-tool",
1845
+ })),
1846
+ { permission: "readFiles", value: permissions.readFiles === true ? "allow" : "deny", scope: "filesystem" },
1847
+ { permission: "editFiles", value: permissions.editFiles === true ? "allow" : "deny", scope: "filesystem" },
1848
+ { permission: "deleteFiles", value: permissions.deleteFiles === true ? "allow" : "deny", scope: "filesystem" },
1725
1849
  { permission: "writeFiles", value: permissions.writeFiles === false ? "deny" : "allow", scope: "runtime" },
1726
1850
  { permission: "sync", value: permissions.sync === false ? "deny" : "allow", scope: "runtime" },
1727
1851
  { permission: "externalApi", value: permissions.externalApi === false ? "deny" : "allow", scope: "network" },
@@ -1743,12 +1867,12 @@ async function handlePermissions(args) {
1743
1867
  const allow = action === "allow";
1744
1868
  const next = { ...(config.permissions || DEFAULT_AI_CONFIG.permissions) };
1745
1869
  next.localTools = { ...(next.localTools || {}) };
1746
- if (LOCAL_TOOLS.includes(name)) {
1870
+ if (ALL_LOCAL_TOOLS.includes(name)) {
1747
1871
  next.localTools[name] = allow;
1748
1872
  } else if (name in DEFAULT_AI_CONFIG.permissions) {
1749
1873
  next[name] = allow;
1750
1874
  } else {
1751
- throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...LOCAL_TOOLS, "writeFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
1875
+ throw new Error(`Неизвестное разрешение: ${name}. Доступно: ${[...ALL_LOCAL_TOOLS, "readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"].join(", ")}`);
1752
1876
  }
1753
1877
  await saveConfig({ permissions: next });
1754
1878
  console.log(`${name}: ${allow ? "allow" : "deny"}`);
@@ -3910,7 +4034,7 @@ function resolveAiProfile(config, options = {}) {
3910
4034
  async function localToolAsk(question, providerConfig, options) {
3911
4035
  await ensureLocalData();
3912
4036
  const plan = await buildLocalToolPlan(question, providerConfig, options);
3913
- const validated = validateToolPlan(plan);
4037
+ const validated = validateToolPlan(plan, options);
3914
4038
  const result = await executeToolPlan(validated);
3915
4039
  const answer = formatToolResult(result, options);
3916
4040
 
@@ -3942,8 +4066,9 @@ async function buildLocalToolPlan(question, providerConfig, options) {
3942
4066
  const mode = options.reasoning || "verify";
3943
4067
  const prompt = [
3944
4068
  "Ты планировщик CLI iola. Верни только JSON.",
3945
- "Доступные tools: search_local, get_card, export_data, run_report, save_view.",
4069
+ `Доступные tools: ${availableToolNames(options).join(", ")}.`,
3946
4070
  "Схема: {\"steps\":[{\"tool\":\"search_local\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
4071
+ options.files ? "Файловые tools: files_tree {path,depth,limit}, files_read {path}, files_search {query,path}, files_write {path,text}, files_patch {path,search,replace}." : "",
3947
4072
  "Для выгрузки CSV добавь export_data с format=csv и output, если пользователь назвал файл.",
3948
4073
  `Вопрос: ${question}`,
3949
4074
  ].join("\n");
@@ -3952,11 +4077,11 @@ async function buildLocalToolPlan(question, providerConfig, options) {
3952
4077
  const raw = await callOllama(providerConfig, [{ role: "user", content: prompt }]);
3953
4078
  const parsed = parseJsonObject(raw);
3954
4079
  if (mode === "vote") {
3955
- return chooseBestPlan([parsed, inferToolPlan(question)]);
4080
+ return chooseBestPlan([parsed, inferToolPlan(question, options)], options);
3956
4081
  }
3957
4082
  return parsed;
3958
4083
  } catch {
3959
- return inferToolPlan(question);
4084
+ return inferToolPlan(question, options);
3960
4085
  }
3961
4086
  }
3962
4087
 
@@ -3966,7 +4091,7 @@ function parseJsonObject(text) {
3966
4091
  return JSON.parse(match[0]);
3967
4092
  }
3968
4093
 
3969
- function inferToolPlan(question) {
4094
+ function inferToolPlan(question, options = {}) {
3970
4095
  const normalized = question.toLocaleLowerCase("ru-RU");
3971
4096
  const dataset = normalized.includes("сад") ? "kindergartens" : normalized.includes("школ") || normalized.includes("лицей") ? "schools" : "all";
3972
4097
  const steps = [];
@@ -3979,13 +4104,20 @@ function inferToolPlan(question) {
3979
4104
  if (normalized.includes("csv") || normalized.includes("выгруз")) {
3980
4105
  steps.push({ tool: "export_data", args: { format: "csv", output: normalized.match(/([a-z0-9_-]+\.csv)/i)?.[1] || "iola-export.csv" } });
3981
4106
  }
4107
+ if (options.files || normalized.includes("файл") || normalized.includes("папк") || normalized.includes("readme")) {
4108
+ if (normalized.includes("найди") || normalized.includes("поиск")) {
4109
+ steps.unshift({ tool: "files_search", args: { query: question, path: "." } });
4110
+ } else {
4111
+ steps.unshift({ tool: "files_tree", args: { path: ".", depth: 2, limit: 80 } });
4112
+ }
4113
+ }
3982
4114
  return { steps };
3983
4115
  }
3984
4116
 
3985
- function chooseBestPlan(plans) {
4117
+ function chooseBestPlan(plans, options = {}) {
3986
4118
  return plans.find((plan) => {
3987
4119
  try {
3988
- validateToolPlan(plan);
4120
+ validateToolPlan(plan, options);
3989
4121
  return true;
3990
4122
  } catch {
3991
4123
  return false;
@@ -3993,8 +4125,8 @@ function chooseBestPlan(plans) {
3993
4125
  }) || plans.at(-1);
3994
4126
  }
3995
4127
 
3996
- function validateToolPlan(plan) {
3997
- const allowed = new Set(LOCAL_TOOLS);
4128
+ function validateToolPlan(plan, options = {}) {
4129
+ const allowed = new Set(availableToolNames(options));
3998
4130
  if (!plan || !Array.isArray(plan.steps)) throw new Error("Некорректный tool-plan.");
3999
4131
  for (const step of plan.steps) {
4000
4132
  if (!allowed.has(step.tool)) throw new Error(`Недопустимый tool: ${step.tool}`);
@@ -4002,6 +4134,10 @@ function validateToolPlan(plan) {
4002
4134
  return plan;
4003
4135
  }
4004
4136
 
4137
+ function availableToolNames(options = {}) {
4138
+ return options.files ? ALL_LOCAL_TOOLS : LOCAL_TOOLS;
4139
+ }
4140
+
4005
4141
  async function executeToolPlan(plan) {
4006
4142
  let current = [];
4007
4143
  const outputs = [];
@@ -4027,6 +4163,24 @@ async function executeToolPlan(plan) {
4027
4163
  const text = step.args?.format === "json" ? JSON.stringify(current, null, 2) : toCsv(current);
4028
4164
  await writeFile(step.args?.output || "iola-export.csv", text, "utf8");
4029
4165
  outputs.push({ tool: step.tool, output: step.args?.output || "iola-export.csv", rows: current.length });
4166
+ } else if (step.tool === "files_tree") {
4167
+ current = await filesTree(step.args?.path || ".", step.args || {});
4168
+ outputs.push({ tool: step.tool, rows: current.length });
4169
+ } else if (step.tool === "files_read") {
4170
+ const text = await filesRead(step.args?.path || step.args?.file || ".", step.args || {});
4171
+ current = [{ path: step.args?.path || step.args?.file || ".", text }];
4172
+ outputs.push({ tool: step.tool, bytes: text.length });
4173
+ } else if (step.tool === "files_search") {
4174
+ current = await filesSearch(step.args?.query || "", { path: step.args?.path || ".", limit: step.args?.limit || 50 });
4175
+ outputs.push({ tool: step.tool, rows: current.length });
4176
+ } else if (step.tool === "files_write") {
4177
+ await filesWrite(step.args?.path || step.args?.file, step.args?.text || "", { append: Boolean(step.args?.append) });
4178
+ current = [{ path: step.args?.path || step.args?.file, status: "written" }];
4179
+ outputs.push({ tool: step.tool, output: step.args?.path || step.args?.file, rows: 1 });
4180
+ } else if (step.tool === "files_patch") {
4181
+ const result = await filesPatch(step.args?.path || step.args?.file, step.args?.search || "", step.args?.replace || "");
4182
+ current = [result];
4183
+ outputs.push({ tool: step.tool, output: result.path, replacements: result.replacements });
4030
4184
  }
4031
4185
  await runHooks("AfterTool", { tool: step.tool, rows: current.length });
4032
4186
  }
@@ -4071,7 +4225,7 @@ async function runHooks(event, payload = {}) {
4071
4225
  async function assertPermission(name) {
4072
4226
  const config = await loadConfig();
4073
4227
  const permissions = applyToolsetPermissions(config.permissions || DEFAULT_AI_CONFIG.permissions, config.toolsets?.enabled || []);
4074
- if (LOCAL_TOOLS.includes(name)) {
4228
+ if (ALL_LOCAL_TOOLS.includes(name)) {
4075
4229
  if (permissions.localTools?.[name] === false) {
4076
4230
  throw new Error(`Tool запрещен политикой permissions: ${name}`);
4077
4231
  }
@@ -4584,12 +4738,12 @@ function parseOptions(args) {
4584
4738
 
4585
4739
  for (let index = 0; index < args.length; index += 1) {
4586
4740
  const arg = args[index];
4587
- if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix") {
4741
+ if (arg === "--json" || arg === "--yes" || arg === "--silent" || arg === "--events" || arg === "--no-history" || arg === "--summary" || arg === "--all" || arg === "--local" || arg === "--cache" || arg === "--tools" || arg === "--files" || arg === "--fts" || arg === "--bare" || arg === "--quiet" || arg === "--no-color" || arg === "--fail-on-empty" || arg === "--debug" || arg === "--fix" || arg === "--append") {
4588
4742
  result[arg.slice(2)] = true;
4589
4743
  } else if (arg === "--check" || arg === "--upgrade-node") {
4590
4744
  result.check = true;
4591
4745
  result[arg.slice(2)] = true;
4592
- } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--debug-file") {
4746
+ } else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--base-url" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--debug-file") {
4593
4747
  result[arg.slice(2)] = args[index + 1];
4594
4748
  index += 1;
4595
4749
  } else {
@@ -4972,6 +5126,207 @@ function readRequestBody(req) {
4972
5126
  });
4973
5127
  }
4974
5128
 
5129
+ async function setFilesMode(mode, config = null) {
5130
+ const current = config || await loadConfig();
5131
+ const localTools = { ...(current.permissions?.localTools || {}) };
5132
+ for (const tool of FILE_TOOLS) localTools[tool] = false;
5133
+ const permissions = {
5134
+ ...(current.permissions || DEFAULT_AI_CONFIG.permissions),
5135
+ localTools,
5136
+ readFiles: false,
5137
+ editFiles: false,
5138
+ deleteFiles: false,
5139
+ };
5140
+ const enabled = new Set(current.toolsets?.enabled || []);
5141
+ enabled.delete("local-files-read");
5142
+ enabled.delete("local-files-write");
5143
+
5144
+ if (mode === "read-only") {
5145
+ permissions.readFiles = true;
5146
+ for (const tool of ["files_tree", "files_read", "files_search"]) permissions.localTools[tool] = true;
5147
+ enabled.add("local-files-read");
5148
+ } else if (mode === "workspace-write") {
5149
+ permissions.readFiles = true;
5150
+ permissions.writeFiles = true;
5151
+ permissions.editFiles = true;
5152
+ for (const tool of FILE_TOOLS) permissions.localTools[tool] = true;
5153
+ enabled.add("local-files-read");
5154
+ enabled.add("local-files-write");
5155
+ } else if (mode === "full-access") {
5156
+ permissions.readFiles = true;
5157
+ permissions.writeFiles = true;
5158
+ permissions.editFiles = true;
5159
+ permissions.deleteFiles = false;
5160
+ for (const tool of FILE_TOOLS) permissions.localTools[tool] = true;
5161
+ enabled.add("local-files-read");
5162
+ enabled.add("local-files-write");
5163
+ }
5164
+
5165
+ await saveConfig({
5166
+ permissions,
5167
+ toolsets: { ...(current.toolsets || {}), enabled: [...enabled] },
5168
+ files: { ...(current.files || {}), mode },
5169
+ });
5170
+ }
5171
+
5172
+ function resolveWorkspaceRoot(config) {
5173
+ return path.resolve(process.cwd(), config.files?.workspaceRoot || ".");
5174
+ }
5175
+
5176
+ async function resolveFileTarget(target, operation) {
5177
+ if (!target) throw new Error("Путь к файлу обязателен.");
5178
+ const config = await loadConfig();
5179
+ const mode = config.files?.mode || "locked";
5180
+ if (mode === "locked") throw new Error("Файловые операции заблокированы. Включите: iola files mode read-only");
5181
+ const workspaceRoot = resolveWorkspaceRoot(config);
5182
+ const resolved = path.resolve(workspaceRoot, target);
5183
+ const relative = path.relative(workspaceRoot, resolved);
5184
+ const insideWorkspace = relative && !relative.startsWith("..") && !path.isAbsolute(relative);
5185
+
5186
+ if ((mode === "read-only" || mode === "workspace-write") && !insideWorkspace && resolved !== workspaceRoot) {
5187
+ throw new Error(`Путь вне workspace запрещен режимом ${mode}: ${resolved}`);
5188
+ }
5189
+
5190
+ const blocked = config.files?.blockedGlobs || [];
5191
+ const normalized = resolved.toLocaleLowerCase("ru-RU");
5192
+ if (blocked.some((pattern) => filePatternMatches(normalized, pattern))) {
5193
+ throw new Error(`Путь заблокирован политикой безопасности: ${target}`);
5194
+ }
5195
+
5196
+ if (operation === "read") await assertPermission("readFiles");
5197
+ if (operation === "write") await assertPermission("writeFiles");
5198
+ if (operation === "edit") await assertPermission("editFiles");
5199
+ if (operation === "delete") await assertPermission("deleteFiles");
5200
+
5201
+ return { config, resolved, workspaceRoot, relative: resolved === workspaceRoot ? "." : relative, insideWorkspace };
5202
+ }
5203
+
5204
+ function filePatternMatches(normalizedPath, pattern) {
5205
+ const normalizedPattern = String(pattern).toLocaleLowerCase("ru-RU").replace(/\*/g, "");
5206
+ if (!normalizedPattern) return false;
5207
+ return normalizedPath.split(/[\\/]/).includes(normalizedPattern) || normalizedPath.includes(normalizedPattern);
5208
+ }
5209
+
5210
+ function isBlockedPathForConfig(fullPath, config) {
5211
+ const normalized = fullPath.toLocaleLowerCase("ru-RU");
5212
+ return (config.files?.blockedGlobs || []).some((pattern) => filePatternMatches(normalized, pattern));
5213
+ }
5214
+
5215
+ async function filesTree(target = ".", options = {}) {
5216
+ await assertPermission("files_tree");
5217
+ const { resolved, workspaceRoot } = await resolveFileTarget(target, "read");
5218
+ const depth = Number(options.depth || 2);
5219
+ const limit = Number(options.limit || 100);
5220
+ const rows = [];
5221
+ await walkFiles(resolved, workspaceRoot, rows, depth, limit, (await loadConfig()));
5222
+ return rows;
5223
+ }
5224
+
5225
+ async function walkFiles(directory, workspaceRoot, rows, depth, limit, config) {
5226
+ if (rows.length >= limit || depth < 0) return;
5227
+ let entries = [];
5228
+ try {
5229
+ entries = readdirSync(directory, { withFileTypes: true });
5230
+ } catch {
5231
+ return;
5232
+ }
5233
+ for (const entry of entries) {
5234
+ if (rows.length >= limit) break;
5235
+ const full = path.join(directory, entry.name);
5236
+ if (isBlockedPathForConfig(full, config)) continue;
5237
+ const relative = path.relative(workspaceRoot, full) || ".";
5238
+ let size = "-";
5239
+ try {
5240
+ size = entry.isFile() ? (await stat(full)).size : "-";
5241
+ } catch {
5242
+ size = "-";
5243
+ }
5244
+ rows.push({ type: entry.isDirectory() ? "dir" : "file", path: relative, size });
5245
+ if (entry.isDirectory()) await walkFiles(full, workspaceRoot, rows, depth - 1, limit, config);
5246
+ }
5247
+ }
5248
+
5249
+ async function filesRead(target, options = {}) {
5250
+ await assertPermission("files_read");
5251
+ const { config, resolved } = await resolveFileTarget(target, "read");
5252
+ const info = await stat(resolved);
5253
+ if (!info.isFile()) throw new Error(`Это не файл: ${target}`);
5254
+ const maxBytes = Number(options.maxBytes || config.files?.maxReadBytes || 200000);
5255
+ if (info.size > maxBytes) throw new Error(`Файл слишком большой: ${info.size} байт. Лимит: ${maxBytes}`);
5256
+ return readFile(resolved, "utf8");
5257
+ }
5258
+
5259
+ async function filesSearch(query, options = {}) {
5260
+ await assertPermission("files_search");
5261
+ if (!query) throw new Error("Строка поиска обязательна.");
5262
+ const rows = await filesTree(options.path || ".", { depth: Number(options.depth || 4), limit: Number(options.limit || 200) });
5263
+ const results = [];
5264
+ for (const row of rows.filter((item) => item.type === "file")) {
5265
+ if (results.length >= Number(options.limit || 50)) break;
5266
+ try {
5267
+ const text = await filesRead(row.path, { maxBytes: 500000 });
5268
+ const lines = text.split(/\r?\n/);
5269
+ lines.forEach((line, index) => {
5270
+ if (results.length < Number(options.limit || 50) && line.toLocaleLowerCase("ru-RU").includes(String(query).toLocaleLowerCase("ru-RU"))) {
5271
+ results.push({ file: row.path, line: index + 1, text: line.trim().slice(0, 240) });
5272
+ }
5273
+ });
5274
+ } catch {
5275
+ // Binary, blocked or oversized files are skipped.
5276
+ }
5277
+ }
5278
+ return results;
5279
+ }
5280
+
5281
+ async function filesWrite(target, text, options = {}) {
5282
+ await assertPermission("files_write");
5283
+ const { resolved, relative } = await resolveFileTarget(target, "write");
5284
+ await maybeConfirmFileOperation("write", relative, text);
5285
+ await mkdir(path.dirname(resolved), { recursive: true });
5286
+ if (options.append) {
5287
+ await appendFile(resolved, text, "utf8");
5288
+ } else {
5289
+ await writeFile(resolved, text, "utf8");
5290
+ }
5291
+ }
5292
+
5293
+ async function filesPatch(target, search, replace) {
5294
+ await assertPermission("files_patch");
5295
+ const { resolved, relative } = await resolveFileTarget(target, "edit");
5296
+ const current = await readFile(resolved, "utf8");
5297
+ if (!current.includes(search)) throw new Error("Искомый фрагмент не найден.");
5298
+ const next = current.split(search).join(replace);
5299
+ const replacements = current.split(search).length - 1;
5300
+ await maybeConfirmFileOperation("patch", relative, unifiedPreview(current, next));
5301
+ await writeFile(resolved, next, "utf8");
5302
+ return { path: relative, replacements };
5303
+ }
5304
+
5305
+ async function maybeConfirmFileOperation(operation, target, preview) {
5306
+ const config = await loadConfig();
5307
+ const approvals = config.files?.approvals || "on-write";
5308
+ const needsApproval = approvals === "always" || approvals === "on-write" || (approvals === "on-danger" && operation !== "write");
5309
+ if (!needsApproval) return;
5310
+ console.log(`Файловая операция: ${operation} ${target}`);
5311
+ if (preview) console.log(String(preview).slice(0, 2000));
5312
+ const ok = await confirm("Продолжить? [y/N] ");
5313
+ if (!ok) throw new Error("Файловая операция отменена.");
5314
+ }
5315
+
5316
+ function unifiedPreview(before, after) {
5317
+ const beforeLines = before.split(/\r?\n/);
5318
+ const afterLines = after.split(/\r?\n/);
5319
+ const output = ["--- before", "+++ after"];
5320
+ const max = Math.max(beforeLines.length, afterLines.length);
5321
+ for (let index = 0; index < Math.min(max, 80); index += 1) {
5322
+ if (beforeLines[index] !== afterLines[index]) {
5323
+ if (beforeLines[index] !== undefined) output.push(`- ${beforeLines[index]}`);
5324
+ if (afterLines[index] !== undefined) output.push(`+ ${afterLines[index]}`);
5325
+ }
5326
+ }
5327
+ return output.join("\n");
5328
+ }
5329
+
4975
5330
  async function executeRpc(method, options = {}) {
4976
5331
  if (method === "status") {
4977
5332
  return { db: getDbStatus(), sync: getSyncStatus(), activeProfile: getActiveProfileName(await loadConfig()) };
@@ -4996,6 +5351,15 @@ async function executeRpc(method, options = {}) {
4996
5351
  await assertPermission("sync");
4997
5352
  return syncDataset(options.dataset || "schools");
4998
5353
  }
5354
+ if (method === "files.tree") {
5355
+ return filesTree(options.path || ".", options);
5356
+ }
5357
+ if (method === "files.read") {
5358
+ return { path: options.path, text: await filesRead(options.path, options) };
5359
+ }
5360
+ if (method === "files.search") {
5361
+ return filesSearch(options.query || options.search || "", options);
5362
+ }
4999
5363
  throw new Error(`RPC method неизвестен: ${method}. Доступно: status, search, card, quality, sync.`);
5000
5364
  }
5001
5365
 
@@ -5155,6 +5519,10 @@ function mergeConfig(base, override) {
5155
5519
  ...(override.permissions?.localTools || {}),
5156
5520
  },
5157
5521
  },
5522
+ files: {
5523
+ ...base.files,
5524
+ ...(override.files || {}),
5525
+ },
5158
5526
  memory: {
5159
5527
  ...base.memory,
5160
5528
  ...(override.memory || {}),
@@ -5190,7 +5558,7 @@ function validateConfig(config) {
5190
5558
  if (profile.provider !== "codex" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
5191
5559
  }
5192
5560
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
5193
- if (!LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
5561
+ if (!ALL_LOCAL_TOOLS.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);
5194
5562
  }
5195
5563
  for (const toolset of config.toolsets?.enabled || []) {
5196
5564
  if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
@@ -5205,8 +5573,9 @@ function configSchema() {
5205
5573
  properties: {
5206
5574
  api: { required: ["baseUrl", "mcpBaseUrl"] },
5207
5575
  ai: { required: ["activeProfile", "profiles"], providers: ["ollama", "openai", "openrouter", "codex"] },
5208
- permissions: { localTools: LOCAL_TOOLS, runtime: ["writeFiles", "sync", "externalApi", "externalAi", "codex"] },
5576
+ permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
5209
5577
  toolsets: { available: Object.keys(TOOLSETS) },
5578
+ files: { modes: ["locked", "read-only", "workspace-write", "full-access"], approvals: ["never", "on-write", "on-danger", "always"] },
5210
5579
  skills: { enabled: "array of skill names" },
5211
5580
  daemon: { host: "127.0.0.1", port: DAEMON_PORT },
5212
5581
  },
package/wiki/Home.md CHANGED
@@ -29,6 +29,7 @@ iola ask "найди школу 29"
29
29
  - [AI-профили](AI-профили)
30
30
  - [Локальный инструментальный агент](Локальный-инструментальный-агент)
31
31
  - [Skills и toolsets](Skills-и-toolsets)
32
+ - [Локальные файлы](Локальные-файлы)
32
33
  - [Daemon, RPC и cron](Daemon-RPC-и-cron)
33
34
  - [Контекст и память](Контекст-и-память)
34
35
  - [Команды](Команды)
@@ -47,6 +47,10 @@ iola agents list
47
47
  iola agents run quality-checker "проверь школы"
48
48
  iola skills list
49
49
  iola tools toolsets
50
+ iola files status
51
+ iola files mode read-only
52
+ iola files tree .
53
+ iola files search "Петрова" --path .
50
54
  iola memory suggest
51
55
  ```
52
56
 
@@ -0,0 +1,46 @@
1
+ # Локальные файлы
2
+
3
+ Файловые операции в `iola-cli` управляются отдельным режимом доступа и политикой подтверждений.
4
+
5
+ Режимы:
6
+
7
+ ```bash
8
+ iola files mode locked
9
+ iola files mode read-only
10
+ iola files mode workspace-write
11
+ iola files mode full-access
12
+ ```
13
+
14
+ - `locked` - файлы недоступны.
15
+ - `read-only` - дерево папок, чтение и поиск.
16
+ - `workspace-write` - чтение и запись только внутри workspace.
17
+ - `full-access` - расширенный режим, опасные пути все равно блокируются.
18
+
19
+ Подтверждения:
20
+
21
+ ```bash
22
+ iola files approvals never
23
+ iola files approvals on-write
24
+ iola files approvals on-danger
25
+ iola files approvals always
26
+ ```
27
+
28
+ Команды:
29
+
30
+ ```bash
31
+ iola files status
32
+ iola files tree .
33
+ iola files read README.md
34
+ iola files search "Петрова" --path .
35
+ iola files write report.md --text "Текст отчета"
36
+ iola files patch README.md --search old --replace new
37
+ ```
38
+
39
+ AI/tool-agent:
40
+
41
+ ```bash
42
+ iola ask "найди в текущей папке упоминания школ" --profile local --tools --files
43
+ ```
44
+
45
+ По умолчанию файловый режим `locked`. Запись требует включения `workspace-write` или `full-access`.
46
+