@wangzhizhi/remi 0.0.1-alpha

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.
Files changed (55) hide show
  1. package/README.md +9 -0
  2. package/dist/doctor.js +108 -0
  3. package/dist/git.js +41 -0
  4. package/dist/help.js +27 -0
  5. package/dist/i18n.js +422 -0
  6. package/dist/index.js +97 -0
  7. package/dist/initPrompt.js +17 -0
  8. package/dist/model.js +116 -0
  9. package/dist/modelSelection.js +34 -0
  10. package/dist/permissionDisplay.js +46 -0
  11. package/dist/permissions.js +206 -0
  12. package/dist/repl.js +346 -0
  13. package/dist/resume.js +3 -0
  14. package/dist/setup.js +62 -0
  15. package/dist/statusline.js +59 -0
  16. package/dist/style.js +48 -0
  17. package/dist/syntaxTheme.js +39 -0
  18. package/dist/tui/RemiApp.js +1756 -0
  19. package/dist/tui/commands.js +427 -0
  20. package/dist/tui/index.js +42 -0
  21. package/dist/tui/renderers/Header.js +28 -0
  22. package/dist/tui/renderers/MessageList.js +1176 -0
  23. package/dist/tui/renderers/PromptBox.js +118 -0
  24. package/dist/tui/renderers/StatusLine.js +124 -0
  25. package/dist/tui/renderers/WorkingIndicator.js +70 -0
  26. package/dist/tui/slashCommandHighlight.js +8 -0
  27. package/dist/tui/theme.js +13 -0
  28. package/dist/tui/types.js +1 -0
  29. package/dist/usage.js +66 -0
  30. package/dist/version.js +5 -0
  31. package/node_modules/@remi/compact/dist/index.js +389 -0
  32. package/node_modules/@remi/compact/package.json +8 -0
  33. package/node_modules/@remi/config/dist/index.js +426 -0
  34. package/node_modules/@remi/config/package.json +8 -0
  35. package/node_modules/@remi/core/dist/contextBuilder.js +344 -0
  36. package/node_modules/@remi/core/dist/directoryOverview.js +359 -0
  37. package/node_modules/@remi/core/dist/index.js +2843 -0
  38. package/node_modules/@remi/core/dist/projectInstructions.js +123 -0
  39. package/node_modules/@remi/core/dist/responseStyles.js +98 -0
  40. package/node_modules/@remi/core/package.json +8 -0
  41. package/node_modules/@remi/llm/dist/index.js +804 -0
  42. package/node_modules/@remi/llm/package.json +8 -0
  43. package/node_modules/@remi/memory/dist/index.js +312 -0
  44. package/node_modules/@remi/memory/package.json +8 -0
  45. package/node_modules/@remi/permissions/dist/index.js +90 -0
  46. package/node_modules/@remi/permissions/package.json +8 -0
  47. package/node_modules/@remi/sessions/dist/index.js +370 -0
  48. package/node_modules/@remi/sessions/package.json +8 -0
  49. package/node_modules/@remi/skills/dist/index.js +273 -0
  50. package/node_modules/@remi/skills/package.json +8 -0
  51. package/node_modules/@remi/terminal-markdown/dist/index.js +1412 -0
  52. package/node_modules/@remi/terminal-markdown/package.json +8 -0
  53. package/node_modules/@remi/tools/dist/index.js +3875 -0
  54. package/node_modules/@remi/tools/package.json +8 -0
  55. package/package.json +48 -0
package/dist/index.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { formatDoctor, runDoctor } from './doctor.js';
5
+ import { formatHelp } from './help.js';
6
+ import { runModelCommand } from './model.js';
7
+ import { startRepl } from './repl.js';
8
+ import { runSetupCommand } from './setup.js';
9
+ import { startTui } from './tui/index.js';
10
+ import { version } from './version.js';
11
+ import { sessionExists } from '@remi/sessions';
12
+ export const cliPackageName = '@remi/cli';
13
+ export function shouldUseTui(env = process.env, input = process.stdin, output = process.stdout) {
14
+ if (env['REMI_PLAIN'] === '1' || env['REMI_PLAIN'] === 'true') {
15
+ return false;
16
+ }
17
+ if (env['CI'] === '1' || env['CI'] === 'true') {
18
+ return false;
19
+ }
20
+ return input.isTTY === true && output.isTTY === true;
21
+ }
22
+ export function isDirectCliEntrypoint(metaUrl, argvPath = process.argv[1]) {
23
+ if (!argvPath) {
24
+ return false;
25
+ }
26
+ const modulePath = fileURLToPath(metaUrl);
27
+ try {
28
+ return realpathSync(modulePath) === realpathSync(argvPath);
29
+ }
30
+ catch {
31
+ return modulePath === argvPath;
32
+ }
33
+ }
34
+ export async function runCli(args = process.argv.slice(2)) {
35
+ const forceTui = args.includes('--tui');
36
+ const forcePlain = args.includes('--plain');
37
+ const commandArgs = args.filter(arg => arg !== '--tui' && arg !== '--plain');
38
+ const [command] = commandArgs;
39
+ if (!command || command === 'repl' || command === 'chat' || command === 'code') {
40
+ if (forceTui || (!forcePlain && shouldUseTui())) {
41
+ await startTui();
42
+ }
43
+ else {
44
+ await startRepl();
45
+ }
46
+ return 0;
47
+ }
48
+ if (command === 'resume') {
49
+ const sessionId = commandArgs[1];
50
+ if (!sessionId) {
51
+ console.error('Usage: remi resume <session-id>');
52
+ return 1;
53
+ }
54
+ const cwd = process.cwd();
55
+ if (!sessionExists(cwd, sessionId)) {
56
+ console.error(`Session not found: ${sessionId}`);
57
+ return 1;
58
+ }
59
+ if (forceTui || (!forcePlain && shouldUseTui())) {
60
+ await startTui({ cwd, sessionId });
61
+ }
62
+ else {
63
+ await startRepl({ cwd, sessionId });
64
+ }
65
+ return 0;
66
+ }
67
+ if (command === '--help' || command === '-h' || command === 'help') {
68
+ console.log(formatHelp());
69
+ return 0;
70
+ }
71
+ if (command === '--version' || command === '-v' || command === '-V' || command === 'version') {
72
+ console.log(version);
73
+ return 0;
74
+ }
75
+ if (command === 'doctor') {
76
+ const report = runDoctor();
77
+ console.log(formatDoctor(report));
78
+ return report.ok ? 0 : 1;
79
+ }
80
+ if (command === 'model') {
81
+ const result = runModelCommand(commandArgs.slice(1));
82
+ console.log(result.output);
83
+ return result.code;
84
+ }
85
+ if (command === 'setup') {
86
+ const result = runSetupCommand(commandArgs.slice(1));
87
+ console.log(result.output);
88
+ return result.code;
89
+ }
90
+ console.error(`Unknown command: ${command}`);
91
+ console.error('Run remi --help for usage.');
92
+ return 1;
93
+ }
94
+ if (isDirectCliEntrypoint(import.meta.url)) {
95
+ const exitCode = await runCli();
96
+ process.exitCode = exitCode;
97
+ }
@@ -0,0 +1,17 @@
1
+ export function buildInitPrompt() {
2
+ return `Set up a minimal AGENTS.md for this repository. AGENTS.md is loaded into future Remi sessions, so keep it concise and only include information that would prevent mistakes.
3
+
4
+ Work in this order:
5
+
6
+ 1. Explore the repository before writing. Read key files such as README, package manifests, workspace config, build/test config, Makefile, CI config, and any existing AI-agent instruction files: AGENTS.md, AGENT.md, CLAUDE.md, REMI.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, or .clinerules.
7
+ 2. Identify only non-obvious guidance: build/test/lint commands, package manager/runtime requirements, architecture boundaries, repo workflow, local setup gotchas, testing quirks, generated files, security rules, or user/team preferences already documented in the repo.
8
+ 3. If AGENTS.md already exists, read it first and update it narrowly. Do not silently overwrite existing guidance.
9
+ 4. Create or update AGENTS.md at the project root with a short, practical structure. Start it with:
10
+
11
+ # AGENTS.md
12
+
13
+ This file provides guidance to Remi when working in this repository.
14
+
15
+ 5. Exclude generic advice, obvious commands directly inferable from manifests, file-by-file inventories, long tutorials, secrets, API keys, tokens, credentials, and anything likely to go stale unless it references a source file to check.
16
+ 6. After writing, briefly summarize what was added or changed and mention any uncertainty that needs user confirmation.`;
17
+ }
package/dist/model.js ADDED
@@ -0,0 +1,116 @@
1
+ import { loadRemiConfig, modelRoles } from '@remi/config';
2
+ import { createModelRouter } from '@remi/llm';
3
+ export function runModelCommand(args, cwd = process.cwd()) {
4
+ const subcommand = args[0] ?? 'list';
5
+ const loaded = loadRemiConfig({ cwd });
6
+ const router = createModelRouter(loaded.config);
7
+ if (subcommand === 'list') {
8
+ return { code: 0, output: formatModelList(loaded, router) };
9
+ }
10
+ if (subcommand === 'providers') {
11
+ return { code: 0, output: formatProviderList(loaded, router) };
12
+ }
13
+ if (subcommand === 'profiles') {
14
+ return { code: 0, output: formatProfileList(loaded) };
15
+ }
16
+ if (subcommand === 'doctor') {
17
+ return { code: 0, output: formatModelDoctor(loaded, router) };
18
+ }
19
+ return {
20
+ code: 1,
21
+ output: `Unknown model command: ${subcommand}\nRun remi model list, providers, profiles, or doctor.`,
22
+ };
23
+ }
24
+ export function formatModelList(loaded, router) {
25
+ const lines = ['Remi models', '', `active profile: ${loaded.config.activeProfile ?? 'unset'}`, ''];
26
+ const models = router.models.list();
27
+ if (models.length === 0) {
28
+ return [...lines, 'No models configured.'].join('\n');
29
+ }
30
+ lines.push('alias name provider model ctx tools reasoning used by');
31
+ for (const model of models) {
32
+ lines.push([
33
+ pad(model.alias, 18),
34
+ pad(model.displayName ?? model.alias, 22),
35
+ pad(model.provider, 10),
36
+ pad(model.model, 21),
37
+ pad(formatContext(model.contextWindow), 9),
38
+ pad(model.supportsTools ? 'yes' : 'no', 7),
39
+ pad(model.supportsReasoning ? 'yes' : 'no', 10),
40
+ model.usedBy.join(', ') || '-',
41
+ ].join(''));
42
+ }
43
+ return lines.join('\n');
44
+ }
45
+ export function formatProviderList(_loaded, router) {
46
+ const providers = router.providers.list();
47
+ const lines = ['Remi model providers', ''];
48
+ if (providers.length === 0) {
49
+ return [...lines, 'No providers configured.'].join('\n');
50
+ }
51
+ lines.push('alias type key baseURL');
52
+ for (const provider of providers) {
53
+ lines.push([
54
+ pad(provider.alias, 12),
55
+ pad(provider.type, 22),
56
+ pad(provider.apiKeyStatus, 12),
57
+ provider.baseURL,
58
+ ].join(''));
59
+ }
60
+ return lines.join('\n');
61
+ }
62
+ export function formatProfileList(loaded) {
63
+ const profiles = Object.entries(loaded.config.profiles ?? {});
64
+ const lines = ['Remi model profiles', '', `active profile: ${loaded.config.activeProfile ?? 'unset'}`, ''];
65
+ if (profiles.length === 0) {
66
+ return [...lines, 'No profiles configured.'].join('\n');
67
+ }
68
+ for (const [name, profile] of profiles) {
69
+ lines.push(`${name}${name === loaded.config.activeProfile ? ' (active)' : ''}`);
70
+ if (profile.extends) {
71
+ lines.push(` extends: ${profile.extends}`);
72
+ }
73
+ if (profile.effort) {
74
+ lines.push(` effort: ${profile.effort}`);
75
+ }
76
+ for (const role of modelRoles) {
77
+ const alias = profile.roles[role];
78
+ if (alias) {
79
+ lines.push(` ${role}: ${alias}`);
80
+ }
81
+ }
82
+ }
83
+ return lines.join('\n');
84
+ }
85
+ export function formatModelDoctor(loaded, router) {
86
+ const lines = ['Remi model doctor', ''];
87
+ lines.push(`active profile: ${loaded.config.activeProfile ?? 'unset'}`);
88
+ lines.push(`providers: ${router.providers.list().length}`);
89
+ lines.push(`models: ${router.models.list().length}`);
90
+ const providerIssues = router.providers
91
+ .list()
92
+ .filter(provider => provider.apiKeyStatus === 'missing')
93
+ .map(provider => provider.alias);
94
+ if (providerIssues.length > 0) {
95
+ lines.push(`missing API key: ${providerIssues.join(', ')}`);
96
+ }
97
+ else {
98
+ lines.push('API keys: configured');
99
+ }
100
+ for (const diagnostic of loaded.diagnostics) {
101
+ lines.push(`${diagnostic.level}: ${diagnostic.message}`);
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+ function pad(value, length) {
106
+ return value.length >= length ? `${value} ` : value.padEnd(length);
107
+ }
108
+ function formatContext(contextWindow) {
109
+ if (contextWindow >= 1_000_000) {
110
+ return `${contextWindow / 1_000_000}M`;
111
+ }
112
+ if (contextWindow >= 1_000) {
113
+ return `${contextWindow / 1_000}K`;
114
+ }
115
+ return String(contextWindow);
116
+ }
@@ -0,0 +1,34 @@
1
+ import { loadRemiConfig, readProjectRemiConfig, writeProjectRemiConfig } from '@remi/config';
2
+ import { createModelRouter } from '@remi/llm';
3
+ export function saveProjectMainModel(cwd, modelAlias) {
4
+ const loaded = loadRemiConfig({ cwd });
5
+ const router = createModelRouter(loaded.config);
6
+ if (!router.models.get(modelAlias)) {
7
+ throw new Error(`Unknown model alias: ${modelAlias}`);
8
+ }
9
+ const activeProfile = router.getActiveProfile();
10
+ const projectConfig = readProjectRemiConfig(cwd);
11
+ const existingProfile = projectConfig.profiles?.[activeProfile];
12
+ writeProjectRemiConfig({
13
+ ...projectConfig,
14
+ activeProfile: projectConfig.activeProfile ?? activeProfile,
15
+ profiles: {
16
+ ...projectConfig.profiles,
17
+ [activeProfile]: {
18
+ ...existingProfile,
19
+ roles: {
20
+ ...(existingProfile?.roles ?? {}),
21
+ main: modelAlias,
22
+ },
23
+ },
24
+ },
25
+ }, cwd);
26
+ }
27
+ export function resolveMainModelContextWindow(cwd) {
28
+ try {
29
+ return createModelRouter(loadRemiConfig({ cwd }).config).resolve('main').contextWindow;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
@@ -0,0 +1,46 @@
1
+ import { t } from './i18n.js';
2
+ export function formatPermissionFileAction(request, language) {
3
+ return formatPermissionFileActionFromParts(request.toolName, request.inputSummary, language, request.targetPath);
4
+ }
5
+ export function formatPermissionFileActionFromParts(toolName, inputSummary, language, targetPath) {
6
+ const path = targetPath ?? permissionRequestPath(inputSummary);
7
+ if (!path) {
8
+ return undefined;
9
+ }
10
+ const directory = permissionRequestDirectory(path, language);
11
+ if (toolName === 'write_file' || toolName === 'edit_file') {
12
+ return {
13
+ action: t(language, 'permission.request.action.modifyFilesIn', { directory }),
14
+ detail: t(language, 'permission.request.action.target', { path }),
15
+ };
16
+ }
17
+ return undefined;
18
+ }
19
+ function permissionRequestPath(inputSummary) {
20
+ try {
21
+ const parsed = JSON.parse(inputSummary);
22
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
23
+ return undefined;
24
+ }
25
+ const path = parsed['path'];
26
+ return typeof path === 'string' && path.length > 0 ? path : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function permissionRequestDirectory(path, language) {
33
+ const normalized = path.replace(/\\/g, '/');
34
+ const lastSeparator = normalized.lastIndexOf('/');
35
+ if (lastSeparator < 0) {
36
+ return t(language, 'permission.request.path.currentDirectory');
37
+ }
38
+ if (lastSeparator === 0) {
39
+ return '/';
40
+ }
41
+ const directory = normalized.slice(0, lastSeparator);
42
+ if (!directory || directory === '.') {
43
+ return t(language, 'permission.request.path.currentDirectory');
44
+ }
45
+ return normalized.startsWith('/') ? directory : `${directory}/`;
46
+ }
@@ -0,0 +1,206 @@
1
+ import { normalizePermissionProfile, permissionProfiles, readProjectRemiConfig, transitionPermissionProfile, writeProjectRemiConfig, loadRemiConfig, } from '@remi/config';
2
+ import { resolve } from 'node:path';
3
+ import { t } from './i18n.js';
4
+ export function permissionProfileOptions(language) {
5
+ return permissionProfiles.map(profile => ({
6
+ id: profile,
7
+ label: permissionProfileLabel(profile, language),
8
+ description: permissionProfileDescription(profile, language),
9
+ }));
10
+ }
11
+ export function permissionProfileLabel(profile, language) {
12
+ return t(language, permissionProfileLabelKeys[profile]);
13
+ }
14
+ export function permissionProfileDescription(profile, language) {
15
+ return t(language, permissionProfileDescriptionKeys[profile]);
16
+ }
17
+ const permissionProfileLabelKeys = {
18
+ default: 'permissions.profile.default.label',
19
+ 'auto-review': 'permissions.profile.auto-review.label',
20
+ 'full-access': 'permissions.profile.full-access.label',
21
+ };
22
+ const permissionProfileDescriptionKeys = {
23
+ default: 'permissions.profile.default.description',
24
+ 'auto-review': 'permissions.profile.auto-review.description',
25
+ 'full-access': 'permissions.profile.full-access.description',
26
+ };
27
+ export function parsePermissionProfileArg(value) {
28
+ if (!value) {
29
+ return undefined;
30
+ }
31
+ const normalized = value.trim().toLowerCase();
32
+ if (['default', 'normal', 'safe', '默认'].includes(normalized)) {
33
+ return 'default';
34
+ }
35
+ if (['auto-review', 'autoreview', 'auto', 'review', '自动审查'].includes(normalized)) {
36
+ return 'auto-review';
37
+ }
38
+ if (['full-access', 'fullaccess', 'full', 'bypass', 'danger', '完全访问'].includes(normalized)) {
39
+ return 'full-access';
40
+ }
41
+ return undefined;
42
+ }
43
+ export function resolveConfiguredPermissionProfile(cwd) {
44
+ try {
45
+ return normalizePermissionProfile(loadRemiConfig({ cwd }).config.permissions);
46
+ }
47
+ catch {
48
+ return 'default';
49
+ }
50
+ }
51
+ export function saveProjectPermissionProfile(cwd, profile) {
52
+ const config = readProjectRemiConfig(cwd);
53
+ writeProjectRemiConfig(transitionPermissionProfile(config, profile), cwd);
54
+ }
55
+ export function formatPermissionProfileChanged(profile, language) {
56
+ return t(language, 'permissions.saved', { label: permissionProfileLabel(profile, language) });
57
+ }
58
+ export function formatPermissionProfileList(active, language) {
59
+ return permissionProfileOptions(language)
60
+ .map(option => `${option.id === active ? '*' : ' '} ${option.label}: ${option.description}`)
61
+ .join('\n');
62
+ }
63
+ export function saveProjectPermissionRule(cwd, ruleOrPrefix) {
64
+ const rule = typeof ruleOrPrefix === 'string' ? { kind: 'shell-prefix', prefix: ruleOrPrefix } : ruleOrPrefix;
65
+ const config = readProjectRemiConfig(cwd);
66
+ const currentRules = config.permissions?.allow ?? [];
67
+ const nextRule = normalizePermissionRule(rule, cwd);
68
+ if (!nextRule) {
69
+ return;
70
+ }
71
+ const nextRules = mergePermissionRule(currentRules, nextRule);
72
+ if (nextRules === currentRules) {
73
+ return;
74
+ }
75
+ writeProjectRemiConfig({
76
+ ...config,
77
+ permissions: {
78
+ ...config.permissions,
79
+ allow: nextRules,
80
+ },
81
+ }, cwd);
82
+ }
83
+ export const saveProjectShellPermissionRule = saveProjectPermissionRule;
84
+ export function addSessionPermissionRules(currentRules, cwd, rules) {
85
+ let nextRules = currentRules;
86
+ for (const rule of rules) {
87
+ const normalized = normalizePermissionRule(rule, cwd);
88
+ if (!normalized) {
89
+ continue;
90
+ }
91
+ nextRules = mergePermissionRule(nextRules, normalized);
92
+ }
93
+ return nextRules;
94
+ }
95
+ export function formatPermissionRuleScope(rule, language) {
96
+ if (rule.kind === 'filesystem-write-root') {
97
+ const operationLabel = formatFilesystemOperations(rule.operations, language);
98
+ if (operationLabel) {
99
+ return t(language, 'permission.rule.filesystemOperations', { operations: operationLabel, root: rule.root });
100
+ }
101
+ return t(language, 'permission.rule.filesystem', { root: rule.root });
102
+ }
103
+ if (rule.cwd) {
104
+ return t(language, 'permission.rule.scoped', { prefix: rule.prefix, cwd: rule.cwd });
105
+ }
106
+ return t(language, 'permission.rule.prefix', { prefix: rule.prefix });
107
+ }
108
+ export function formatPermissionRulesScope(rules, language) {
109
+ if (rules.length === 1 && rules[0]) {
110
+ return formatPermissionRuleScope(rules[0], language);
111
+ }
112
+ if (rules.every(rule => rule.kind === 'shell-prefix')) {
113
+ const firstCwd = rules[0]?.cwd;
114
+ const sameCwd = firstCwd && rules.every(rule => rule.kind === 'shell-prefix' && rule.cwd === firstCwd);
115
+ const prefixes = rules.map(rule => (rule.kind === 'shell-prefix' ? `\`${rule.prefix}\`` : '')).join(', ');
116
+ if (sameCwd) {
117
+ return t(language, 'permission.rule.multipleScoped', { prefixes, cwd: firstCwd });
118
+ }
119
+ return t(language, 'permission.rule.multiple', { prefixes });
120
+ }
121
+ return rules.map(rule => formatPermissionRuleScope(rule, language)).join(', ');
122
+ }
123
+ function normalizePermissionRule(rule, cwd) {
124
+ if (rule.kind === 'shell-prefix') {
125
+ const normalizedPrefix = rule.prefix.trim();
126
+ if (!normalizedPrefix) {
127
+ return undefined;
128
+ }
129
+ const normalizedCwd = typeof rule.cwd === 'string' && rule.cwd.trim().length > 0 ? rule.cwd.trim() : undefined;
130
+ return {
131
+ kind: 'shell-prefix',
132
+ prefix: normalizedPrefix,
133
+ ...(normalizedCwd ? { cwd: normalizedCwd } : {}),
134
+ createdAt: new Date().toISOString(),
135
+ };
136
+ }
137
+ const root = rule.root.trim();
138
+ if (!root) {
139
+ return undefined;
140
+ }
141
+ const operations = normalizeFilesystemOperations(rule.operations);
142
+ return {
143
+ kind: 'filesystem-write-root',
144
+ root: resolve(cwd, root),
145
+ ...(operations.length > 0 ? { operations } : {}),
146
+ createdAt: new Date().toISOString(),
147
+ };
148
+ }
149
+ function mergePermissionRule(currentRules, nextRule) {
150
+ if (nextRule.kind === 'shell-prefix') {
151
+ if (currentRules.some(existing => existing.kind === 'shell-prefix' &&
152
+ existing.prefix === nextRule.prefix &&
153
+ (existing.cwd ?? undefined) === (nextRule.cwd ?? undefined))) {
154
+ return currentRules;
155
+ }
156
+ return [...currentRules, nextRule];
157
+ }
158
+ const existingIndex = currentRules.findIndex(existing => existing.kind === 'filesystem-write-root' && existing.root === nextRule.root);
159
+ if (existingIndex < 0) {
160
+ return [...currentRules, nextRule];
161
+ }
162
+ const existing = currentRules[existingIndex];
163
+ if (!existing || existing.kind !== 'filesystem-write-root') {
164
+ return currentRules;
165
+ }
166
+ const mergedOperations = normalizeFilesystemOperations([...(existing.operations ?? []), ...(nextRule.operations ?? [])]);
167
+ const existingOperations = normalizeFilesystemOperations(existing.operations);
168
+ if (existingOperations.join('\0') === mergedOperations.join('\0')) {
169
+ return currentRules;
170
+ }
171
+ const mergedRule = {
172
+ ...existing,
173
+ operations: mergedOperations,
174
+ };
175
+ const nextRules = [...currentRules];
176
+ nextRules[existingIndex] = mergedRule;
177
+ return nextRules;
178
+ }
179
+ function normalizeFilesystemOperations(operations) {
180
+ const order = ['create', 'write', 'edit', 'delete'];
181
+ const values = Array.isArray(operations) ? operations : [];
182
+ return order.filter(operation => values.includes(operation));
183
+ }
184
+ function formatFilesystemOperations(operations, language) {
185
+ const normalized = normalizeFilesystemOperations(operations);
186
+ if (normalized.length === 0 || normalized.length === 4) {
187
+ return '';
188
+ }
189
+ if (normalized.length === 2 && normalized.includes('write') && normalized.includes('edit')) {
190
+ return language === 'zh-Hans' ? '修改' : 'modifying';
191
+ }
192
+ const labels = language === 'zh-Hans'
193
+ ? {
194
+ create: '创建',
195
+ write: '写入',
196
+ edit: '编辑',
197
+ delete: '删除',
198
+ }
199
+ : {
200
+ create: 'creating',
201
+ write: 'writing',
202
+ edit: 'editing',
203
+ delete: 'deleting',
204
+ };
205
+ return normalized.map(operation => labels[operation]).join(language === 'zh-Hans' ? '、' : ', ');
206
+ }