From 1ac3a7f689bd1a39bd6884c76bfb7d96e4442f43 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:38:19 +0300 Subject: [PATCH 1/2] Add ZipCLI as a built-in plugin (agent tool over the open project) Brings ZipCLI's command engine into CodePrimer and exposes it to the in-app AI agent as a `zipcli` tool that runs find / search / replace / zip / unzip commands and scripts over the open project (mounted at /project). - services/zipcli-core: ZipCLI's platform-free sources, copied verbatim and re-syncable with sync-from-zipcli.sh, plus ZipCliProject (project-root glue) - services/zipcli-plugin: ZipCliPlugin (Settings > Plugins toggle) and the permission-gated ZipCliAgentTool; in-app chat only, not the MCP server - Shared files touched: settings.gradle.kts, ide-core build files, BuiltInPlugins.kt, AgentBackend.kt (see docs/zipcli.md) Not compiled or tested in the authoring environment (no Kotlin toolchain). Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01QBhkGVZrBjstLbwTeULaRQ --- app/ide-core/build.gradle.kts | 1 + app/ide-core/module.toml | 2 +- .../kotlin/dev/ide/core/agent/AgentBackend.kt | 11 +- .../dev/ide/core/plugins/BuiltInPlugins.kt | 3 + docs/zipcli.md | 63 ++ services/zipcli-core/CHANGELOG.md | 82 +++ services/zipcli-core/build.gradle.kts | 11 + services/zipcli-core/docs/MCP.md | 138 ++++ services/zipcli-core/docs/SCRIPTING.md | 112 ++++ services/zipcli-core/examples/backup.zcli | 9 + services/zipcli-core/module.toml | 14 + .../com/zipcli/ke/domain/Autocomplete.kt | 201 ++++++ .../com/zipcli/ke/domain/CommandParser.kt | 174 ++++++ .../com/zipcli/ke/domain/CommandRegistry.kt | 17 + .../com/zipcli/ke/domain/CommandResult.kt | 33 + .../com/zipcli/ke/domain/CommandSpec.kt | 42 ++ .../kotlin/com/zipcli/ke/domain/ErrorCodes.kt | 51 ++ .../main/kotlin/com/zipcli/ke/domain/Glob.kt | 80 +++ .../main/kotlin/com/zipcli/ke/domain/Lexer.kt | 193 ++++++ .../com/zipcli/ke/domain/ParsedCommand.kt | 15 + .../com/zipcli/ke/domain/ShellSession.kt | 52 ++ .../kotlin/com/zipcli/ke/domain/Suggest.kt | 83 +++ .../ke/domain/commands/ArchiveCommands.kt | 143 +++++ .../ke/domain/commands/CommandSupport.kt | 199 ++++++ .../ke/domain/commands/ContentCommands.kt | 542 ++++++++++++++++ .../zipcli/ke/domain/commands/CoreCommands.kt | 588 ++++++++++++++++++ .../ke/domain/commands/ScriptCommands.kt | 203 ++++++ .../zipcli/ke/domain/commands/TextMatch.kt | 389 ++++++++++++ .../zipcli/ke/domain/script/ScriptParser.kt | 277 +++++++++ .../zipcli/ke/domain/script/ScriptRunner.kt | 470 ++++++++++++++ .../main/kotlin/com/zipcli/ke/fs/FileEntry.kt | 9 + .../kotlin/com/zipcli/ke/fs/NioFileSystem.kt | 160 +++++ .../com/zipcli/ke/fs/TerminalFileSystem.kt | 27 + .../kotlin/com/zipcli/ke/fs/VirtualPath.kt | 25 + .../main/kotlin/com/zipcli/ke/zip/Unzipper.kt | 107 ++++ .../main/kotlin/com/zipcli/ke/zip/Zipper.kt | 138 ++++ .../kotlin/dev/ide/zipcli/ZipCliProject.kt | 169 +++++ services/zipcli-core/sync-from-zipcli.sh | 33 + services/zipcli-core/tests/McpTests.kt | 252 ++++++++ services/zipcli-core/tests/MultilineTests.kt | 254 ++++++++ .../zipcli-core/tests/RecommendationTests.kt | 275 ++++++++ services/zipcli-core/tests/ScenarioTests.kt | 219 +++++++ services/zipcli-core/tests/TranscriptTests.kt | 47 ++ services/zipcli-core/tests/run.sh | 18 + .../zipcli-core/tests/stubs/Coroutines.kt | 17 + .../transcripts/01-escaping-matrix.expected | 33 + .../tests/transcripts/01-escaping-matrix.zcli | 13 + .../tests/transcripts/02-heredoc.expected | 7 + .../tests/transcripts/02-heredoc.zcli | 9 + .../transcripts/03-failure-footers.expected | 5 + .../tests/transcripts/03-failure-footers.zcli | 4 + .../transcripts/04-confirmation-stop.expected | 5 + .../transcripts/04-confirmation-stop.zcli | 5 + .../05-unterminated-quote.expected | 4 + .../transcripts/05-unterminated-quote.zcli | 5 + .../transcripts/06-shell-syntax.expected | 6 + .../tests/transcripts/06-shell-syntax.zcli | 6 + .../transcripts/07-regex-and-find.expected | 11 + .../tests/transcripts/07-regex-and-find.zcli | 5 + .../transcripts/08-errors-and-hints.expected | 12 + .../transcripts/08-errors-and-hints.zcli | 11 + .../tests/transcripts/09-check.expected | 7 + .../tests/transcripts/09-check.zcli | 8 + services/zipcli-plugin/build.gradle.kts | 14 + services/zipcli-plugin/module.toml | 15 + .../dev/ide/zipcli/plugin/ZipCliAgentTool.kt | 101 +++ .../dev/ide/zipcli/plugin/ZipCliPlugin.kt | 28 + settings.gradle.kts | 3 + 68 files changed, 6263 insertions(+), 2 deletions(-) create mode 100644 docs/zipcli.md create mode 100644 services/zipcli-core/CHANGELOG.md create mode 100644 services/zipcli-core/build.gradle.kts create mode 100644 services/zipcli-core/docs/MCP.md create mode 100644 services/zipcli-core/docs/SCRIPTING.md create mode 100644 services/zipcli-core/examples/backup.zcli create mode 100644 services/zipcli-core/module.toml create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Autocomplete.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandParser.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandRegistry.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandResult.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandSpec.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ErrorCodes.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Glob.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Lexer.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ParsedCommand.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ShellSession.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Suggest.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ArchiveCommands.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CommandSupport.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ContentCommands.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CoreCommands.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ScriptCommands.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/TextMatch.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/script/ScriptParser.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/script/ScriptRunner.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/fs/FileEntry.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/fs/NioFileSystem.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/fs/TerminalFileSystem.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/fs/VirtualPath.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/zip/Unzipper.kt create mode 100644 services/zipcli-core/src/main/kotlin/com/zipcli/ke/zip/Zipper.kt create mode 100644 services/zipcli-core/src/main/kotlin/dev/ide/zipcli/ZipCliProject.kt create mode 100755 services/zipcli-core/sync-from-zipcli.sh create mode 100644 services/zipcli-core/tests/McpTests.kt create mode 100644 services/zipcli-core/tests/MultilineTests.kt create mode 100644 services/zipcli-core/tests/RecommendationTests.kt create mode 100644 services/zipcli-core/tests/ScenarioTests.kt create mode 100644 services/zipcli-core/tests/TranscriptTests.kt create mode 100644 services/zipcli-core/tests/run.sh create mode 100644 services/zipcli-core/tests/stubs/Coroutines.kt create mode 100644 services/zipcli-core/tests/transcripts/01-escaping-matrix.expected create mode 100644 services/zipcli-core/tests/transcripts/01-escaping-matrix.zcli create mode 100644 services/zipcli-core/tests/transcripts/02-heredoc.expected create mode 100644 services/zipcli-core/tests/transcripts/02-heredoc.zcli create mode 100644 services/zipcli-core/tests/transcripts/03-failure-footers.expected create mode 100644 services/zipcli-core/tests/transcripts/03-failure-footers.zcli create mode 100644 services/zipcli-core/tests/transcripts/04-confirmation-stop.expected create mode 100644 services/zipcli-core/tests/transcripts/04-confirmation-stop.zcli create mode 100644 services/zipcli-core/tests/transcripts/05-unterminated-quote.expected create mode 100644 services/zipcli-core/tests/transcripts/05-unterminated-quote.zcli create mode 100644 services/zipcli-core/tests/transcripts/06-shell-syntax.expected create mode 100644 services/zipcli-core/tests/transcripts/06-shell-syntax.zcli create mode 100644 services/zipcli-core/tests/transcripts/07-regex-and-find.expected create mode 100644 services/zipcli-core/tests/transcripts/07-regex-and-find.zcli create mode 100644 services/zipcli-core/tests/transcripts/08-errors-and-hints.expected create mode 100644 services/zipcli-core/tests/transcripts/08-errors-and-hints.zcli create mode 100644 services/zipcli-core/tests/transcripts/09-check.expected create mode 100644 services/zipcli-core/tests/transcripts/09-check.zcli create mode 100644 services/zipcli-plugin/build.gradle.kts create mode 100644 services/zipcli-plugin/module.toml create mode 100644 services/zipcli-plugin/src/main/kotlin/dev/ide/zipcli/plugin/ZipCliAgentTool.kt create mode 100644 services/zipcli-plugin/src/main/kotlin/dev/ide/zipcli/plugin/ZipCliPlugin.kt diff --git a/app/ide-core/build.gradle.kts b/app/ide-core/build.gradle.kts index 508f2d1c74..13b83233ef 100644 --- a/app/ide-core/build.gradle.kts +++ b/app/ide-core/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(project(":agent-ui")) // the agent's Compose UI plugin (AgentUiPlugin) — paired with AgentPlugin in BuiltInPlugins implementation(project(":vcs-impl")) // the Git engine (JGit working copy + the GitHub client + the account store) implementation(project(":vcs-ui")) // the version-control Compose UI plugin (VcsUiPlugin) — paired with VcsPlugin in BuiltInPlugins + implementation(project(":zipcli-plugin")) // ZipCLI: the engine plugin + the `zipcli` agent tool (see AgentBackend) // Opt-in usage analytics. `api` because AnalyticsService appears in IdeServicesBackend's (public) // constructor signature, so a host wiring it (ide-android) needs the type on its compile classpath. api(project(":analytics-api")) diff --git a/app/ide-core/module.toml b/app/ide-core/module.toml index 0e6aa812ea..20bbe9316e 100644 --- a/app/ide-core/module.toml +++ b/app/ide-core/module.toml @@ -17,5 +17,5 @@ java = ["src/test/kotlin"] [dependencies] api = [{ module = "ide-ui" }, { module = "interp-api" }, { module = "layout-preview-api" }, { module = "analytics-api" }, { module = "store-api" }] -implementation = [{ module = "platform-core" }, { module = "project-model-api" }, { module = "project-model-impl" }, { module = "language-api" }, { module = "lang-jdt" }, { module = "lang-java" }, { module = "lang-xml" }, { module = "lang-kotlin" }, { module = "lang-ksp" }, { module = "interp-impl" }, { module = "decompiler" }, { module = "index-api" }, { module = "index-impl" }, { module = "analysis-api" }, { module = "analysis-impl" }, { module = "block-api" }, { module = "block-impl" }, { module = "plugin-api" }, { module = "plugin-impl" }, { module = "build-api" }, { module = "build-engine" }, { module = "jvm-build" }, { module = "android-support" }, { module = "layout-preview-impl" }, { module = "deps-api" }, { module = "deps-impl" }, { module = "agent-impl" }, { module = "agent-mcp" }, { module = "agent-ui" }, { module = "vcs-impl" }, { module = "vcs-ui" }, { module = "store-impl" }, { module = "vfs-api" }, "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1", "kotlin-stdlib"] +implementation = [{ module = "platform-core" }, { module = "project-model-api" }, { module = "project-model-impl" }, { module = "language-api" }, { module = "lang-jdt" }, { module = "lang-java" }, { module = "lang-xml" }, { module = "lang-kotlin" }, { module = "lang-ksp" }, { module = "interp-impl" }, { module = "decompiler" }, { module = "index-api" }, { module = "index-impl" }, { module = "analysis-api" }, { module = "analysis-impl" }, { module = "block-api" }, { module = "block-impl" }, { module = "plugin-api" }, { module = "plugin-impl" }, { module = "build-api" }, { module = "build-engine" }, { module = "jvm-build" }, { module = "android-support" }, { module = "layout-preview-impl" }, { module = "deps-api" }, { module = "deps-impl" }, { module = "agent-impl" }, { module = "agent-mcp" }, { module = "agent-ui" }, { module = "vcs-impl" }, { module = "vcs-ui" }, { module = "zipcli-plugin" }, { module = "store-impl" }, { module = "vfs-api" }, "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1", "kotlin-stdlib"] testImplementation = [{ platform = "org.junit:junit-bom:5.11.4" }, "org.jetbrains.kotlin:kotlin-test:2.4.0", { module = "test-support" }, "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0", { module = "store-impl" }, { module = "bench-support" }, "org.bouncycastle:bcpkix-jdk18on:1.78.1"] diff --git a/app/ide-core/src/main/kotlin/dev/ide/core/agent/AgentBackend.kt b/app/ide-core/src/main/kotlin/dev/ide/core/agent/AgentBackend.kt index 36e35d6113..4a89556584 100644 --- a/app/ide-core/src/main/kotlin/dev/ide/core/agent/AgentBackend.kt +++ b/app/ide-core/src/main/kotlin/dev/ide/core/agent/AgentBackend.kt @@ -34,6 +34,8 @@ import dev.ide.agent.toolSchema import dev.ide.core.BackendContext import dev.ide.core.IdeServices import dev.ide.platform.log.Log +import dev.ide.zipcli.plugin.ZipCliAgentTool +import dev.ide.zipcli.plugin.ZipCliPlugin import dev.ide.ui.backend.AgentService import dev.ide.ui.backend.UiAgentAttachment import dev.ide.ui.backend.UiAgentAttachmentKind @@ -128,7 +130,14 @@ internal class AgentBackend(private val ctx: BackendContext) : AgentService { } } - private val tools = SimpleToolRegistry(builtinTools(checkpoints) + ftpControlTool) + /** ZipCLI over the open project (see the ZipCLI plugin). In-app chat only: deliberately absent from + * [mcpTools], whose allow-all gate would hand a remote client a delete/overwrite command line. */ + private val zipCliTool = ZipCliAgentTool( + root = { ctx.servicesOrNull?.workspaceRoot }, + enabled = { ctx.manager?.env?.pluginCatalog?.isEnabled(ZipCliPlugin.ID) != false }, + ) + + private val tools = SimpleToolRegistry(builtinTools(checkpoints) + ftpControlTool + zipCliTool) /** The MCP server's tools write straight to the workspace: an external client's edits are not a chat turn, * and recording them into one would let that turn's Undo revert someone else's work. */ diff --git a/app/ide-core/src/main/kotlin/dev/ide/core/plugins/BuiltInPlugins.kt b/app/ide-core/src/main/kotlin/dev/ide/core/plugins/BuiltInPlugins.kt index 80a09869a1..7a45db40a6 100644 --- a/app/ide-core/src/main/kotlin/dev/ide/core/plugins/BuiltInPlugins.kt +++ b/app/ide-core/src/main/kotlin/dev/ide/core/plugins/BuiltInPlugins.kt @@ -1,6 +1,7 @@ package dev.ide.core.plugins import dev.ide.agent.ui.AgentUiPlugin +import dev.ide.zipcli.plugin.ZipCliPlugin import dev.ide.analysis.ACTION_PROVIDER_EP import dev.ide.analysis.ANALYZER_EP import dev.ide.analysis.MODULE_ANALYSIS @@ -240,6 +241,8 @@ object BuiltInPlugins { BuiltInPlugin(AgentPlugin(), ui = AgentUiPlugin), // Version control: engine facet (settings page + VcsBackend wiring) + its Compose Git UI. BuiltInPlugin(VcsPlugin(), ui = VcsUiPlugin), + // ZipCLI: file / search-and-replace / ZIP commands the AI agent can run over the open project. + BuiltInPlugin(ZipCliPlugin()), ) } diff --git a/docs/zipcli.md b/docs/zipcli.md new file mode 100644 index 0000000000..ceefa50019 --- /dev/null +++ b/docs/zipcli.md @@ -0,0 +1,63 @@ +# ZipCLI in CodePrimer + +ZipCLI (the Android file / search-and-replace / ZIP command line, with its scripting language) is built in as a +plugin. The AI agent gets one extra tool, `zipcli`, that runs ZipCLI commands and scripts over the **open +project**; the project root appears as `/project`. Typical uses: bulk `replace` across a source tree, `find` / +`search` with ZipCLI's smart-case and regex handling, `zip` / `unzip` of assets or release bundles, and +re-runnable `.zcli` scripts (see `services/zipcli-core/docs/SCRIPTING.md` and `examples/backup.zcli`). + +## Layout + +| Path | What it is | Who edits it | +|---|---|---| +| `services/zipcli-core/src/main/kotlin/com/zipcli/` | ZipCLI's platform-free sources, copied verbatim | only `sync-from-zipcli.sh` | +| `services/zipcli-core/src/main/kotlin/dev/ide/zipcli/ZipCliProject.kt` | CodePrimer glue: mounts a project directory at `/project`, runs scripts non-interactively | us | +| `services/zipcli-core/tests/`, `docs/`, `examples/`, `CHANGELOG.md` | ZipCLI's own tests and docs, copied verbatim | only `sync-from-zipcli.sh` | +| `services/zipcli-plugin/` | `ZipCliPlugin` (identity, Settings > Plugins toggle) and `ZipCliAgentTool` | us | + +Not brought over: the Android app shell and UI, the SAF (folder-grant) file system and `saf` command, and the +`mcp` server commands. CodePrimer has its own agent MCP server, and projects are plain directories. + +## Shared files touched (the whole upstream-conflict surface) + +| File | Change | +|---|---| +| `settings.gradle.kts` | 2 `include(...)` lines + 1 line in the `"services"` group list | +| `app/ide-core/build.gradle.kts` | 1 dependency line | +| `app/ide-core/module.toml` | `{ module = "zipcli-plugin" }` in the `implementation` list | +| `app/ide-core/.../plugins/BuiltInPlugins.kt` | 1 import + 1 `BuiltInPlugin(ZipCliPlugin())` entry | +| `app/ide-core/.../agent/AgentBackend.kt` | 2 imports + the `zipCliTool` block + `+ zipCliTool` in `tools` | + +Everything else is new files, so a merge can only conflict in these five. If upstream edits one of those lines, +keep both sides: the ZipCLI lines are independent of whatever upstream added next to them. + +## Keeping up with upstream CodeAssist + +```sh +git remote add upstream https://github.com/tyron12233/CodeAssist.git # once +git fetch upstream +git merge upstream/main # or: git rebase upstream/main +``` + +## Keeping up with ZipCLI + +```sh +services/zipcli-core/sync-from-zipcli.sh /path/to/zipcli/app +git diff --stat services/zipcli-core # review what ZipCLI changed +services/zipcli-core/tests/run.sh # engine tests (needs kotlinc) +``` + +The script copies the same file set every time and stops with an error if a new ZipCLI file needs an Android +type, so the JVM module cannot silently break. + +## Behaviour and limits + +- **Permission-gated.** `zipcli` is a mutating tool, so every call goes through the agent's permission prompt. + It is offered to the in-app chat only, never to the MCP server (that server runs allow-all). +- **Confirmations.** A destructive step that asks for confirmation stops the batch before it; the agent is told + which command is waiting and repeats with `confirm=true` only if you asked for that action. +- **Not in Undo.** ZipCLI writes to disk directly, so its changes are not part of a chat turn's checkpoint, and an + open editor tab may need a reload. +- **Symlinks.** `/project` confines `..` paths, but a symlink inside the project that points outside it is + followed by the file layer. +- **Turning it off.** Settings > Plugins > ZipCLI removes the tool. diff --git a/services/zipcli-core/CHANGELOG.md b/services/zipcli-core/CHANGELOG.md new file mode 100644 index 0000000000..b1cfd3ec0a --- /dev/null +++ b/services/zipcli-core/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +## 1.1.0 — LLM-friendliness pass (DEV_RECOMMENDATIONS.md) + +**Breaking: single quotes are now literal (POSIX).** `'C:\new\table.txt'` is written as typed; previously `\n`, `\t`, `\\` were escapes inside `'...'` and silently corrupted writes. Only `\'` is special. Double quotes are unchanged. Use `"a\nb"` or a heredoc where the old behaviour was relied on. Failures now exit with 2 (usage) or 3 (not found) instead of always 1, and every error line carries a code (`[E_NOT_FOUND] ...`). + +P0 +- `find --regex` works again: the smart-case helper is a hand-written scanner (no helper regex to fail on Android's engine), and pattern errors are reported as `Invalid pattern`. +- Internal faults are `[E_INTERNAL] ... this is a bug, not your script`, never `Syntax error`. +- New heredoc write path: `create|update f <<'EOF' ... EOF` (byte-exact; `<<-` strips tabs). POSIX `'it'\''s'` accepted. Unterminated-quote errors quote the line with a caret. +- Shell prompts (`$ ls`) and bare operators get one diagnosis naming the four missing features. +- `history` defaults to the last 20; MCP commands are recorded. +- Deleted the empty `ui/TermanScreen.kt`. + +P1 +- Confirmation in a non-interactive run stops the batch before the prompt (`stopped at line N: 1 command needs confirmation, M command(s) not run`, status 4, MCP `pending_confirmation`). Global `--yes`/`-y` on `delete`, `set -y`. +- `--json` (find, search, list, cat, replace, help), `--count-only`, `--offset`, per-command MCP `report`, `check` / `check_only` dry run, `info [--json]`, `help --json`. +- `saf`/`mcp` over MCP explain they are app-only; `saf list` is available read-only. Tool description states the boundaries. + +P2 +- `list` expands wildcards; find/search explain they do not. `replace --fail-on-no-match`. `set --reset`. Unknown flags list the real ones; not-found errors list the folder; "-m 6" style mistakes name the flag. Truncation footers name the continuing flag. + +Tests: new `RecommendationTests` and golden transcripts (`tests/run.sh`). + +## Unreleased — hardening pass (behaviour fixes) + +Fixes are grouped by severity. No new dependencies were introduced, so module.toml is unchanged; +everything uses the Kotlin stdlib and the APIs already declared there. + +### Silent wrong behaviour fixed + +- Lexer: a lone `>`, `<`, `|` or `&` used as a word is now rejected with an explanation instead of + being handed to the command as text. Previously `echo hello > out.txt` printed "hello > out.txt" + and created nothing. +- Lexer: command substitution and arithmetic (dollar-paren forms) are now rejected with a hint to use + `set NAME=value`. Previously the text was passed through unchanged, so `echo \$((1+1))` printed + the expression literally. +- Metacharacters glued to other text stay literal, so regexes such as `a|b` keep working. + +### Semantics + +- delete: `-f` now also ignores paths that do not exist, matching POSIX `rm -f`. Backup scripts can + call `delete old.zip -f` without an existence guard. **Behaviour change.** +- zip: new `-o` / `--overwrite` replaces an existing archive (previously a hard error, which made + every second run of a backup script fail). +- find and search: a run with no matches now exits with status 1 (grep convention), so + `find TODO dir || echo none` takes the fallback branch. **Behaviour change.** +- CommandParser: a token that merely looks like a flag (`-- archive contents --`, `-3`) is treated as + a positional argument; only flag-shaped tokens are parsed as flags, so real typos still get a + "did you mean" hint that now also mentions the `--` escape. + +### Documentation + +- CommandSpec: new optional `exitStatus` field, rendered by `help `. + Documented for delete, find, replace, search, test, run, zip and unzip. +- help scripting and docs/SCRIPTING.md: sections on unsupported constructs, quoting, leading dashes + and exit status. +- create/update: the --content help now says single quotes keep text literal. +- examples/backup.zcli: uses zip -o so it can be re-run. + +### Tests + +- tests/ScenarioTests.kt: the errexit tests now use a plain `delete nothere` (the failing case), and + new checks cover idempotent delete -f, rejection of redirection/pipes/arithmetic, dash-prefixed + text, grep-style no-match status, and zip -o. + +### Not in this pass (proposed next steps) + +- zip -o deletes the old archive before writing the new one; an atomic temp-file + move would survive + a failure in the middle of a zip. +- if/elif/else and while: the parser only knows `for`; the `test A && cmd1 || cmd2` idiom is the + usual trap. Needs parser + runner nodes and tests. +- Arithmetic expansion proper (counting files and sizes) instead of only an error. +- stat / wc / tail style aggregation commands. +- `run --dry-run` file-system write plan. +- list -l: a legend row for the leading type column (deferred because tests assert that output). + +### Verification status + +The change set was reviewed by reading the modified sources, but this environment has no Kotlin +compiler or Gradle, so nothing was compiled or executed. Run the JVM test programs in tests/ in CI +before merging. diff --git a/services/zipcli-core/build.gradle.kts b/services/zipcli-core/build.gradle.kts new file mode 100644 index 0000000000..1bf38701f3 --- /dev/null +++ b/services/zipcli-core/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// zipcli-core — ZipCLI's pure-JVM command engine (parser, script runner, file / archive / search commands), +// mounted over a project directory. Everything under src/main/kotlin/com/zipcli is a verbatim copy of the +// ZipCLI app's platform-free sources (re-sync with tools/sync-zipcli.sh); only src/main/kotlin/dev/ide/zipcli +// is CodePrimer's own glue. No Android, Compose or IDE dependency, so it builds and tests on a plain JVM. +dependencies { + implementation(libs.kotlinx.coroutines.core) +} diff --git a/services/zipcli-core/docs/MCP.md b/services/zipcli-core/docs/MCP.md new file mode 100644 index 0000000000..364fc6732a --- /dev/null +++ b/services/zipcli-core/docs/MCP.md @@ -0,0 +1,138 @@ +# ZipCLI MCP server + +ZipCLI can run an [MCP](https://modelcontextprotocol.io) server so an AI client can run ZipCLI commands on the phone. +Transport: **Streamable HTTP**, one endpoint: `POST http://:/mcp`. + +## Quick start + +In the app: + +``` +mcp start # listens on 127.0.0.1:8765 (this phone only) +mcp token # prints the bearer token the client must send +``` + +A notification ("ZipCLI MCP server running") appears while the server is up; it keeps Android from killing the +app when you switch away. Stop with `mcp stop`. + +Client configuration (generic): + +| Setting | Value | +|---|---| +| URL | `http://127.0.0.1:8765/mcp` | +| Header | `Authorization: Bearer ` | + +### Reaching the phone from another machine + +Prefer keeping the default (loopback only) and tunnelling: + +``` +adb forward tcp:8765 tcp:8765 # then use http://127.0.0.1:8765/mcp on your computer +``` + +`mcp start --lan` binds all interfaces so the client can use the phone's Wi-Fi address (`mcp status` lists the URLs). +That is **plain HTTP**: anyone on the same network can read the token. Use it only on a network you trust, or put a +VPN (e.g. Tailscale/WireGuard) underneath. + +### Terminal command + +``` +mcp start [--port N] [--lan] start (port is remembered; default 8765) +mcp stop +mcp status +mcp token [--rotate] show the token; --rotate invalidates the old one immediately +``` + +## Tools + +| Tool | Purpose | +|---|---| +| `run_command` | Run a command or multi-line script (`;` `&&` `\|\|`, variables, `for` loops). Args: `command` (required), `cwd`, `confirm`, `timeout_seconds` | +| `list_commands` | List commands with usage, or full flags for one (`name`) | + +Behaviour worth knowing: + +- **Shared shell.** The working directory and `set` variables persist between calls (like a real shell). Pass `cwd` + to make a call independent of earlier state. +- **One command at a time.** Calls are serialized; the terminal UI is not affected, but each call is echoed there as + `[mcp] $ ` so you can see what the AI is doing. +- **Non-interactive.** `edit` and `saf grant` can't open UI from a remote call and return an error (`update --content` + is the scriptable alternative). +- **Confirmations.** A step that asks "are you sure?" (e.g. `delete`) is skipped unless the call passes + `confirm=true`. This is a guard against accidents, **not a security boundary**: `delete -f` never asks. +- **Not reachable remotely:** `mcp` (token / start / stop) and `saf` (grants). +- **Timeouts.** Default 50 s, max 600 s per call; on timeout the command is cancelled (work already done is not + rolled back). If you raise `timeout_seconds`, raise your client's request timeout too. +- **Output** is capped at 100,000 characters per call. +- Each result ends with `[exit | cwd ]`; `isError` is true when the status is non-zero. + +## Protocol support + +Both generations of the spec are served from the same endpoint, chosen per request: + +| Client speaks | How the server recognises it | Notes | +|---|---|---| +| **2026-07-28** (stateless) | `MCP-Protocol-Version: 2026-07-28` header or `_meta` protocol version | `server/discover`, `tools/list`, `tools/call`; every result carries `resultType`, `_meta` server info and (lists) `ttlMs`/`cacheScope`. Header/body mismatches → 400 `-32020`; unsupported version → 400 `-32022` | +| **2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05** | `initialize` | `initialize`, `ping`, `tools/list`, `tools/call`. No `Mcp-Session-Id` is issued (allowed by the spec), so no `initialize` is required first | + +Not implemented (all optional): SSE responses, the GET stream, sessions/DELETE (both answer **405**), JSON-RPC +batches, chunked request bodies, CORS (browser-based clients can't connect directly; use a native client or proxy). + +## Security model + +- Bearer token (256-bit, random) on **every** request; compared in constant time. Rotate with `mcp token --rotate`. +- Binds `127.0.0.1` unless `--lan`. +- `Origin`, when present, must be loopback (or the same host in LAN mode) → otherwise 403 (DNS-rebinding guard). +- Limits: 16 concurrent connections, 4 MB body, 30 s idle timeout. +- What the AI can touch is exactly what the app can: `/home`, `/sdcard-ext`, and any folder you granted with + `saf grant`. Grant only what you are comfortable letting the AI modify. + +## Try it with curl + +```bash +adb forward tcp:8765 tcp:8765 +TOKEN=... # from 'mcp token' + +# list tools (2025-era client, no handshake needed) +curl -s http://127.0.0.1:8765/mcp -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# run a command +curl -s http://127.0.0.1:8765/mcp -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"list /home"}}}' + +# 2026-07-28 discovery +curl -s http://127.0.0.1:8765/mcp -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":3,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +## Tests + +`tests/McpTests.kt` is a standalone JVM program (like the other test files): it starts the real server on a random +port and checks security, both protocol eras, keep-alive and the tools against a temp directory. It touches no +Android classes (`McpController` / `McpService` are not involved). + +## Files + +| File | Role | +|---|---| +| `mcp/McpProtocol.kt` | JSON-RPC / MCP logic, both protocol generations (pure Kotlin) | +| `mcp/McpHttpServer.kt` | HTTP/1.1 server, auth, Origin check, limits | +| `mcp/ZipCliMcpTools.kt` | `run_command` / `list_commands` on top of `ScriptRunner` | +| `mcp/McpController.kt` | start/stop, token storage, process-wide server | +| `mcp/McpService.kt` | foreground service (notification keeps the process alive) | +| `domain/commands/McpCommands.kt` | the `mcp` terminal command | + + +## Transport boundaries (also in the tool description) + +- **No pipes, no redirection, no command substitution, no arithmetic.** Separate lines, `for`, heredocs. +- Single quotes are literal; byte-exact text goes in a heredoc (`update f <<'EOF' ... EOF`). +- The working directory and variables persist across `run_command` calls (`info --json` shows them; `set --reset` clears variables). +- `saf` and `mcp` are app-only: `saf list` works (read-only), everything else answers "not available in this session", which is a policy, not a typo. +- Confirmations are never answered. A step that asks stops the batch *before* the prompt; the result has `structuredContent.pending_confirmation` and `not_run`. Re-issue with `confirm=true` or `--yes` on that command. +- Output is capped at 100000 characters; a cut is marked with the flags that page it (`--max`, `--lines`, `--offset`, `--count-only`, `--json`). +- `check_only=true` validates a script without running it; `report="json"` adds a per-command `{line, command, status, message}` array. +- `structuredContent` always has `exit` and `cwd`; `history` also records MCP commands. +- Exit statuses and `[E_*]` codes: see docs/SCRIPTING.md. diff --git a/services/zipcli-core/docs/SCRIPTING.md b/services/zipcli-core/docs/SCRIPTING.md new file mode 100644 index 0000000000..86542a7e50 --- /dev/null +++ b/services/zipcli-core/docs/SCRIPTING.md @@ -0,0 +1,112 @@ +# ZipCLI scripting + +## Quoting contract (read this first) + +| Construct | Result | +|---|---| +| `'...'` | **Literal**, like POSIX. No `$` expansion; a backslash is a backslash. `'C:\new\table.txt'` is written exactly as typed; `'\n'` is two characters | +| `\'` inside `'...'` | a quote: `'it\'s'` → `it's`. The POSIX idiom `'it'\''s'` works too | +| `\\` inside `'...'` | two backslashes (the pair is consumed so the parser always agrees where the string ends). A string cannot *end* in a single backslash inside `'...'`: use `"C:\\"` | +| `"..."` | expands `$NAME`; escapes `\\` `\"` `\'` `\n` `\t` | +| `\$` in `"..."` or unquoted | a dollar, **only when a variable name follows** (so `"price\$"` keeps its backslash, which regexes need) | +| `\$` inside `'...'` | stays backslash-dollar | +| heredoc `<<'EOF'` … `EOF` | **byte-exact**, no escapes, no expansion, whichever way the delimiter is quoted. Body ends with a line break; `<<-EOF` strips leading tabs. Only for `create` / `update` (`--append` works). Must be the last thing on its line | +| unquoted `\'` `\"` | a plain quote character | + +Writing exact text (Windows paths, JSON, source code) is therefore either `'...'` for one line or a heredoc: + + update /home/cfg.json <<'EOF' + {"dir": "C:\new\table", "home": "$HOME"} + EOF + +**Not supported, by design:** pipes, redirection, command substitution, arithmetic, background jobs. Each is rejected with one message naming all four. Workarounds: separate lines instead of a pipe; `update --append` or a heredoc instead of `>`; `set NAME=value` instead of substitution; `for` instead of `xargs`. A pasted shell prompt (`$ ls`, `zipcli$ ls`) is recognised and explained, not reported as an unknown command. + +An unterminated quote rejects the whole paste (nothing runs) and shows the line with a caret: + + [E_USAGE] Syntax error (line 39): unterminated quote + 39 | update /home/a.txt 'it is + | ^ opened here, never closed + +Quoting changed from earlier builds: inside `'...'` a backslash used to be an escape (`'a\nb'` made a line break, `'\\'` one backslash). Use `"a\nb"`, a heredoc, or `--old-file`/`--new-file` where you relied on that. + +## Exit statuses and error codes + +| Status | Meaning | +|---|---| +| 0 | success | +| 1 | no match (`find`, `search`, `replace --fail-on-no-match`), false `test`, or another failure | +| 2 | usage error: unknown command/flag, wrong argument count, syntax error | +| 3 | path not found | +| 4 | needs confirmation (batch stopped before the prompt) | +| 5 | partial batch (reserved: some commands did not run) | + +Every error line is `[CODE] sentence`, with `line N:` in front inside a batch: `E_USAGE`, `E_NOT_FOUND`, `E_IS_DIR`, `E_NEEDS_CONFIRM`, `E_INTERNAL` (a bug in ZipCLI, never your script), `E_FAILED` (anything else). `help exit-codes` prints this table. + +## Confirmations in batches + +`delete` asks first. `--yes` / `-y` on that command authorises it; `set -y` does so for the session. Where nobody can answer (MCP) the batch stops **before** the prompt: `stopped at line 6: 1 command needs confirmation, 5 command(s) not run`, exit status 4, and the MCP result carries `pending_confirmation {line, command, prompt, authorize_with}`. Nothing after it runs, even with `set +e`. + +## Checking a script first + +`check script.zcli` (or `check_only=true` over MCP) parses without running: quoting, heredocs, unknown commands, unknown flags, argument counts. Every problem is listed with its physical line number; status 2 if any. Variables are not expanded, so wildcard/variable-dependent errors only show at run time. + +## Machine-readable output and paging + +`--json` on `find`, `search`, `list`, `cat`, `replace` and `help`; `--count-only` on `find`/`search`; `--offset N` (with `--max` / `--lines`) resumes a capped result, and every cap footer names the flag that continues it. `info [--json]` reports version, transport, mounts, available commands, limits and shell state. `set --reset` forgets all variables and restores defaults. + +Type `help scripting` in the app for the same summary. + +| Syntax | Meaning | +|---|---| +| `a; b` or one command per line | run in order | +| `a && b` / `a \|\| b` | run b only if a succeeded / failed | +| `# comment`, trailing `\` | comment, line continuation | +| `set NAME=value` → `$NAME`, `${NAME}`, `${NAME:-default}` | variables; `$?` last status, `$PWD`, `$1..$9`, `$#` | +| `for f in *.txt; do ...; done` | loop (also multi-line) | +| `run script.zcli [args]` | run a file in its own sub-session; `-y` auto-answers prompts | +| `test -e/-f/-d path`, `test a = b`, `echo`, `true`, `false`, `exit n` | helpers | +| `set +e` / `set -x` | keep going after errors / trace commands | + +Notes +- Scripts **stop at the first failing command** (reported with its line number). `cmd || true` or `set +e` to continue. +- Undefined variables are left as typed, so regex `$1` / `a$` keep working at the prompt. Use `'...'` to force a literal `$`. Use `${X:-}` to get an empty string for an unset X. +- Wildcards `* ? **` are expanded for `zip copy move delete cat list` and in `for` lists. `find`, `search` and `replace` take patterns instead (`find x dir -r --name '*.md'`, `search '*.md' dir -r`) and say so if given a wildcard path. +- `delete` still asks for confirmation inside scripts (use `-f`, or `run -y`). `-f` is idempotent like POSIX `rm -f`: a path that is already gone is not an error, so `delete backup.zip -f` safely clears a previous run. +- `edit` and `saf grant` are interactive and cannot run inside a script file. +- Paste a multi-line script into the input; use the ↵ button (or Shift+Enter) to add lines by hand; Enter / Send runs. + +## What is not supported + +ZipCLI is a scriptable file manager, not a POSIX shell. The following are rejected with an error instead of being silently passed to a command as text: + +- redirection `>` `>>` `<` — use `update --content` / `--append` +- pipes `|` — run the commands one after another, or combine `find` with `replace` +- background `&` +- command substitution (dollar-paren and backticks) and arithmetic — use `set NAME=value` + +A metacharacter is only rejected when it stands alone as a word, so a regex such as `a|b` still works. + +## Quoting and leading dashes + +- See the quoting contract at the top. +- A word that starts with a dash is treated as a flag only when it looks like one, so `echo '-- one --'` prints as typed. Writing `--` forces everything after it to be an argument. + +## Exit status + +Scripts stop at the first failing command, so exit codes are part of the language; `help ` prints them for every command. The ones that matter most: `find` and `search` return 1 when nothing matched (grep-style, so `find TODO dir || echo none` works), `test` returns 1 when the condition is false, and `delete -f` returns 0 for paths that are already gone. + +Terminal log: each command and its output is one block followed by a blank line. The small icon on the `$ command` line copies that command's whole output. To copy individual lines, long-press a line, tap more lines to add or remove them, then press Copy in the bar that appears (Cancel or Back clears the selection). + +Tests (`tests/run.sh`): `ScenarioTests` (82), `MultilineTests` (117), `RecommendationTests` (112: quoting, heredoc, confirmations, check, JSON, codes) and golden transcripts in `tests/transcripts/*.zcli` + `.expected` (run in a fresh root, diffed verbatim; regenerate with `--update` and review the diff). + +## Find / replace for code and rich text +- A pattern may contain line breaks (type them inside quotes). `\n` in a pattern matches LF **and** CRLF files; inserted breaks use the file's own style; untouched lines stay byte-identical. This also holds for `--regex` (a typed `\n`, a literal line break, or `\n` inside `[...]`). +- `--reindent`: ignores indentation/trailing-blank differences when matching, then re-indents your replacement to the file (keeps its relative nesting, converts tabs⇄spaces and 2/4/8-space steps). The block must start at the beginning of a line (a UTF-8 BOM before line 1 is fine). A pattern that **ends with a line break** matches whole lines and leaves the next line's indentation alone. With a one-line pattern, `--reindent` indents the extra lines of a multi-line replacement like the line where the match is. +- `--regex`: `^`/`$` mean start/end of each line (no phantom match after the last line break), `$1`/`${name}` and `\n`/`\t` work in the replacement, `--dotall` lets `.` cross lines, `-w` wraps the pattern in `\b…\b`. +- `-w` whole word, `-i` ignore case, `--dry-run` shows a before/after diff, `--backup` keeps `.bak`. +- `find` is case-insensitive unless the pattern has a capital letter (escapes such as `\S` don't count). Regexes that can cross lines (`\n`, `(?s)`, `--dotall`) search whole files automatically. +- Long blocks: write them in files and use `replace f --old-file old.txt --new-file new.txt` (exact, no escape processing; one final newline is ignored, so end a file with a blank line to match whole lines). +- `find` shows matches with their original indentation; multi-line patterns (or `-m`) report the start line and the following lines. +- Tab completion never turns a pattern or replacement text into a file name; only the path argument completes. +- Binary / non-UTF-8 files are never rewritten. +- Caveat: inside *double* quotes `\n`, `\t`, `\\` are escapes; inside single quotes they are literal characters. (In `--regex` mode `\n` in a pattern always means a line break.) diff --git a/services/zipcli-core/examples/backup.zcli b/services/zipcli-core/examples/backup.zcli new file mode 100644 index 0000000000..320bcf4011 --- /dev/null +++ b/services/zipcli-core/examples/backup.zcli @@ -0,0 +1,9 @@ +#!zipcli +# Usage: run backup.zcli [-- ...] +# Zips every .txt in the given folder into /home/backups with a dated-style name. +set DIR=${1:-/home} +create /home/backups/ +cd $DIR +test -n "${1:-}" || echo "no folder given, using $DIR" +zip /home/backups/notes.zip *.txt -o || echo "nothing to back up" +ls /home/backups -l diff --git a/services/zipcli-core/module.toml b/services/zipcli-core/module.toml new file mode 100644 index 0000000000..c8d79530c4 --- /dev/null +++ b/services/zipcli-core/module.toml @@ -0,0 +1,14 @@ +version = 2 + +[module] +type = "java-lib" +name = "zipcli-core" +languageLevel = "JAVA_17" +output = "build/codeassist/classes" + +[sourceSets.main] +scope = "IMPLEMENTATION" +java = ["src/main/kotlin"] + +[dependencies] +implementation = ["org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0", "kotlin-stdlib"] diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Autocomplete.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Autocomplete.kt new file mode 100644 index 0000000000..0e49658e29 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Autocomplete.kt @@ -0,0 +1,201 @@ +package com.zipcli.ke.domain + +import com.zipcli.ke.fs.FileEntry +import com.zipcli.ke.fs.TerminalFileSystem +import com.zipcli.ke.fs.VirtualPath +import kotlinx.coroutines.CancellationException + +/** + * @param newText the whole input line after completion, or null when nothing can be inserted + * @param suggestions what to show when several candidates remain + */ +data class Completion(val newText: String?, val suggestions: List = emptyList()) + +/** + * Tab completion. Works on the LAST statement of the line (after `;`, `&&`, `||`, newline), + * understands quotes, and completes command names, flags, `$variables`, sub-commands and paths. + * Several matches complete to their longest common prefix, like a real shell. + */ +class Autocomplete( + private val registry: CommandRegistry, + private val fs: TerminalFileSystem, +) { + private val dirOnlyCommands = setOf("cd") + private val builtinVars = listOf("PWD", "CWD", "HOME", "OLDPWD") + + suspend fun complete(input: String, cwd: String, varNames: Collection = emptyList()): Completion { + val split = lastBoundary(input) + var head = input.substring(0, split) + val seg = input.substring(split) + val lead = seg.takeWhile { it.isWhitespace() } + head += lead + val body = seg.substring(lead.length) + + val t = tokenize(body) + val cur = t.values.lastOrNull() ?: "" + val prefixText = head + body.substring(0, t.lastStart) + + // 1. command name + if (t.values.size <= 1) { + if (cur.isEmpty()) return Completion(null) + val names = (registry.names() + listOf("for")).filter { it.startsWith(cur, ignoreCase = true) }.sorted() + return finish(prefixText, cur, names, trailingSpaceWhenUnique = true) + } + + val spec = registry.find(t.values[0]) + + // 2. flags + if (t.lastQuote == null && cur.startsWith("-") && spec != null) { + val longs = spec.flags.map { "--${it.long}" } + val matches = if (cur == "-" || cur == "--") longs else longs.filter { it.startsWith(cur, ignoreCase = true) } + return finish(prefixText, cur, matches.sorted(), trailingSpaceWhenUnique = true) + } + + // 3. variables + if (cur.startsWith("$") && !cur.startsWith("\${")) { + val partial = cur.substring(1) + val names = (varNames + builtinVars).distinct().filter { it.startsWith(partial, ignoreCase = true) }.sorted() + return finish(prefixText, cur, names.map { "$$it" }, trailingSpaceWhenUnique = false) + } + + // 4. fixed sub-commands + when (t.values[0].lowercase()) { + "saf" -> if (t.values.size == 2) + return finish(prefixText, cur, listOf("grant", "list", "revoke").filter { it.startsWith(cur, true) }, true) + "help", "?" -> if (t.values.size == 2) + return finish(prefixText, cur, + (registry.names() + "scripting").filter { it.startsWith(cur, true) }.sorted(), true) + } + + // 5. paths (but never inside the / of find and replace) + if (spec != null && !isPathPosition(spec, t.values)) return Completion(null) + return completePath(prefixText, cur, t.lastQuote, cwd, dirOnly = t.values[0].lowercase() in dirOnlyCommands) + } + + // ───────────────────────── helpers ───────────────────────── + + /** + * `find [path]` and `replace ` mix text and paths, so a word typed in a + * pattern position must not be "completed" into a file name (that would silently rewrite the pattern). + * Only the path position, or the value of --old-file/--new-file, completes. Other commands always do. + */ + private fun isPathPosition(spec: CommandSpec, values: List): Boolean { + if (spec.name != "find" && spec.name != "replace") return true + var positional = 0 + var awaiting: FlagSpec? = null + for (k in 1 until values.size - 1) { + val tok = values[k] + if (awaiting != null) { awaiting = null; continue } // this token is a flag's value + when { + tok.startsWith("--") && tok.length > 2 -> + if ('=' !in tok) awaiting = spec.flags.firstOrNull { it.long == tok.substring(2) && it.takesValue } + tok.length == 2 && tok[0] == '-' -> awaiting = spec.flags.firstOrNull { it.short == tok[1] && it.takesValue } + tok.length > 2 && tok[0] == '-' -> {} // bundled short flags such as -ri + else -> positional++ + } + } + awaiting?.let { return it.long == "old-file" || it.long == "new-file" } + return if (spec.name == "replace") positional == 0 else positional == 1 + } + + private fun finish(prefixText: String, typed: String, matches: List, trailingSpaceWhenUnique: Boolean): Completion { + if (matches.isEmpty()) return Completion(null) + if (matches.size == 1) { + return Completion(prefixText + matches[0] + if (trailingSpaceWhenUnique) " " else "") + } + val lcp = Suggest.commonPrefix(matches) + val newText = if (lcp.length > typed.length) prefixText + lcp else null + return Completion(newText, matches) + } + + private suspend fun completePath(prefixText: String, cur: String, quote: Char?, cwd: String, dirOnly: Boolean): Completion { + val slash = cur.lastIndexOf('/') + val dirPrefix = if (slash >= 0) cur.substring(0, slash + 1) else "" + val partial = cur.substring(slash + 1) + val dir = if (dirPrefix.isEmpty()) cwd else VirtualPath.resolve(cwd, dirPrefix) + + val entries: List = try { fs.list(dir) } + catch (e: CancellationException) { throw e } catch (_: Exception) { return Completion(null) } + + val matches = entries + .filter { it.name.startsWith(partial, ignoreCase = true) } + .filter { !it.name.startsWith(".") || partial.startsWith(".") } + .filter { !dirOnly || it.isDirectory } + .sortedWith(compareByDescending { it.isDirectory }.thenComparator { a, b -> NaturalOrder.compare(a.name, b.name) }) + if (matches.isEmpty()) return Completion(null) + + if (matches.size == 1) { + val m = matches[0] + val value = dirPrefix + m.name + if (m.isDirectory) "/" else "" + return Completion(prefixText + render(value, quote, closeQuote = !m.isDirectory) + if (!m.isDirectory) " " else "") + } + + val lcp = Suggest.commonPrefix(matches.map { it.name }) + val shown = matches.take(40).map { if (it.isDirectory) it.name + "/" else it.name } + + if (matches.size > 40) listOf("… +${matches.size - 40} more") else emptyList() + val newText = if (lcp.length > partial.length) prefixText + render(dirPrefix + lcp, quote, closeQuote = false) else null + return Completion(newText, shown) + } + + /** Wraps [value] in quotes when it contains characters that would otherwise split or end the word. */ + private fun render(value: String, quote: Char?, closeQuote: Boolean): String { + val special = value.any { it.isWhitespace() || it in ";&|#'\"" } + if (quote == null && !special) return value + val q = quote ?: '"' + val escaped = value.replace("\\", "\\\\").replace(q.toString(), "\\$q") + return q + escaped + if (closeQuote) q.toString() else "" + } + + private class Tokens(val values: List, val lastStart: Int, val lastQuote: Char?) + + /** Splits the last statement into words; the last entry is the word being completed (maybe empty). */ + private fun tokenize(body: String): Tokens { + val values = ArrayList() + val sb = StringBuilder() + var quote: Char? = null + var started = false + var start = 0 + var i = 0 + while (i < body.length) { + val c = body[i] + val q = quote + if (q != null) { + when { + c == q -> quote = null + c == '\\' && i + 1 < body.length && body[i + 1] in "\\'\"" -> { sb.append(body[i + 1]); i++ } + else -> sb.append(c) + } + } else when { + c == '\'' || c == '"' -> { if (!started) { started = true; start = i }; quote = c } + c.isWhitespace() -> if (started) { values += sb.toString(); sb.setLength(0); started = false } + else -> { if (!started) { started = true; start = i }; sb.append(c) } + } + i++ + } + return if (started) { + Tokens(values + sb.toString(), start, quote) + } else { + Tokens(values + "", body.length, null) + } + } + + /** Index just after the last `;`, newline, `&&` or `||` that is outside quotes. */ + private fun lastBoundary(s: String): Int { + var quote: Char? = null + var last = 0 + var i = 0 + while (i < s.length) { + val c = s[i] + val q = quote + if (q != null) { + if (c == '\\' && i + 1 < s.length) i++ else if (c == q) quote = null + } else when { + c == '\'' || c == '"' -> quote = c + c == ';' || c == '\n' -> last = i + 1 + (c == '&' || c == '|') && i + 1 < s.length && s[i + 1] == c -> { last = i + 2; i++ } + } + i++ + } + return last + } +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandParser.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandParser.kt new file mode 100644 index 0000000000..3796411453 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandParser.kt @@ -0,0 +1,174 @@ +package com.zipcli.ke.domain + +sealed interface ParseOutcome { + data class Ok(val parsed: ParsedCommand) : ParseOutcome + data class Err(val message: String) : ParseOutcome +} + +/** + * Turns one command into a [ParsedCommand]. Statement splitting (`;`, `&&`, newlines, loops) + * and variable expansion happen before this, in the script layer; [parse] keeps the original + * one-string entry point for callers that don't need them. + */ +class CommandParser(private val registry: CommandRegistry) { + + fun parse(input: String): ParseOutcome { + val trimmed = input.trim() + if (trimmed.isEmpty() || trimmed.startsWith("#")) return ParseOutcome.Err("empty") + val tokens = try { Lexer.words(autoFix(trimmed)).map { it.text } } + catch (e: CommandParseException) { return ParseOutcome.Err(e.message ?: "Parse error") } + return parseTokens(tokens, trimmed) + } + + /** "update/home/a.txt" -> "update /home/a.txt" (only when the first word is a real command). */ + fun autoFix(statement: String): String { + val m = Regex("^([^ /\"']+)(/)").find(statement) ?: return statement + return if (registry.find(m.groupValues[1]) != null) statement.replaceFirst("/", " /") else statement + } + + fun parseTokens(tokens: List, raw: String): ParseOutcome { + if (tokens.isEmpty()) return ParseOutcome.Err("empty") + + val spec = registry.find(tokens[0]) + ?: return ParseOutcome.Err(unknownCommand(tokens[0])) + + val flagsByShort = spec.flags.mapNotNull { f -> f.short?.let { it to f } }.toMap() + val flagsByLong = spec.flags.associateBy { it.long } + + fun unknownLong(key: String): ParseOutcome.Err { + val hint = Suggest.closest(key, flagsByLong.keys)?.let { " Did you mean --$it?" } + ?: " If it is literal text, write it after '--' or in quotes: ${spec.name} -- ''" + return ParseOutcome.Err("Unknown flag: --$key for '${spec.name}'.$hint") + } + + /** "-z" is not a flag of this command: list the ones it does have, so the caller can fix it in one step. */ + fun unknownShort(c: Char): ParseOutcome.Err { + val have = spec.flags.joinToString(", ") { f -> (f.short?.let { "-$it/" } ?: "") + "--" + f.long } + val hint = if (have.isEmpty()) "It takes no flags." else "Flags: $have." + return ParseOutcome.Err( + "Unknown flag: -$c for '${spec.name}'. $hint If it is literal text, write it after '--' or in quotes. Try 'help ${spec.name}'.") + } + + val positional = mutableListOf() + // positional index -> the boolean flag typed right before it ("-m 6": 6 is not the flag's value) + val afterBoolFlag = mutableMapOf() + var lastBool: String? = null + val bools = mutableSetOf() + val values = mutableMapOf() + var i = 1 + + while (i < tokens.size) { + val t = tokens[i] + val prevBool = lastBool + lastBool = null + when { + // Text that only looks like a flag ("-- archive contents --", "-3") is a positional + // argument: every command has a closed flag set, so such a token can never be a flag. + t.length > 1 && t != "--" && t[0] == '-' && !looksLikeFlag(t) -> { positional += t; i++ } + t == "--" -> { + i++ + while (i < tokens.size) { positional += tokens[i]; i++ } + } + t.startsWith("--") && t.length > 2 -> { + val body = t.substring(2) + val eq = body.indexOf('=') + if (eq >= 0) { + val key = body.substring(0, eq) + val value = body.substring(eq + 1) + val f = flagsByLong[key] ?: return unknownLong(key) + if (!f.takesValue) return ParseOutcome.Err("--$key does not take a value") + values[key] = value; i++ + } else { + val f = flagsByLong[body] ?: return unknownLong(body) + if (f.takesValue) { + if (i + 1 >= tokens.size) + return ParseOutcome.Err("--$body requires a value") + values[body] = tokens[i + 1]; i += 2 + } else { bools += body; i++; lastBool = "--$body" } + } + } + t.startsWith("-") && t.length > 1 -> { + val body = t.substring(1) + if (body.length == 1) { + val f = flagsByShort[body[0]] ?: return unknownShort(body[0]) + if (f.takesValue) { + if (i + 1 >= tokens.size) + return ParseOutcome.Err("-${body[0]} requires a value") + values[f.long] = tokens[i + 1]; i += 2 + } else { bools += f.long; i++; lastBool = "-${body[0]}" } + } else { + for (c in body) { + val f = flagsByShort[c] ?: return unknownShort(c) + if (f.takesValue) + return ParseOutcome.Err("-$c takes a value and cannot be combined") + bools += f.long + } + i++ + } + } + else -> { + prevBool?.let { afterBoolFlag[positional.size] = it } + positional += t; i++ + } + } + } + + if (positional.size < spec.minArgs || positional.size > spec.maxArgs) { + val got = positional.size + // "-m 6": a yes/no flag followed by a word is the usual cause of "too many arguments". + if (got > spec.maxArgs) { + for (idx in spec.maxArgs until got) { + afterBoolFlag[idx]?.let { flag -> + return ParseOutcome.Err( + "Usage: $flag takes no value (it is an on/off flag); the extra argument is '${positional[idx]}'. ${spec.usage}") + } + } + } + return ParseOutcome.Err("Usage: ${spec.usage} (got $got argument${if (got == 1) "" else "s"})") + } + + return ParseOutcome.Ok(ParsedCommand(spec.name, positional, bools, values, raw.trim())) + } + + /** True when [t] has the shape of a flag (-x, --name, --name=value), so real typos are still reported. */ + private fun looksLikeFlag(t: String): Boolean { + val key = t.trimStart('-').substringBefore('=') + return key.any { it.isLetter() } && key.all { it.isLetterOrDigit() || it == '-' || it == '_' } + } + + private fun unknownCommand(name: String): String { + shellSyntaxHint(name)?.let { return it } + if (name.lowercase() in APP_ONLY_COMMANDS && registry.find(name) == null) { + return "'$name' is not available in this session: it is interactive and only runs in the app's own " + + "terminal. (This is a policy, not a typo.) Run 'info' to see what is available here." + } + val hint = Suggest.closest(name, registry.names()) + return if (hint != null) "Unknown command: '$name'. Did you mean '$hint'?" + else "Unknown command: '$name'. Type 'help'." + } + + companion object { + /** Commands that exist in the app but are never reachable over MCP (they need UI or manage the server). */ + val APP_ONLY_COMMANDS = setOf("saf", "mcp") + + const val NO_SHELL_FEATURES = + "ZipCLI has no pipes, no redirection, no command substitution and no arithmetic. " + + "Run commands as separate lines; use 'for' to loop. See 'help scripting'." + + /** + * A "command" that is really shell syntax: a pasted prompt (`$`, `zipcli$`, `>`) or a bare operator. + * That is a design boundary, not a typo, so it must not be reported as "Unknown command". + */ + fun shellSyntaxHint(name: String): String? { + if (name.isEmpty()) return null + val promptLike = name.length <= 24 && name.last() in "$%>#" && name.dropLast(1).all { it.isLetterOrDigit() || it in "_-@:~/." } + val operator = name.isNotEmpty() && name.all { it in "|<>&;()" } + return when { + promptLike -> "'$name' looks like a shell prompt, not a command. Paste the command without the prompt " + + "(without '$name'). $NO_SHELL_FEATURES" + operator -> "'$name' is shell syntax and is not supported. $NO_SHELL_FEATURES" + else -> null + } + } + } +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandRegistry.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandRegistry.kt new file mode 100644 index 0000000000..8e65e52640 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandRegistry.kt @@ -0,0 +1,17 @@ +package com.zipcli.ke.domain + +class CommandRegistry(specs: List) { + private val byName: Map = + specs.flatMap { spec -> spec.names.map { it.lowercase() to spec } }.toMap() + + fun find(name: String): CommandSpec? = byName[name.lowercase()] + + fun all(): List = + byName.values.distinctBy { it.name }.sortedBy { it.name } + + /** Every name and alias, for completion and "did you mean". */ + fun names(): Set = byName.keys + + fun complete(prefix: String): List = + byName.keys.filter { it.startsWith(prefix, ignoreCase = true) }.sorted() +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandResult.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandResult.kt new file mode 100644 index 0000000000..264ac84e2b --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandResult.kt @@ -0,0 +1,33 @@ +package com.zipcli.ke.domain + +sealed interface CommandResult { + /** Trigger the system file picker for SAF */ + object RequestSafPermission : CommandResult + + data class Success( + val lines: List = emptyList(), + val newCwd: String? = null, + /** Exit status seen by scripts; 0 = success. Non-zero makes '&&' / '||' and errexit react. */ + val status: Int = 0, + ) : CommandResult + + /** A blank [message] means "failed, nothing to report" (used by `test` and `false`). */ + data class Failure(val message: String, val code: String? = null) : CommandResult + + data class Confirmation( + val prompt: String, + val onConfirm: suspend () -> CommandResult, + ) : CommandResult + + /** Clear the terminal log (used by `clear`). */ + object Clear : CommandResult + + /** Open the in-app editor sheet (used by `edit` and `update`). */ + data class OpenEditor( + val path: String, + val initialContent: String, + ) : CommandResult + + /** Stop the running script (used by `exit`). */ + data class Exit(val code: Int = 0) : CommandResult +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandSpec.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandSpec.kt new file mode 100644 index 0000000000..3ad90fd604 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/CommandSpec.kt @@ -0,0 +1,42 @@ +package com.zipcli.ke.domain + +import com.zipcli.ke.fs.TerminalFileSystem + +data class FlagSpec( + val long: String, + val short: Char? = null, + val takesValue: Boolean = false, + val description: String = "", +) + +data class CommandContext( + val parsed: ParsedCommand, + val fs: TerminalFileSystem, + val cwd: String, + val history: List, + val session: ShellSession = ShellSession(cwd), + val launcher: ScriptLauncher? = null, + /** Where the command runs: "terminal" (the app) or "mcp" (a remote client). Shown by `info`. */ + val transport: String = "terminal", + /** Report a short status line for long-running work (shown next to the spinner, not logged). */ + val progress: (String) -> Unit = {}, +) + +data class CommandSpec( + val name: String, + val aliases: List = emptyList(), + val summary: String, + val usage: String, + val flags: List = emptyList(), + val minArgs: Int = 0, + val maxArgs: Int = Int.MAX_VALUE, + /** When true, unquoted `*` / `?` / `**` in arguments are expanded to matching paths before the command runs. */ + val expandGlobs: Boolean = false, + val execute: suspend (CommandContext) -> CommandResult, + /** One line describing the exit codes, shown by 'help '. */ + val exitStatus: String? = null, + /** True when the command can ask for confirmation, so the global `--yes` / `-y` flag applies to it. */ + val mayConfirm: Boolean = false, +) { + val names: List get() = listOf(name) + aliases +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ErrorCodes.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ErrorCodes.kt new file mode 100644 index 0000000000..8a9117e266 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ErrorCodes.kt @@ -0,0 +1,51 @@ +package com.zipcli.ke.domain + +/** + * Stable error codes and exit statuses. + * + * Every error line is `[E_CODE] human sentence`, so a caller can branch on the code without parsing prose. + * The exit-status table is published in docs/SCRIPTING.md and in `help`: + * + * 0 success | 1 no match / false predicate / other failure | 2 usage error | 3 path not found + * 4 needs confirmation | 5 partial batch (some commands did not run) + */ +object ErrorCodes { + const val USAGE = "E_USAGE" + const val NOT_FOUND = "E_NOT_FOUND" + const val IS_DIR = "E_IS_DIR" + const val NEEDS_CONFIRM = "E_NEEDS_CONFIRM" + const val INTERNAL = "E_INTERNAL" + /** Anything else that went wrong in a command (already exists, permission, I/O ...). */ + const val FAILED = "E_FAILED" + + const val STATUS_OK = 0 + const val STATUS_FAILED = 1 + const val STATUS_USAGE = 2 + const val STATUS_NOT_FOUND = 3 + const val STATUS_NEEDS_CONFIRM = 4 + const val STATUS_PARTIAL = 5 + + val ALL = listOf(USAGE, NOT_FOUND, IS_DIR, NEEDS_CONFIRM, INTERNAL, FAILED) + + fun statusFor(code: String): Int = when (code) { + USAGE -> STATUS_USAGE + NOT_FOUND -> STATUS_NOT_FOUND + NEEDS_CONFIRM -> STATUS_NEEDS_CONFIRM + else -> STATUS_FAILED + } + + /** Picks the code for a message that did not name one itself. */ + fun classify(message: String): String = when { + message.startsWith("Internal error") -> INTERNAL + message.startsWith("Not found") || message.startsWith("No such") || message.startsWith("No files match") -> NOT_FOUND + message.contains("is a directory") -> IS_DIR + message.startsWith("Unknown command") || message.startsWith("Unknown flag") || message.startsWith("Usage") || + message.startsWith("Syntax error") || message.contains("requires a value") || + message.contains("does not take a value") || message.contains("takes no value") || + message.contains("cannot be combined") || message.contains("not supported") || + message.contains("looks like a shell prompt") -> USAGE + else -> FAILED + } + + fun tag(code: String) = "[$code]" +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Glob.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Glob.kt new file mode 100644 index 0000000000..67b0439acc --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Glob.kt @@ -0,0 +1,80 @@ +package com.zipcli.ke.domain + +import com.zipcli.ke.fs.FileEntry +import com.zipcli.ke.fs.TerminalFileSystem +import com.zipcli.ke.fs.VirtualPath +import kotlinx.coroutines.CancellationException + +/** + * Wildcards: `*` (any run inside one path segment), `?` (one character), and a `**` segment + * meaning "any number of directories". Hidden entries (".foo") only match when the pattern + * segment itself starts with a dot. + */ +object Glob { + + fun hasWildcard(s: String): Boolean = s.any { it == '*' || it == '?' } + + fun toRegex(glob: String, ignoreCase: Boolean = false): Regex { + val sb = StringBuilder() + for (c in glob) { + when (c) { + '*' -> sb.append("[^/]*") + '?' -> sb.append("[^/]") + else -> sb.append(Regex.escape(c.toString())) + } + } + return if (ignoreCase) Regex(sb.toString(), RegexOption.IGNORE_CASE) else Regex(sb.toString()) + } + + private class Cand(val display: String, val real: String) + + private fun join(display: String, name: String) = when { + display.isEmpty() -> name + display.endsWith("/") -> display + name + else -> "$display/$name" + } + + private suspend fun safeList(fs: TerminalFileSystem, path: String): List = + try { fs.list(path) } catch (e: CancellationException) { throw e } catch (_: Exception) { emptyList() } + + /** + * Expands [pattern] against the filesystem. The result keeps the style of the pattern + * (relative stays relative) and is naturally sorted. Empty when nothing matches. + */ + suspend fun expand(fs: TerminalFileSystem, cwd: String, pattern: String): List { + val absolute = pattern.startsWith("/") + val segs = pattern.split('/').filter { it.isNotEmpty() && it != "." } + var cands = listOf(Cand(if (absolute) "/" else "", if (absolute) "/" else cwd)) + + for ((idx, seg) in segs.withIndex()) { + val last = idx == segs.lastIndex + val next = ArrayList() + when { + seg == "**" -> for (c in cands) { + next += c + val base = c.real.trimEnd('/') + val all = try { fs.walk(c.real) } catch (e: CancellationException) { throw e } catch (_: Exception) { emptyList() } + for (e in all) { + if (e.isDirectory && e.path != c.real) { + next += Cand(join(c.display, e.path.removePrefix(base).trimStart('/')), e.path) + } + } + } + hasWildcard(seg) -> { + val re = toRegex(seg) + for (c in cands) { + for (e in safeList(fs, c.real)) { + if (!re.matches(e.name)) continue + if (e.name.startsWith(".") && !seg.startsWith(".")) continue + if (last || e.isDirectory) next += Cand(join(c.display, e.name), e.path) + } + } + } + else -> for (c in cands) next += Cand(join(c.display, seg), VirtualPath.resolve(c.real, seg)) + } + cands = next.distinctBy { it.real } + if (cands.isEmpty()) return emptyList() + } + return cands.filter { fs.exists(it.real) }.map { it.display }.sortedWith(NaturalOrder) + } +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Lexer.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Lexer.kt new file mode 100644 index 0000000000..b65bf22c0a --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Lexer.kt @@ -0,0 +1,193 @@ +package com.zipcli.ke.domain + +/** + * One argument produced by [Lexer]. + * + * @param glob true when the word contained an *unquoted* `*` or `?` literally typed by the user + * (characters that came out of a variable expansion never count). + */ +data class Word(val text: String, val glob: Boolean = false) + +/** Returns the value of a variable, or null when it is not defined (the `$name` text is then kept as-is). */ +typealias VarLookup = (String) -> String? + +/** + * Splits one command (a single statement, see ScriptParser) into words. + * + * Rules (a superset of the original tokenizer, so existing commands keep working): + * - Whitespace separates words; '...' and "..." group text. + * - Single quotes are LITERAL (POSIX): nothing is expanded and a backslash is just a backslash, with one + * exception: a backslash before the quote itself (\') produces a quote, and the pair \\ is kept as two + * backslashes so that the scanner and the lexer always agree where the string ends. + * The POSIX idiom 'it'\''s' works too (an unquoted \' is a plain quote). + * - Inside double quotes \\ \" \' \n \t are escapes and variables expand. + * - `#` at the start of a word begins a comment. + * - `$NAME`, `${NAME}`, `${NAME:-default}`, `$1`..`$9`, `$?`, `$#` are expanded outside single quotes. + * A variable that is NOT defined is left untouched. That keeps regex anchors ("a$") and + * regex replacement groups ("$1") working in interactive use. + * - `\$` produces a literal `$` outside single quotes, but only when it precedes something that would + * otherwise expand (so a regex such as "price\$" keeps its backslash). Inside single quotes it stays `\$`. + * - For text that must be byte-exact (Windows paths, JSON) use a heredoc: update f <<'EOF' ... EOF. + */ +object Lexer { + + private val NO_VARS: VarLookup = { null } + + /** Shared wording for constructs this shell deliberately does not implement. */ + private const val NO_SUBSTITUTION = + "Arithmetic expansion and command substitution are not supported. " + + "Use 'set NAME=value' plus a variable reference, or quote the text to keep it literal." + + private fun noMetachar(c: Char): String = + "'$c' on its own is not supported: ZipCLI has no pipes, no redirection, no command substitution and " + + "no arithmetic. Run commands as separate lines; use 'for' to loop and 'update --append' to add to a file. " + + "Quote it if it is literal text (e.g. echo 'a $c b'). See 'help scripting'." + + fun words(raw: String, vars: VarLookup = NO_VARS): List { + val out = ArrayList() + val sb = StringBuilder() + var started = false + var glob = false + var quote: Char? = null + var i = 0 + val n = raw.length + + fun flush() { + if (started) { + out += Word(sb.toString(), glob) + sb.setLength(0) + started = false + glob = false + } + } + + while (i < n) { + val c = raw[i] + val q = quote + if (q != null) { + if (q == '\'') { + // POSIX single quotes: literal, except \' (a quote). \\ is consumed as a pair so the + // script scanner and this lexer agree on where the string ends. + when { + c == '\'' -> { quote = null; i++ } + c == '\\' && i + 1 < n && raw[i + 1] == '\'' -> { sb.append('\''); i += 2 } + c == '\\' && i + 1 < n && raw[i + 1] == '\\' -> { sb.append("\\\\"); i += 2 } + else -> { sb.append(c); i++ } + } + } else { + when { + c == q -> { quote = null; i++ } + c == '\\' && i + 1 < n -> { + val nx = raw[i + 1] + when { + nx == '\\' || nx == '\'' || nx == '"' -> { sb.append(nx); i += 2 } + nx == 'n' -> { sb.append('\n'); i += 2 } + nx == 't' -> { sb.append('\t'); i += 2 } + nx == '$' && startsVar(raw, i + 2) -> { sb.append('$'); i += 2 } + else -> { sb.append(c); i++ } + } + } + c == '$' -> i = expandVar(raw, i, vars, sb) + else -> { sb.append(c); i++ } + } + } + } else { + when { + c == '\'' || c == '"' -> { quote = c; started = true; i++ } + c == '#' && !started -> i = n + c.isWhitespace() -> { flush(); i++ } + c == '\\' && i + 1 < n && raw[i + 1] == '$' && startsVar(raw, i + 2) -> { + sb.append('$'); started = true; i += 2 + } + // Unquoted \' and \" are plain quote characters (this is what makes 'it'\''s' work); + // \\ is kept as two backslashes, consumed as a pair like the script scanner does. + c == '\\' && i + 1 < n && (raw[i + 1] == '\'' || raw[i + 1] == '"') -> { + sb.append(raw[i + 1]); started = true; i += 2 + } + c == '\\' && i + 1 < n && raw[i + 1] == '\\' -> { + sb.append("\\\\"); started = true; i += 2 + } + c == '$' -> { + val before = sb.length + i = expandVar(raw, i, vars, sb) + if (sb.length > before) started = true + } + c == '`' -> throw CommandParseException(NO_SUBSTITUTION) + // '>' '<' '|' '&' standing alone mean redirection / pipe / background, + // which this shell does not support. Glued to other text they stay literal, + // so regexes such as a|b keep working. + (c == '>' || c == '<' || c == '|' || c == '&') && !started -> + throw CommandParseException(noMetachar(c)) + else -> { + if (c == '*' || c == '?') glob = true + sb.append(c); started = true; i++ + } + } + } + } + if (quote != null) throw CommandParseException("Unterminated quote") + flush() + return out + } + + private fun startsVar(s: String, idx: Int): Boolean { + if (idx >= s.length) return false + val c = s[idx] + return c.isLetterOrDigit() || c == '_' || c == '{' || c == '?' || c == '#' + } + + private fun isNameStart(c: Char) = c.isLetter() || c == '_' + private fun isNameChar(c: Char) = c.isLetterOrDigit() || c == '_' + + /** Handles the `$` at [i]. Appends the expansion (or the literal `$`) and returns the next index. */ + private fun expandVar(raw: String, i: Int, vars: VarLookup, sb: StringBuilder): Int { + val n = raw.length + if (i + 1 >= n) { sb.append('$'); return i + 1 } + val c = raw[i + 1] + + if (c == '{') { + val close = raw.indexOf('}', i + 2) + if (close < 0) { sb.append('$'); return i + 1 } + val body = raw.substring(i + 2, close) + val sep = body.indexOf(":-") + val name = if (sep >= 0) body.substring(0, sep) else body + val default = if (sep >= 0) body.substring(sep + 2) else null + if (!isValidName(name)) { sb.append('$'); return i + 1 } + val value = vars(name) + when { + value != null && (value.isNotEmpty() || default == null) -> sb.append(value) + default != null -> sb.append(default) + else -> { sb.append('$'); return i + 1 } // undefined, no default: keep literal text + } + return close + 1 + } + + if (isNameStart(c)) { + var j = i + 1 + while (j < n && isNameChar(raw[j])) j++ + val value = vars(raw.substring(i + 1, j)) + if (value == null) { sb.append('$'); return i + 1 } + sb.append(value) + return j + } + + if (c.isDigit() || c == '?' || c == '#') { + val value = vars(c.toString()) + if (value == null) { sb.append('$'); return i + 1 } + sb.append(value) + return i + 2 + } + + if (c == '(') throw CommandParseException(NO_SUBSTITUTION) + + sb.append('$') + return i + 1 + } + + private fun isValidName(name: String): Boolean = + name.isNotEmpty() && ( + name == "?" || name == "#" || + (name.all { it.isDigit() } && name.length == 1) || + (isNameStart(name[0]) && name.all { isNameChar(it) }) + ) +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ParsedCommand.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ParsedCommand.kt new file mode 100644 index 0000000000..26130b51db --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ParsedCommand.kt @@ -0,0 +1,15 @@ +package com.zipcli.ke.domain + +class CommandParseException(message: String) : Exception(message) + +data class ParsedCommand( + val name: String, + val args: List, + val boolFlags: Set, + val valueFlags: Map, + val raw: String, +) { + fun hasFlag(name: String): Boolean = name in boolFlags + fun flagValue(name: String): String? = valueFlags[name] + fun arg(i: Int): String? = args.getOrNull(i) +} \ No newline at end of file diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ShellSession.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ShellSession.kt new file mode 100644 index 0000000000..85e367ef92 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/ShellSession.kt @@ -0,0 +1,52 @@ +package com.zipcli.ke.domain + +/** + * Mutable state that survives between commands: working directory, variables and shell options. + * The terminal keeps one for the whole session; `run` gives every script its own copy + * (so a script's `cd` / `set` never leaks into the caller, like a sub-shell). + */ +class ShellSession(var cwd: String = "/home") { + val vars: MutableMap = LinkedHashMap() + + /** Stop at the first failing command (like `set -e`). On by default: safer for file operations. */ + var errexit: Boolean = true + + /** Print every command before it runs (like `set -x`). */ + var trace: Boolean = false + + var lastStatus: Int = 0 + + /** Answer yes to every confirmation prompt (`set -y`). Off by default. */ + var autoYes: Boolean = false + + /** 0 for the interactive prompt, +1 for every nested `run`. */ + var depth: Int = 0 + + /** Name of the script being executed (for error locations), null at the prompt. */ + var scriptName: String? = null + + fun lookup(name: String): String? = when (name) { + "?" -> lastStatus.toString() + "PWD", "CWD" -> cwd + else -> vars[name] ?: if (name == "HOME") "/home" else null + } + + fun child(): ShellSession = ShellSession(cwd).also { + it.vars.putAll(vars) + it.errexit = true + it.trace = trace + it.depth = depth + 1 + } +} + +/** Lets the `run` command start a script without knowing about the runner. */ +interface ScriptLauncher { + /** Returns the exit status (0 = success). */ + suspend fun runFile(parent: ShellSession, path: String, args: List, autoYes: Boolean): Int + + /** Parses [text] as a script without running anything and returns one line per problem found (empty = fine). */ + suspend fun check(text: String): ScriptCheck +} + +/** Result of `check`: [problems] are ready to print, [commands] is how many commands were inspected. */ +data class ScriptCheck(val problems: List, val commands: Int) diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Suggest.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Suggest.kt new file mode 100644 index 0000000000..0a8360651b --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/Suggest.kt @@ -0,0 +1,83 @@ +package com.zipcli.ke.domain + +/** Small text helpers shared by the parser ("did you mean"), autocomplete and the commands. */ +object Suggest { + + /** Levenshtein distance with an early exit once the result must exceed [limit]. */ + fun distance(a: String, b: String, limit: Int = Int.MAX_VALUE): Int { + if (a == b) return 0 + if (Math.abs(a.length - b.length) > limit) return limit + 1 + var prev = IntArray(b.length + 1) { it } + var cur = IntArray(b.length + 1) + for (i in 1..a.length) { + cur[0] = i + var rowMin = cur[0] + for (j in 1..b.length) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + cur[j] = minOf(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost) + if (cur[j] < rowMin) rowMin = cur[j] + } + if (rowMin > limit) return limit + 1 + val t = prev; prev = cur; cur = t + } + return prev[b.length] + } + + /** Best "did you mean" candidate for [input], or null when nothing is close enough. */ + fun closest(input: String, candidates: Collection): String? { + val x = input.lowercase() + if (x.isEmpty()) return null + // A unique prefix match ("rep" -> "replace") is the strongest signal. + val prefixed = candidates.filter { it.lowercase().startsWith(x) } + if (prefixed.size == 1 && x.length >= 2) return prefixed[0] + val limit = if (x.length <= 3) 1 else 2 + var best: String? = null + var bestD = limit + 1 + for (c in candidates.sorted()) { + val d = distance(x, c.lowercase(), limit) + if (d < bestD) { bestD = d; best = c } + } + return best + } + + /** Longest common prefix, compared case-insensitively but returned with the first item's casing. */ + fun commonPrefix(items: List): String { + if (items.isEmpty()) return "" + var len = items[0].length + for (s in items) { + var k = 0 + val m = minOf(len, s.length) + while (k < m && items[0][k].equals(s[k], ignoreCase = true)) k++ + len = k + if (len == 0) break + } + return items[0].substring(0, len) + } +} + +/** "file2" sorts before "file10"; case-insensitive. */ +object NaturalOrder : Comparator { + override fun compare(a: String, b: String): Int { + var i = 0 + var j = 0 + while (i < a.length && j < b.length) { + val ca = a[i] + val cb = b[j] + if (ca.isDigit() && cb.isDigit()) { + var ei = i; while (ei < a.length && a[ei].isDigit()) ei++ + var ej = j; while (ej < b.length && b[ej].isDigit()) ej++ + val na = a.substring(i, ei).trimStart('0') + val nb = b.substring(j, ej).trimStart('0') + if (na.length != nb.length) return na.length - nb.length + val c = na.compareTo(nb) + if (c != 0) return c + i = ei; j = ej + } else { + val c = ca.lowercaseChar().compareTo(cb.lowercaseChar()) + if (c != 0) return c + i++; j++ + } + } + return (a.length - i) - (b.length - j) + } +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ArchiveCommands.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ArchiveCommands.kt new file mode 100644 index 0000000000..3cd3d39837 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ArchiveCommands.kt @@ -0,0 +1,143 @@ +package com.zipcli.ke.domain.commands + +import com.zipcli.ke.domain.CommandResult +import com.zipcli.ke.domain.CommandSpec +import com.zipcli.ke.domain.FlagSpec +import com.zipcli.ke.fs.VirtualPath +import com.zipcli.ke.zip.Unzipper +import com.zipcli.ke.zip.Zipper + +fun buildArchiveCommands(zipper: Zipper, unzipper: Unzipper): List = listOf( + zipSpec(zipper), + unzipSpec(unzipper), +) + +private fun zipSpec(zipper: Zipper) = CommandSpec( + name = "zip", + summary = "Create a zip archive (folders included automatically)", + usage = "zip [-r] [-o]", + minArgs = 2, + maxArgs = Int.MAX_VALUE, + expandGlobs = true, + exitStatus = "0 = archive written, 1 = error (e.g. the destination exists and -o was not given)", + flags = listOf( + FlagSpec("recursive", 'r', false, "accepted for convenience; folders are always included"), + FlagSpec("overwrite", 'o', false, "replace an existing archive"), + ), + execute = execute@ { ctx -> + var dest = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(0)!!) + var addedExt = false + if (!VirtualPath.name(dest).contains('.')) { dest += ".zip"; addedExt = true } + val srcs = ctx.parsed.args.drop(1).map { VirtualPath.resolve(ctx.cwd, it) }.distinct() + + for (src in srcs) { + if (ctx.fs.stat(src) == null) return@execute CommandResult.Failure(notFoundMessage(ctx.fs, src)) + if (src == dest) return@execute CommandResult.Failure("The archive cannot contain itself: $src") + } + if (ctx.fs.exists(dest)) { + if (!ctx.parsed.hasFlag("overwrite")) { + return@execute CommandResult.Failure("Destination already exists: $dest (add -o/--overwrite to replace it)") + } + if (ctx.fs.stat(dest)?.isDirectory == true) { + return@execute CommandResult.Failure("$dest is a directory") + } + try { ctx.fs.delete(dest, recursive = false) } + catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { return@execute CommandResult.Failure("Cannot replace $dest: ${e.message ?: e.javaClass.simpleName}") } + } + + val stats = try { + zipper.zip(srcs, dest) { msg -> ctx.progress(msg) } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + return@execute CommandResult.Failure("Zip failed: ${e.message ?: e.javaClass.simpleName}") + } + + if (stats.files + stats.dirs == 0) { + return@execute CommandResult.Failure("Nothing was added to $dest") + } + val saved = if (stats.bytesIn > 0) { + val pct = ((1.0 - stats.bytesOut.toDouble() / stats.bytesIn) * 100).toInt() + if (pct > 0) ", $pct% smaller" else "" + } else "" + val out = ArrayList() + if (addedExt) out += "Added .zip extension" + out += "Created $dest — ${stats.files} file(s)" + + (if (stats.dirs > 0) ", ${stats.dirs} empty folder(s)" else "") + + " · ${formatSize(stats.bytesIn)} → ${formatSize(stats.bytesOut)}$saved" + + (if (stats.skipped > 0) " · ${stats.skipped} duplicate name(s) skipped" else "") + CommandResult.Success(out) + }, +) + +private fun unzipSpec(unzipper: Unzipper) = CommandSpec( + name = "unzip", + summary = "Extract a zip archive (or list it with -l)", + usage = "unzip [destDir] [-o] [-l] [--here]", + minArgs = 1, + maxArgs = 2, + flags = listOf( + FlagSpec("overwrite", 'o', false, "overwrite existing files"), + FlagSpec("list", 'l', false, "list the contents without extracting"), + FlagSpec("here", null, false, "extract into the current directory even if the archive has many top-level items"), + ), + exitStatus = "0 = extracted or listed, 1 = error (nothing extracted, bad archive, …)", + execute = execute@ { ctx -> + var zipPath = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(0)!!) + if (!ctx.fs.exists(zipPath) && ctx.fs.exists("$zipPath.zip")) zipPath += ".zip" + val zipEntry = ctx.fs.stat(zipPath) + ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, zipPath)) + if (zipEntry.isDirectory) return@execute CommandResult.Failure("$zipPath is a directory") + + if (ctx.parsed.hasFlag("list")) { + ctx.progress("Reading $zipPath…") + val items = try { unzipper.list(zipPath) } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { return@execute CommandResult.Failure("Cannot read archive: ${e.message ?: e.javaClass.simpleName}") } + if (items.isEmpty()) return@execute CommandResult.Failure("No entries found — not a valid zip archive?") + val lines = items.take(500).map { "%8s %s".format(if (it.isDirectory) "-" else formatSize(it.size), it.name) } + val more = if (items.size > 500) listOf("… and ${items.size - 500} more") else emptyList() + val files = items.count { !it.isDirectory } + return@execute CommandResult.Success( + lines + more + "$files file(s), ${items.size - files} folder(s), ${formatSize(items.sumOf { it.size })} uncompressed") + } + + // Destination: explicit, or smart. An archive with several top-level items would + // otherwise spill its contents all over the current directory. + val explicit = ctx.parsed.arg(1) + var note: String? = null + val destDir: String = if (explicit != null) { + VirtualPath.resolve(ctx.cwd, explicit) + } else if (ctx.parsed.hasFlag("here")) { + ctx.cwd + } else { + ctx.progress("Inspecting $zipPath…") + val items = try { unzipper.list(zipPath) } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { return@execute CommandResult.Failure("Cannot read archive: ${e.message ?: e.javaClass.simpleName}") } + val top = items.map { it.name.trimStart('/').substringBefore('/') }.filter { it.isNotEmpty() }.toSet() + if (top.size > 1) { + val folder = VirtualPath.name(zipPath).removeSuffix(".zip").removeSuffix(".ZIP").ifEmpty { "extracted" } + note = "Archive has ${top.size} top-level items; extracting into $folder/ (use --here to extract in place)" + VirtualPath.resolve(ctx.cwd, folder) + } else ctx.cwd + } + + val stats = try { + unzipper.unzip(zipPath, destDir, ctx.parsed.hasFlag("overwrite")) { msg -> ctx.progress(msg) } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + return@execute CommandResult.Failure("Unzip failed: ${e.message ?: e.javaClass.simpleName}") + } + + if (stats.files + stats.dirs + stats.skipped == 0) { + return@execute CommandResult.Failure("No entries found — not a valid zip archive?") + } + val out = ArrayList() + note?.let { out += it } + out += "Extracted to $destDir — ${stats.files} file(s), ${formatSize(stats.bytes)}" + + (if (stats.dirs > 0) ", ${stats.dirs} folder(s)" else "") + if (stats.skipped > 0) out += "Skipped ${stats.skipped} existing file(s); use -o to overwrite" + CommandResult.Success(out) + }, +) diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CommandSupport.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CommandSupport.kt new file mode 100644 index 0000000000..de923243dd --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CommandSupport.kt @@ -0,0 +1,199 @@ +package com.zipcli.ke.domain.commands + +import com.zipcli.ke.domain.Glob +import com.zipcli.ke.domain.NaturalOrder +import com.zipcli.ke.domain.Suggest +import com.zipcli.ke.fs.FileEntry +import com.zipcli.ke.fs.TerminalFileSystem +import com.zipcli.ke.fs.VirtualPath +import java.io.BufferedInputStream +import java.io.BufferedReader +import java.io.ByteArrayOutputStream +import java.io.InputStreamReader +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Published limits (shown by `info`, used by the MCP layer). */ +object Limits { + const val APP_VERSION = "1.1.0" + const val CAT_LINES = 500 + const val CAT_LINES_MAX = 50_000 + const val FIND_MATCHES = 200 + const val FIND_MATCHES_MAX = 10_000 + const val SEARCH_RESULTS = 500 + const val MCP_OUTPUT_CHARS = 100_000 + const val MCP_COMMAND_CHARS = 64 * 1024 + const val SCRIPT_COMMANDS = 20_000 +} + +internal const val IO_BUFFER = 64 * 1024 + +/** Files bigger than this are skipped by find/replace/cat instead of being loaded into memory. */ +internal const val TEXT_SIZE_LIMIT = 8L * 1024 * 1024 + +internal fun formatSize(bytes: Long): String = when { + bytes < 1024L -> "$bytes B" + bytes < 1024L * 1024 -> String.format(Locale.US, "%.1fK", bytes / 1024.0) + bytes < 1024L * 1024 * 1024 -> String.format(Locale.US, "%.1fM", bytes / (1024.0 * 1024)) + else -> String.format(Locale.US, "%.1fG", bytes / (1024.0 * 1024 * 1024)) +} + +internal fun formatDate(millis: Long): String = + if (millis <= 0) "-" else SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(Date(millis)) + +/** Mount roots (/home, /tree, /tree/, ...) must never be deleted or moved away. */ +internal fun isProtectedRoot(path: String): Boolean { + val parts = VirtualPath.normalize(path).split('/').filter { it.isNotEmpty() } + return parts.size <= 1 || (parts[0] == "tree" && parts.size == 2) +} + +/** True when [child] is [parent] itself or lives inside it. */ +internal fun isInside(parent: String, child: String): Boolean { + val p = VirtualPath.normalize(parent) + val c = VirtualPath.normalize(child) + return c == p || p == "/" || c.startsWith("$p/") +} + +/** Where `src` lands when copied/moved to [dst]: inside it if it is an existing directory (like `cp`). */ +internal suspend fun resolveTarget(fs: TerminalFileSystem, src: String, dst: String): String { + val e = fs.stat(dst) + return if (e != null && e.isDirectory) VirtualPath.resolve(dst, VirtualPath.name(src)) else dst +} + +/** Opens [path] as a UTF-8 line reader, or returns null when it looks binary (NUL byte in the first 8 KB). */ +internal suspend fun openTextReader(fs: TerminalFileSystem, path: String): BufferedReader? { + val input = BufferedInputStream(fs.openRead(path), IO_BUFFER) + try { + input.mark(8192) + val head = ByteArray(8192) + val n = input.read(head) + if (n > 0 && (0 until n).any { head[it] == 0.toByte() }) { input.close(); return null } + input.reset() + return BufferedReader(InputStreamReader(input, Charsets.UTF_8), IO_BUFFER) + } catch (e: Throwable) { + try { input.close() } catch (_: Exception) {} + throw e + } +} + +internal sealed interface TextRead { + data class Ok(val text: String) : TextRead + object Binary : TextRead + object TooLarge : TextRead +} + +/** + * Reads a whole file as text but refuses anything that is not valid UTF-8 text. + * Used before rewriting a file, so binary files are never corrupted by a decode/encode round trip. + */ +internal suspend fun readTextStrict(fs: TerminalFileSystem, entry: FileEntry): TextRead { + if (entry.size > TEXT_SIZE_LIMIT) return TextRead.TooLarge + val bytes = fs.openRead(entry.path).use { input -> + val out = ByteArrayOutputStream(if (entry.size in 1..TEXT_SIZE_LIMIT) entry.size.toInt() else 8192) + val buf = ByteArray(IO_BUFFER) + var total = 0L + while (true) { + val r = input.read(buf) + if (r < 0) break + total += r + if (total > TEXT_SIZE_LIMIT) return TextRead.TooLarge + out.write(buf, 0, r) + } + out.toByteArray() + } + if (bytes.any { it == 0.toByte() }) return TextRead.Binary + return try { + val dec = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + TextRead.Ok(dec.decode(ByteBuffer.wrap(bytes)).toString()) + } catch (e: CharacterCodingException) { + TextRead.Binary + } +} + +/** + * "Not found: /x/fiel.txt. Did you mean 'file.txt'?" — looks at the siblings of the missing path. + * Without a close match it lists what the folder does contain (first few names), which is usually what the + * caller needs next. [wildcardHint] explains a `*`/`?` in the path for commands that do not expand wildcards. + */ +internal suspend fun notFoundMessage( + fs: TerminalFileSystem, path: String, dirsOnly: Boolean = false, wildcardHint: String? = null, +): String { + val base = "Not found: $path" + val wild = if (wildcardHint != null && Glob.hasWildcard(VirtualPath.name(path))) " $wildcardHint" else "" + return try { + val parent = path.substringBeforeLast('/', "").ifEmpty { "/" } + val name = VirtualPath.name(path) + val names = fs.list(parent).filter { !dirsOnly || it.isDirectory }.map { it.name } + val hint = Suggest.closest(name, names) + when { + hint != null -> "$base. Did you mean '$hint'?$wild" + names.isEmpty() -> "$base ($parent is empty).$wild" + else -> { + val shown = names.sortedWith(NaturalOrder).take(8).joinToString(", ") + "$base. $parent contains: $shown" + (if (names.size > 8) ", … (${names.size - 8} more)" else "") + ".$wild" + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + base + wild + } +} + +/** Minimal JSON writer for `--json` output (the domain layer has no serialization dependency). */ +internal object Json { + fun str(s: String): String { + val sb = StringBuilder("\"") + for (c in s) when { + c == '"' -> sb.append("\\\"") + c == '\\' -> sb.append("\\\\") + c == '\n' -> sb.append("\\n") + c == '\r' -> sb.append("\\r") + c == '\t' -> sb.append("\\t") + c < ' ' -> sb.append(String.format("\\u%04x", c.code)) + else -> sb.append(c) + } + return sb.append('"').toString() + } + + fun obj(vararg pairs: Pair): String = + pairs.joinToString(",", "{", "}") { (k, v) -> str(k) + ":" + value(v) } + + fun arr(items: List): String = items.joinToString(",", "[", "]") { value(it) } + + fun value(v: Any?): String = when (v) { + null -> "null" + is String -> str(v) + is Boolean, is Int, is Long -> v.toString() + is Raw -> v.json + is List<*> -> arr(v) + else -> str(v.toString()) + } + + /** Already-encoded JSON, embedded as is. */ + class Raw(val json: String) +} + +/** Counts of what a directory holds, for the delete confirmation. */ +internal data class TreeStats(val files: Int, val dirs: Int, val bytes: Long) + +internal suspend fun treeStats(fs: TerminalFileSystem, root: String): TreeStats { + var files = 0; var dirs = 0; var bytes = 0L + for (e in fs.walk(root)) { + if (e.path == root) continue + if (e.isDirectory) dirs++ else { files++; bytes += e.size } + } + return TreeStats(files, dirs, bytes) +} + +/** Creates the parent directory of [path] when missing (SAF cannot do this implicitly). */ +internal suspend fun ensureParent(fs: TerminalFileSystem, path: String) { + val parent = VirtualPath.normalize(path).substringBeforeLast('/', "") + if (parent.isNotEmpty() && !fs.exists(parent)) fs.mkdirs(parent) +} diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ContentCommands.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ContentCommands.kt new file mode 100644 index 0000000000..c2cecf1a21 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ContentCommands.kt @@ -0,0 +1,542 @@ +package com.zipcli.ke.domain.commands + +import com.zipcli.ke.domain.CommandResult +import com.zipcli.ke.domain.CommandSpec +import com.zipcli.ke.domain.ErrorCodes +import com.zipcli.ke.domain.FlagSpec +import com.zipcli.ke.domain.Glob +import com.zipcli.ke.fs.FileEntry +import com.zipcli.ke.fs.VirtualPath +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext + +fun buildContentCommands(): List = listOf( + editSpec, + updateSpec, + catSpec, + findSpec, + replaceSpec, + clearSpec, + historySpec, +) + +private fun describeSkip(r: TextRead, path: String): String = when (r) { + TextRead.Binary -> "$path looks like a binary / non-UTF-8 file; refusing to treat it as text" + TextRead.TooLarge -> "$path is larger than ${formatSize(TEXT_SIZE_LIMIT)}" + is TextRead.Ok -> "" +} + +// ───────────────────────────── edit ───────────────────────────── + +private val editSpec = CommandSpec( + name = "edit", + summary = "Open a file in the editor sheet", + usage = "edit ", + minArgs = 1, + maxArgs = 1, + execute = execute@ { ctx -> + val path = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(0)!!) + val entry = ctx.fs.stat(path) + ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, path)) + if (entry.isDirectory) { + return@execute CommandResult.Failure("Not a file: $path") + } + when (val r = readTextStrict(ctx.fs, entry)) { + is TextRead.Ok -> CommandResult.OpenEditor(path, r.text) + else -> CommandResult.Failure(describeSkip(r, path)) + } + }, +) + +// ───────────────────────────── update ───────────────────────────── + +private val updateSpec = CommandSpec( + name = "update", + summary = "Overwrite (or --append to) a file; creates it if missing", + usage = "update [--content | <<'EOF' heredoc] [--append]", + minArgs = 1, + maxArgs = 1, + flags = listOf( + FlagSpec("content", 'c', true, "new content (otherwise opens the editor; single-quote to keep it literal)"), + FlagSpec("append", 'a', false, "add the content to the end instead of replacing the file"), + ), + execute = execute@ { ctx -> + val path = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(0)!!) + val entry = ctx.fs.stat(path) + if (entry?.isDirectory == true) { + return@execute CommandResult.Failure("Not a file: $path") + } + + val inline = ctx.parsed.flagValue("content") + val append = ctx.parsed.hasFlag("append") + if (append && inline == null) { + return@execute CommandResult.Failure("--append needs --content ") + } + if (inline != null) { + ensureParent(ctx.fs, path) + if (append) { + ctx.fs.openWrite(path, append = true).use { it.write(inline.toByteArray(Charsets.UTF_8)) } + return@execute CommandResult.Success(listOf("Appended to $path")) + } + ctx.fs.writeText(path, inline) + return@execute CommandResult.Success(listOf("Updated $path")) + } + + val existing = if (entry != null) { + when (val r = readTextStrict(ctx.fs, entry)) { + is TextRead.Ok -> r.text + else -> return@execute CommandResult.Failure(describeSkip(r, path)) + } + } else "" + CommandResult.OpenEditor(path, existing) + }, +) + +// ───────────────────────────── cat ───────────────────────────── + +private val catSpec = CommandSpec( + name = "cat", + summary = "Print file contents", + usage = "cat [-n] [--lines N] [--offset N] [--json]", + minArgs = 1, + expandGlobs = true, + flags = listOf( + FlagSpec("number", 'n', false, "number the lines"), + FlagSpec("lines", null, true, "maximum lines to print (default 500)"), + FlagSpec("offset", null, true, "skip this many lines first (page through a long file)"), + FlagSpec("json", null, false, "machine-readable: one JSON array of {path, lines, total, truncated, next_offset}"), + ), + execute = execute@ { ctx -> + val limit = ctx.parsed.flagValue("lines")?.toIntOrNull()?.coerceIn(1, 50_000) ?: 500 + val offset = ctx.parsed.flagValue("offset")?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val numbered = ctx.parsed.hasFlag("number") + val json = ctx.parsed.hasFlag("json") + val out = ArrayList() + val files = ArrayList() + var shown = 0 + var truncated = false + var nextOffset = 0 + for (arg in ctx.parsed.args) { + val path = VirtualPath.resolve(ctx.cwd, arg) + val entry = ctx.fs.stat(path) ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, path)) + if (entry.isDirectory) return@execute CommandResult.Failure("$path is a directory") + val text = when (val r = readTextStrict(ctx.fs, entry)) { + is TextRead.Ok -> r.text + else -> return@execute CommandResult.Failure(describeSkip(r, path)) + } + // Text mode prints the empty "line" after a final newline as before; JSON reports real lines only. + val all = text.lineSequence().toList().let { if (json && it.size > 1 && it.last().isEmpty()) it.dropLast(1) else it } + if (ctx.parsed.args.size > 1 && !json) out += "==> $path <==" + val fileLines = ArrayList() + var n = offset + var cut = false + for (line in all.drop(offset)) { + if (shown >= limit) { cut = true; truncated = true; nextOffset = n; break } + n++; shown++ + if (json) fileLines += line + else out += if (numbered) "%4d %s".format(n, line) else line + } + if (json) files += Json.obj( + "path" to path, "lines" to fileLines, "total" to all.size, "truncated" to cut, + "next_offset" to (if (cut) nextOffset else null), + ) + if (truncated) break + } + if (json) return@execute CommandResult.Success(listOf(Json.arr(files.map { Json.Raw(it) }))) + if (truncated) out += "[truncated at $limit lines — use --lines to raise, or --offset $nextOffset to continue]" + CommandResult.Success(out) + }, +) + +// ───────────────────────────── find ───────────────────────────── + +private fun snippet(s: String, max: Int = 160) = if (s.length > max) s.take(max) + "…" else s + +private class Hit(val path: String, val line: Int, val text: String, val more: List = emptyList()) + +private val findSpec = CommandSpec( + name = "find", + summary = "Find text inside files (grep), incl. multi-line blocks. Skips binary files", + usage = "find [path] [-r] [-i|-s] [-w] [-m] [--reindent] [--regex] [--name ] [--max N] [--offset N] [--count-only] [--json]", + minArgs = 1, + maxArgs = 2, + flags = listOf( + FlagSpec("recursive", 'r', false, "search directories recursively"), + FlagSpec("ignore-case", 'i', false, "ignore case (default: automatic when the pattern has no capitals)"), + FlagSpec("case", 's', false, "force case-sensitive matching"), + FlagSpec("word", 'w', false, "whole-word match"), + FlagSpec("multiline", 'm', false, "match across lines (automatic when the pattern contains a line break)"), + FlagSpec("reindent", null, false, "multi-line: ignore differences in indentation and trailing blanks"), + FlagSpec("dotall", null, false, "regex: let . match line breaks"), + FlagSpec("regex", null, false, "treat as a regular expression (^ and $ match per line)"), + FlagSpec("name", null, true, "only search files whose name matches this glob, e.g. *.kt (find does not expand wildcards in )"), + FlagSpec("max", null, true, "maximum matches to return (default 200)"), + FlagSpec("offset", null, true, "skip the first N matches (continue a capped result)"), + FlagSpec("count-only", null, false, "print only how many matches there are, not the matches"), + FlagSpec("summary", null, false, "same as --count-only"), + FlagSpec("json", null, false, "machine-readable output: {matches:[{path,line,text,more}], count, files, searched, skipped, truncated, next_offset}"), + ), + exitStatus = "0 = at least one match, 1 = no matches (grep-style, so 'find x dir || echo none' works), 2 = usage error, 3 = path not found", + execute = execute@ { ctx -> + val pattern = ctx.parsed.arg(0)!! + val root = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(1) ?: ".") + val recursive = ctx.parsed.hasFlag("recursive") + val regexMode = ctx.parsed.hasFlag("regex") + val reindent = ctx.parsed.hasFlag("reindent") + val countOnly = ctx.parsed.hasFlag("count-only") || ctx.parsed.hasFlag("summary") + val json = ctx.parsed.hasFlag("json") + val maxHits = if (countOnly) Int.MAX_VALUE else ctx.parsed.flagValue("max")?.toIntOrNull()?.coerceIn(1, 10_000) ?: 200 + val offset = ctx.parsed.flagValue("offset")?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val nameFilter = ctx.parsed.flagValue("name")?.let { Glob.toRegex(it, ignoreCase = true) } + + // Everything that depends on the caller's pattern is built inside this try, so a bad pattern is always + // reported as "Invalid pattern", never as an unexplained internal error. + val matcher = try { + val ignoreCase = when { + ctx.parsed.hasFlag("case") -> false + ctx.parsed.hasFlag("ignore-case") -> true + else -> BlockMatcher.smartCaseIgnore(pattern, regexMode) // "smart case" + } + BlockMatcher.build(pattern, regexMode, ignoreCase, ctx.parsed.hasFlag("word"), reindent, ctx.parsed.hasFlag("dotall")) + } catch (e: java.util.regex.PatternSyntaxException) { + return@execute CommandResult.Failure("Invalid pattern: ${e.description} (at position ${e.index} of '${e.pattern}')", ErrorCodes.USAGE) + } catch (e: Exception) { + return@execute CommandResult.Failure("Invalid pattern: ${e.message}", ErrorCodes.USAGE) + } + val ignoreCaseLiteral = when { + ctx.parsed.hasFlag("case") -> false + ctx.parsed.hasFlag("ignore-case") -> true + else -> BlockMatcher.smartCaseIgnore(pattern, regexMode) + } + val plainWord = !regexMode && !ctx.parsed.hasFlag("word") + // Whole-file scan when the pattern can cross lines: a line break in it, an escaped \n in a regex, (?s)… + val whole = ctx.parsed.hasFlag("multiline") || reindent || pattern.contains('\n') || matcher.spansLines + + val rootEntry = ctx.fs.stat(root) + ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, root, + wildcardHint = "'find' does not expand wildcards in : give a folder and use --name '' with -r.")) + val candidates: List = when { + !rootEntry.isDirectory -> listOf(rootEntry) + recursive -> ctx.fs.walk(root).filter { !it.isDirectory } + else -> ctx.fs.list(root).filter { !it.isDirectory } + }.filter { nameFilter == null || nameFilter.matches(it.name) } + + val hits = ArrayList(minOf(maxHits, 32)) + var matchCount = 0 // matches recorded (after --offset) + var seen = 0 // matches met, including skipped ones + var scanned = 0 + var skipped = 0 + val filesWithHits = HashSet() + var stoppedEarly = false + + for (entry in candidates) { + if (matchCount >= maxHits) { stoppedEarly = true; break } + coroutineContext.ensureActive() + if (++scanned % 25 == 0) ctx.progress("find: $scanned/${candidates.size} files, $matchCount matches") + if (entry.size > TEXT_SIZE_LIMIT) { skipped++; continue } + + if (whole) { + // Whole-file matching: the pattern may span lines. + val text = try { + when (val r = readTextStrict(ctx.fs, entry)) { is TextRead.Ok -> r.text; else -> null } + } catch (e: CancellationException) { throw e } catch (_: Exception) { null } + if (text == null) { skipped++; continue } + val counter = LineCounter(text) + for (m in matcher.regex.findAll(text)) { + if (matchCount >= maxHits) { stoppedEarly = true; break } + if (seen++ < offset) continue + matchCount++ + filesWithHits += entry.path + if (countOnly) continue + val lineNo = counter.lineAt(m.range.first) + val lines = m.value.split('\n').map { it.trimEnd('\r') } + // First line shown as it appears in the file (indentation kept): include text before the match on that line. + val ls = text.lastIndexOf('\n', m.range.first - 1) + 1 + val first = text.substring(ls, m.range.first) + lines[0] + val more = ArrayList() + lines.drop(1).take(4).forEach { more += snippet(it) } + if (lines.size > 5) more += "… ${lines.size - 5} more line(s)" + hits += Hit(entry.path, lineNo, snippet(first), more) + } + continue + } + + val reader = try { openTextReader(ctx.fs, entry.path) } + catch (e: CancellationException) { throw e } + catch (_: Exception) { skipped++; continue } + if (reader == null) { skipped++; continue } + + try { + var lineNo = 0 + while (matchCount < maxHits) { + val line = reader.readLine() ?: break + lineNo++ + if (lineNo % 4096 == 0) coroutineContext.ensureActive() + val matched = if (plainWord) line.contains(pattern, ignoreCaseLiteral) else matcher.regex.containsMatchIn(line) + if (matched) { + if (seen++ < offset) continue + matchCount++ + filesWithHits += entry.path + if (!countOnly) hits += Hit(entry.path, lineNo, snippet(line)) // indentation is kept as in the file + } + } + if (matchCount >= maxHits) stoppedEarly = true + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + skipped++ // unreadable mid-way; keep what we have + } finally { + try { reader.close() } catch (_: Exception) {} + } + } + + val capped = !countOnly && stoppedEarly && matchCount >= maxHits + val stats = "searched $scanned file(s)" + if (skipped > 0) ", skipped $skipped binary/large/unreadable" else "" + val status = if (matchCount == 0) 1 else 0 + + if (json) { + val body = Json.obj( + "matches" to hits.map { h -> Json.Raw(Json.obj("path" to h.path, "line" to h.line, "text" to h.text, "more" to h.more)) }, + "count" to matchCount, "files" to filesWithHits.size, "searched" to scanned, "skipped" to skipped, + "truncated" to capped, "next_offset" to (if (capped) offset + matchCount else null), + ) + return@execute CommandResult.Success(listOf(body), status = status) + } + if (matchCount == 0) { + // grep-style: no match is exit status 1, so 'find TODO dir || echo none' works in scripts. + CommandResult.Success( + listOf("No matches ($stats)." + if (!recursive && rootEntry.isDirectory) " Try -r to include subfolders." else ""), + status = 1, + ) + } else { + val out = ArrayList() + for (h in hits) { + out += "${h.path}:${h.line}: ${h.text}" + h.more.forEach { out += " │ $it" } + } + out += when { + countOnly -> "$matchCount match(es) in ${filesWithHits.size} file(s) ($stats)" + capped -> "[stopped at $maxHits matches — use --max to raise, or --offset ${offset + matchCount} to continue] ($stats)" + else -> "$matchCount match(es) in ${filesWithHits.size} file(s) ($stats)" + } + CommandResult.Success(out) + } + }, +) + +// ───────────────────────────── replace ───────────────────────────── + +private suspend fun readPatternFile(ctx: com.zipcli.ke.domain.CommandContext, arg: String): String { + val path = VirtualPath.resolve(ctx.cwd, arg) + val entry = ctx.fs.stat(path) ?: throw IllegalArgumentException(notFoundMessage(ctx.fs, path)) + if (entry.isDirectory) throw IllegalArgumentException("$path is a directory") + return when (val r = readTextStrict(ctx.fs, entry)) { + // Editors add a final newline; a block pattern normally should not include it. + is TextRead.Ok -> r.text.removeSuffix("\n").removeSuffix("\r") + else -> throw IllegalArgumentException(describeSkip(r, path)) + } +} + +private val replaceSpec = CommandSpec( + name = "replace", + summary = "Replace text (incl. multi-line code blocks) in a file, glob or directory. --dry-run previews", + usage = "replace [-r] [-i] [-w] [--regex] [--reindent] [--dry-run] [--backup] [--old-file F] [--new-file F] [--fail-on-no-match] [--json]", + minArgs = 1, + maxArgs = 3, + flags = listOf( + FlagSpec("recursive", 'r', false, "apply to every file under a directory"), + FlagSpec("ignore-case", 'i', false, "ignore case when matching "), + FlagSpec("word", 'w', false, "whole-word match"), + FlagSpec("regex", null, false, " is a regex: ^ and $ match per line, \$1 / \${name} work in "), + FlagSpec("dotall", null, false, "regex: let . match line breaks"), + FlagSpec("reindent", null, false, "multi-line: ignore indentation differences when matching and re-indent to fit the file"), + FlagSpec("dry-run", 'n', false, "show a before/after preview without writing anything"), + FlagSpec("backup", null, false, "keep the original as .bak (only if no .bak exists yet)"), + FlagSpec("old-file", null, true, "read from a file (easier than typing a block)"), + FlagSpec("new-file", null, true, "read from a file"), + FlagSpec("fail-on-no-match", null, false, "exit status 1 when nothing was replaced (like find)"), + FlagSpec("json", null, false, "machine-readable result: {replaced, files, dry_run, details:[{path,count}], skipped, errors}"), + ), + exitStatus = "0 = done (also when nothing matched, unless --fail-on-no-match), 1 = nothing matched with --fail-on-no-match or another failure, 2 = usage error, 3 = path not found", + execute = execute@ { ctx -> + val pathArg = ctx.parsed.arg(0)!! + val rest = ctx.parsed.args.drop(1).toMutableList() + val usage = "Usage: replace (or --old-file/--new-file)" + + val old: String + val new: String + try { + old = ctx.parsed.flagValue("old-file")?.let { readPatternFile(ctx, it) } + ?: rest.removeFirstOrNull() ?: return@execute CommandResult.Failure(usage) + new = ctx.parsed.flagValue("new-file")?.let { readPatternFile(ctx, it) } + ?: rest.removeFirstOrNull() ?: return@execute CommandResult.Failure(usage) + } catch (e: IllegalArgumentException) { + return@execute CommandResult.Failure(e.message ?: "Cannot read pattern file") + } + if (rest.isNotEmpty()) return@execute CommandResult.Failure(usage) + + val recursive = ctx.parsed.hasFlag("recursive") + val regexMode = ctx.parsed.hasFlag("regex") + val dryRun = ctx.parsed.hasFlag("dry-run") + val backup = ctx.parsed.hasFlag("backup") + val failOnNoMatch = ctx.parsed.hasFlag("fail-on-no-match") + val json = ctx.parsed.hasFlag("json") + val details = ArrayList>() + + if (old.isEmpty()) return@execute CommandResult.Failure(" must not be empty") + + val matcher = try { + BlockMatcher.build(old, regexMode, ctx.parsed.hasFlag("ignore-case"), ctx.parsed.hasFlag("word"), + ctx.parsed.hasFlag("reindent"), ctx.parsed.hasFlag("dotall")) + } catch (e: java.util.regex.PatternSyntaxException) { + return@execute CommandResult.Failure("Invalid pattern: ${e.description} (at position ${e.index} of '${e.pattern}')", ErrorCodes.USAGE) + } catch (e: Exception) { + return@execute CommandResult.Failure("Invalid pattern: ${e.message}", ErrorCodes.USAGE) + } + + // Resolve targets: a real path, or a wildcard like *.txt + val literal = VirtualPath.resolve(ctx.cwd, pathArg) + val roots: List = when { + ctx.fs.exists(literal) -> listOf(literal) + Glob.hasWildcard(pathArg) -> Glob.expand(ctx.fs, ctx.cwd, pathArg).map { VirtualPath.resolve(ctx.cwd, it) } + else -> emptyList() + } + if (roots.isEmpty()) { + return@execute CommandResult.Failure(notFoundMessage(ctx.fs, literal) + ". " + usage) + } + + val files = ArrayList() + for (r in roots) { + val e = ctx.fs.stat(r) ?: continue + if (e.isDirectory) { + if (!recursive) return@execute CommandResult.Failure("$r is a directory; use -r") + files += ctx.fs.walk(r).filter { !it.isDirectory } + } else files += e + } + + var filesChanged = 0 + var totalReplacements = 0 + var skipped = 0 + var errors = 0 + var firstError: String? = null + val detail = ArrayList() + val preview = ArrayList() + + for ((i, f) in files.withIndex()) { + coroutineContext.ensureActive() + if ((i + 1) % 20 == 0) ctx.progress("replace: ${i + 1}/${files.size} files") + try { + val text = when (val r = readTextStrict(ctx.fs, f)) { + is TextRead.Ok -> r.text + else -> { skipped++; continue } // never rewrite binary / huge files + } + val eol = BlockMatcher.dominantEol(text) + val (updated, count) = matcher.replaceAll(text, new, eol) + if (count > 0 && updated != text) { + if (dryRun && preview.size < 60) { + var shown = 0 + val counter = LineCounter(text) + for (m in matcher.regex.findAll(text)) { + if (shown++ >= 5 || preview.size >= 60) break + preview += "${f.path}:${counter.lineAt(m.range.first)}" + val ls = text.lastIndexOf('\n', m.range.first - 1) + 1 + val before = text.substring(ls, m.range.first) + m.value + val after = text.substring(ls, m.range.first) + matcher.replacementFor(m, new, eol, text) + before.split('\n').take(8).forEach { preview += " - " + snippet(it.trimEnd('\r')) } + after.split('\n').take(8).forEach { preview += " + " + snippet(it.trimEnd('\r')) } + } + } + if (!dryRun) { + if (backup && !ctx.fs.exists(f.path + ".bak")) ctx.fs.writeText(f.path + ".bak", text) + ctx.fs.writeText(f.path, updated) + } + filesChanged++ + totalReplacements += count + detail += " ${f.path}: $count" + details += f.path to count + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + errors++ + if (firstError == null) firstError = e.message ?: e.javaClass.simpleName + } + } + + val tail = buildString { + if (skipped > 0) append(" Skipped $skipped binary/large file(s).") + if (errors > 0) append(" $errors file(s) failed ($firstError).") + } + if (json) { + val body = Json.obj( + "replaced" to totalReplacements, "files" to filesChanged, "dry_run" to dryRun, + "details" to details.map { (p, c) -> Json.Raw(Json.obj("path" to p, "count" to c)) }, + "skipped" to skipped, "errors" to errors, "first_error" to firstError, + ) + return@execute CommandResult.Success(listOf(body), status = if (filesChanged == 0 && failOnNoMatch) 1 else 0) + } + if (filesChanged == 0) { + val hint = when { + errors > 0 || regexMode || !old.contains('\n') -> "" + !ctx.parsed.hasFlag("reindent") -> " Tip: if the indentation may differ, add --reindent." + else -> " Tip: with --reindent the block must start at the beginning of a line, and every line of it must match (ignoring indentation)." + } + CommandResult.Success(listOf("No replacements made.$tail$hint"), status = if (failOnNoMatch) 1 else 0) + } else { + val verb = if (dryRun) "Would replace" else "Replaced" + val out = ArrayList() + out += preview + if (files.size > 1 || dryRun) out += detail.take(30) + if (detail.size > 30) out += " … and ${detail.size - 30} more file(s) (add --json for the complete list)" + if (dryRun && filesChanged > 0 && preview.size >= 60) out += "[preview shows the first 60 lines only; the totals below are complete]" + out += "$verb $totalReplacements occurrence(s) across $filesChanged file(s).$tail" + + if (dryRun) " Nothing was written." else "" + CommandResult.Success(out) + } + }, +) + +private fun countOccurrences(haystack: String, needle: String): Int { + if (needle.isEmpty()) return 0 + var count = 0 + var idx = 0 + while (true) { + idx = haystack.indexOf(needle, idx) + if (idx < 0) break + count++ + idx += needle.length + } + return count +} + +// ───────────────────────────── clear ───────────────────────────── + +private val clearSpec = CommandSpec( + name = "clear", + summary = "Clear the terminal log", + usage = "clear", + maxArgs = 0, + execute = { CommandResult.Clear }, +) + +// ───────────────────────────── history ───────────────────────────── + +private val historySpec = CommandSpec( + name = "history", + summary = "Show recent commands (default: the last 20; also records commands run over MCP)", + usage = "history [n]", + maxArgs = 1, + execute = execute@ { ctx -> + val n = if (ctx.parsed.arg(0) == null) 20 else ctx.parsed.arg(0)!!.toIntOrNull() ?: 0 + if (n <= 0) return@execute CommandResult.Failure("Usage: history [n] (n must be a positive integer; no argument shows the last 20)") + if (ctx.history.isEmpty()) return@execute CommandResult.Success(listOf("(no commands recorded yet)")) + val shown = ctx.history.takeLast(n) + val offset = ctx.history.size - shown.size + CommandResult.Success( + shown.mapIndexed { i, cmd -> + "${(offset + i + 1).toString().padStart(4)} ${cmd.replace("\n", " ⏎ ")}" + } + ) + }, +) diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CoreCommands.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CoreCommands.kt new file mode 100644 index 0000000000..97c80436d5 --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/CoreCommands.kt @@ -0,0 +1,588 @@ +package com.zipcli.ke.domain.commands + +import com.zipcli.ke.domain.CommandRegistry +import com.zipcli.ke.domain.CommandResult +import com.zipcli.ke.domain.CommandSpec +import com.zipcli.ke.domain.ErrorCodes +import com.zipcli.ke.domain.FlagSpec +import com.zipcli.ke.domain.Glob +import com.zipcli.ke.domain.NaturalOrder +import com.zipcli.ke.domain.Suggest +import com.zipcli.ke.fs.FileEntry +import com.zipcli.ke.fs.VirtualPath +import kotlinx.coroutines.CancellationException + +/** + * Core command set: + * help, pwd, cd, list/ls, create, delete/rm, copy/cp, move/mv, rename, search. + * + * @param registryProvider late-bound accessor for the CommandRegistry (see TerminalViewModel). + * Needed only by `help`, which is registered alongside the commands it documents. + */ +fun buildCoreCommands(registryProvider: () -> CommandRegistry): List = listOf( + helpSpec(registryProvider), + infoSpec(registryProvider), + pwdSpec, + cdSpec, + listSpec, + createSpec, + deleteSpec, + copySpec, + moveSpec, + renameSpec, + searchSpec, +) + +// ───────────────────────────── help ───────────────────────────── + +internal val SCRIPTING_HELP: List = """ +Scripting — run many commands at once + QUOTING (read this first): + '...' LITERAL. No @D@ expansion; a backslash is just a backslash: 'C:\new\table.txt' is exactly that. + The only special thing is \' (a quote): 'it\'s' (the POSIX form 'it'\''s' works too). + "..." expands @D@NAME. Escapes: \\ \" \n \t and \@D@ gives a dollar before a variable name. + heredoc byte-exact multi-line text: no escapes, no expansion (use it for Windows paths, JSON, code): + update /home/a.json <<'EOF' + {"path": "C:\new\t.txt"} + EOF + NOT SUPPORTED (by design): pipes (|), redirection (> >> <), command substitution, arithmetic, background (&). + Instead: separate lines for a pipe; 'update --append' or a heredoc for >; 'set NAME=value' for substitution; + 'for' for xargs. A pasted prompt such as '@D@ ls' must be pasted without the prompt. + Paste several lines, or chain commands on one line: + cmd1; cmd2 run both, in order + cmd1 && cmd2 run cmd2 only if cmd1 succeeded + cmd1 || cmd2 run cmd2 only if cmd1 failed + # comment ignored. End a line with \ to continue it. + Variables: + set NAME=value then use @D@NAME or @D@{NAME}; @D@{NAME:-fallback} gives a default; 'set --reset' clears them all + @D@? last status @D@PWD current dir @D@1..@D@9 script arguments + Undefined variables are left exactly as typed. Single quotes never expand; double quotes do. + Loops and wildcards: + for f in *.txt; do copy @D@f /home/backup; done + zip, copy, move, delete, cat, list and for expand * ? and ** (zip all.zip **/*.log). + find, search and replace take a pattern instead: find uses --name '' with -r; search takes the glob itself. + Script files: + run backup.zcli [args] run a file in its own sub-session (-y answers prompts with yes) + check backup.zcli validate a script without running it (every problem at once, with line numbers) + Scripts stop at the first error. 'set +e' keeps going, 'set -x' traces each command. + Confirmations: delete asks first. Add --yes (or -y) to that command, or 'set -y' for the whole session. + Over MCP a prompt stops the batch BEFORE the prompt and reports how many commands did not run. + Errors look like line 4: [E_NOT_FOUND] Not found: ... Codes and exit statuses: 'help exit-codes'. + Helpers: echo, test -e|-f|-d , test a = b, true, false, exit [n], info, check +""".trimIndent().replace("@D@", "$").lines() + +internal val EXIT_CODES_HELP = listOf( + "Exit statuses (also shown as [exit N] over MCP, and in \$?)", + " 0 success", + " 1 no match (find, search, replace --fail-on-no-match), a false test, or another failure", + " 2 usage error: unknown command, unknown flag, wrong number of arguments, syntax error", + " 3 path not found", + " 4 needs confirmation (the batch stopped before the prompt; nothing after it ran)", + " 5 partial batch: some commands did not run", + "Error codes (printed as [E_...] before the message):", + " E_USAGE E_NOT_FOUND E_IS_DIR E_NEEDS_CONFIRM E_INTERNAL (a bug in ZipCLI, not your script) E_FAILED (anything else)", +) + +private fun flagJson(f: FlagSpec) = Json.Raw(Json.obj( + "long" to f.long, "short" to f.short?.toString(), "takes_value" to f.takesValue, "description" to f.description, +)) + +private fun specJson(spec: CommandSpec) = Json.Raw(Json.obj( + "name" to spec.name, "aliases" to spec.aliases, "summary" to spec.summary, "usage" to spec.usage, + "min_args" to spec.minArgs, "max_args" to (if (spec.maxArgs == Int.MAX_VALUE) null else spec.maxArgs), + "expands_wildcards" to spec.expandGlobs, "may_confirm" to spec.mayConfirm, + "exit_status" to spec.exitStatus, "flags" to spec.flags.map { flagJson(it) }, +)) + +private fun helpSpec(registryProvider: () -> CommandRegistry) = CommandSpec( + name = "help", + aliases = listOf("?"), + summary = "Show commands or help for one command (--json for the flag schema)", + usage = "help [command | scripting | exit-codes] [--json]", + maxArgs = 1, + flags = listOf( + FlagSpec("json", null, false, "machine-readable: the command list or one command's flags as JSON"), + FlagSpec("machine", null, false, "same as --json"), + ), + execute = execute@ { ctx -> + val target = ctx.parsed.arg(0) + val json = ctx.parsed.hasFlag("json") || ctx.parsed.hasFlag("machine") + if (target == null) { + if (json) return@execute CommandResult.Success(listOf(Json.arr(registryProvider().all().map { specJson(it) }))) + val lines = buildList { + add("Available commands:") + registryProvider().all().forEach { spec -> + add(" ${spec.name.padEnd(10)} ${spec.summary}") + } + add("") + add("Tip: 'help ' for flags, 'help scripting' for quoting and batches, 'help exit-codes' for statuses.") + } + return@execute CommandResult.Success(lines) + } + if (target.lowercase().let { it == "scripting" || it == "script" || it == "scripts" || it == "quoting" }) { + return@execute CommandResult.Success(SCRIPTING_HELP) + } + if (target.lowercase().let { it == "exit-codes" || it == "codes" || it == "errors" }) { + return@execute CommandResult.Success(EXIT_CODES_HELP) + } + + val spec = registryProvider().find(target) + ?: return@execute CommandResult.Failure( + "Unknown command: $target" + + (Suggest.closest(target, registryProvider().names())?.let { ". Did you mean '$it'?" } ?: "") + ) + if (json) return@execute CommandResult.Success(listOf(specJson(spec).json)) + + val lines = buildList { + add(spec.name + if (spec.aliases.isEmpty()) "" else " (${spec.aliases.joinToString()})") + add(" ${spec.summary}") + add(" Usage: ${spec.usage}") + if (spec.expandGlobs) add(" Wildcards (* ? **) in arguments are expanded.") + if (spec.mayConfirm) add(" Asks for confirmation; add --yes (or -y) to authorise it in a script.") + spec.exitStatus?.let { add(" Exit status: $it") } + add(" All statuses and error codes: help exit-codes") + if (spec.flags.isNotEmpty()) { + add(" Flags:") + spec.flags.forEach { f -> + val flag = (if (f.short != null) "-${f.short}, " else " ") + + "--${f.long}" + if (f.takesValue) " " else "" + add(" ${flag.padEnd(24)} ${f.description}") + } + } + } + CommandResult.Success(lines) + }, +) + +// ───────────────────────────── info ───────────────────────────── + +private fun infoSpec(registryProvider: () -> CommandRegistry) = CommandSpec( + name = "info", + summary = "Capability report: version, mounts, available commands, limits, shell state", + usage = "info [--json]", + maxArgs = 0, + flags = listOf(FlagSpec("json", null, false, "machine-readable report")), + execute = execute@ { ctx -> + val reg = registryProvider() + val s = ctx.session + val mounts = try { ctx.fs.list("/").map { "/" + it.name } } catch (e: CancellationException) { throw e } catch (_: Exception) { emptyList() } + val have = reg.all().map { it.name } + val unavailable = com.zipcli.ke.domain.CommandParser.APP_ONLY_COMMANDS.filter { it !in have } + val limits = linkedMapOf( + "cat_lines" to Limits.CAT_LINES, "cat_lines_max" to Limits.CAT_LINES_MAX, + "find_matches" to Limits.FIND_MATCHES, "find_matches_max" to Limits.FIND_MATCHES_MAX, + "mcp_output_chars" to Limits.MCP_OUTPUT_CHARS, "mcp_command_chars" to Limits.MCP_COMMAND_CHARS, + "script_commands" to Limits.SCRIPT_COMMANDS, + ) + if (ctx.parsed.hasFlag("json")) { + val body = Json.obj( + "version" to Limits.APP_VERSION, "transport" to ctx.transport, "cwd" to ctx.cwd, "mounts" to mounts, + "commands" to have, "unavailable_here" to unavailable, + "errexit" to s.errexit, "trace" to s.trace, "auto_yes" to s.autoYes, + "variables" to Json.Raw(Json.obj(*s.vars.toSortedMap().map { (k, v) -> k to v as Any? }.toTypedArray())), + "limits" to Json.Raw(Json.obj(*limits.map { (k, v) -> k to v as Any? }.toTypedArray())), + "exit_codes" to Json.Raw(Json.obj( + "ok" to 0, "no_match_or_failed" to 1, "usage" to 2, "not_found" to 3, "needs_confirmation" to 4, "partial" to 5)), + "error_codes" to ErrorCodes.ALL, + ) + return@execute CommandResult.Success(listOf(body)) + } + val lines = ArrayList() + lines += "ZipCLI ${Limits.APP_VERSION} transport: ${ctx.transport}" + lines += "cwd: ${ctx.cwd}" + lines += "mounts: " + (if (mounts.isEmpty()) "(unknown)" else mounts.joinToString(", ")) + lines += "commands: ${have.joinToString(" ")}" + if (unavailable.isNotEmpty()) lines += "not available in this session: ${unavailable.joinToString(", ")} (app-only: interactive or manage the server)" + lines += "shell: errexit=${if (s.errexit) "on" else "off"} trace=${if (s.trace) "on" else "off"} auto-yes=${if (s.autoYes) "on" else "off"} variables=${s.vars.size}" + lines += "limits: cat ${Limits.CAT_LINES} lines (--lines, max ${Limits.CAT_LINES_MAX}); find ${Limits.FIND_MATCHES} matches (--max, max ${Limits.FIND_MATCHES_MAX}); " + + "MCP output ${Limits.MCP_OUTPUT_CHARS} characters; MCP command ${Limits.MCP_COMMAND_CHARS} characters; ${Limits.SCRIPT_COMMANDS} commands per script" + lines += "no pipes, no redirection, no command substitution, no arithmetic. See: help scripting, help exit-codes" + CommandResult.Success(lines) + }, +) + +// ───────────────────────────── pwd ───────────────────────────── + +private val pwdSpec = CommandSpec( + name = "pwd", + summary = "Print current directory", + usage = "pwd", + maxArgs = 0, + execute = { ctx -> CommandResult.Success(listOf(ctx.cwd)) }, +) + +// ───────────────────────────── cd ───────────────────────────── + +private val cdSpec = CommandSpec( + name = "cd", + summary = "Change directory (no argument: /home, '-': previous)", + usage = "cd [path | -]", + maxArgs = 1, + execute = execute@ { ctx -> + val arg = ctx.parsed.arg(0) + val back = arg == "-" + val target = when { + arg == null -> "/home" + back -> ctx.session.vars["OLDPWD"] + ?: return@execute CommandResult.Failure("No previous directory") + else -> VirtualPath.resolve(ctx.cwd, arg) + } + val entry = ctx.fs.stat(target) + ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, target, dirsOnly = true).replace("Not found", "No such directory")) + if (!entry.isDirectory) { + return@execute CommandResult.Failure("Not a directory: $target") + } + CommandResult.Success(if (back) listOf(target) else emptyList(), newCwd = target) + }, +) + +// ───────────────────────────── list / ls ───────────────────────────── + +private val listSpec = CommandSpec( + name = "list", + aliases = listOf("ls"), + summary = "List directory contents (folders first); wildcards like *.md are expanded", + usage = "list [path...] [-l] [-a] [--json]", + expandGlobs = true, + flags = listOf( + FlagSpec("long", 'l', false, "long format with size and date"), + FlagSpec("all", 'a', false, "include hidden entries"), + FlagSpec("json", null, false, "machine-readable: [{path, entries:[{name,type,size,mtime}]}]"), + ), + execute = execute@ { ctx -> + val targets = if (ctx.parsed.args.isEmpty()) listOf(".") else ctx.parsed.args + val json = ctx.parsed.hasFlag("json") + val long = ctx.parsed.hasFlag("long") + val out = ArrayList() + val blocks = ArrayList() + for ((idx, arg) in targets.withIndex()) { + val path = VirtualPath.resolve(ctx.cwd, arg) + val root = ctx.fs.stat(path) + ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, path)) + val entries: List = if (root.isDirectory) { + ctx.fs.list(path).filter { ctx.parsed.hasFlag("all") || !it.name.startsWith(".") } + } else listOf(root) + val sorted = entries.sortedWith( + compareByDescending { it.isDirectory }.thenComparator { a, b -> NaturalOrder.compare(a.name, b.name) } + ) + if (json) { + blocks += Json.Raw(Json.obj("path" to path, "entries" to sorted.map { e -> + Json.Raw(Json.obj("name" to e.name, "type" to (if (e.isDirectory) "dir" else "file"), + "size" to (if (e.isDirectory) null else e.size), "mtime" to e.lastModified)) + })) + continue + } + if (targets.size > 1 && root.isDirectory) { if (idx > 0 && out.isNotEmpty()) out += ""; out += "$path:" } + when { + sorted.isEmpty() -> out += "(empty)" + long -> sorted.forEach { e -> + val type = if (e.isDirectory) "d" else "-" + val size = if (e.isDirectory) "-" else formatSize(e.size) + out += "%s %8s %s %s".format(type, size, formatDate(e.lastModified), e.name + if (e.isDirectory) "/" else "") + } + else -> sorted.forEach { e -> out += if (e.isDirectory) "${e.name}/" else e.name } + } + } + if (json) CommandResult.Success(listOf(Json.arr(blocks))) else CommandResult.Success(out) + }, +) + +// ───────────────────────────── create ───────────────────────────── + +private val createSpec = CommandSpec( + name = "create", + summary = "Create files or directories (a trailing / means directory)", + usage = "create [--dir] [--content | <<'EOF' heredoc]", + minArgs = 1, + flags = listOf( + FlagSpec("dir", 'd', false, "create directories"), + FlagSpec("content", null, true, "initial file content. '...' is literal (only \\' is special); for byte-exact text such as Windows paths or JSON use a heredoc: create f <<'EOF' ... EOF"), + ), + execute = execute@ { ctx -> + val wantDir = ctx.parsed.hasFlag("dir") + val targets = ctx.parsed.args.map { raw -> + Triple(raw, VirtualPath.resolve(ctx.cwd, raw), wantDir || raw.endsWith("/")) + } + for ((_, path, _) in targets) { + if (ctx.fs.exists(path)) return@execute CommandResult.Failure("Already exists: $path") + } + val content = ctx.parsed.flagValue("content") + val out = ArrayList() + for ((_, path, isDir) in targets) { + if (isDir) { + ctx.fs.mkdirs(path) + out += "Created directory $path" + } else { + if (content != null) ctx.fs.writeText(path, content) else ctx.fs.createFile(path) + out += "Created file $path" + } + } + CommandResult.Success(out) + }, +) + +// ───────────────────────────── delete / rm ───────────────────────────── + +private val deleteSpec = CommandSpec( + name = "delete", + aliases = listOf("rm"), + summary = "Delete files or directories (asks first, shows what is inside)", + usage = "delete [-r] [-f]", + minArgs = 1, + expandGlobs = true, + mayConfirm = true, + exitStatus = "0 = deleted (or already gone with -f), 1 = error, 3 = path not found, 4 = needs confirmation (stopped before the prompt over MCP)", + flags = listOf( + FlagSpec("recursive", 'r', false, "delete directories recursively"), + FlagSpec("force", 'f', false, "skip confirmation and paths that do not exist (--yes / -y skips only the confirmation)"), + ), + execute = execute@ { ctx -> + val recursive = ctx.parsed.hasFlag("recursive") + val force = ctx.parsed.hasFlag("force") + val paths = ctx.parsed.args.map { VirtualPath.resolve(ctx.cwd, it) }.distinct() + val entries = ArrayList() + val missing = ArrayList() + for (p in paths) { + if (isProtectedRoot(p)) { + return@execute CommandResult.Failure("Refusing to delete $p: it is a storage root") + } + val entry = ctx.fs.stat(p) + if (entry == null) { + // Like 'rm -f': a path that is already gone is not an error, so a script can + // clear a previous run without an existence guard. + if (force) { missing += p; continue } + return@execute CommandResult.Failure(notFoundMessage(ctx.fs, p)) + } + if (entry.isDirectory && !recursive) { + return@execute CommandResult.Failure("$p is a directory; use -r") + } + entries += entry + } + if (entries.isEmpty()) { + return@execute CommandResult.Success(listOf("Nothing to delete: ${missing.joinToString()} (already gone)")) + } + + suspend fun doDelete(): CommandResult { + val errors = ArrayList() + var done = 0 + for (e in entries) { + try { ctx.fs.delete(e.path, recursive); done++ } + catch (ex: CancellationException) { throw ex } + catch (ex: Exception) { errors += "${e.path}: ${ex.message ?: ex.javaClass.simpleName}" } + } + return when { + errors.isNotEmpty() -> CommandResult.Failure( + (if (done > 0) "Deleted $done, but failed on:\n" else "Delete failed:\n") + errors.joinToString("\n")) + entries.size == 1 -> CommandResult.Success(listOf("Deleted ${entries[0].path}")) + else -> CommandResult.Success(listOf("Deleted $done items")) + } + } + + if (ctx.parsed.hasFlag("force")) return@execute doDelete() + + // Tell the user exactly what they are about to lose. + var files = 0; var dirs = 0; var bytes = 0L + for (e in entries) { + if (e.isDirectory) { + ctx.progress("Counting ${e.path}…") + val s = treeStats(ctx.fs, e.path) + dirs += 1 + s.dirs; files += s.files; bytes += s.bytes + } else { files++; bytes += e.size } + } + val what = when { + entries.size == 1 && !entries[0].isDirectory -> "file ${entries[0].path} (${formatSize(bytes)})" + entries.size == 1 -> "directory ${entries[0].path} ($files files, $dirs folders, ${formatSize(bytes)})" + else -> "${entries.size} items ($files files, $dirs folders, ${formatSize(bytes)})" + } + CommandResult.Confirmation("Delete $what? [y/N]") { doDelete() } + }, +) + +// ───────────────────────────── copy / cp ───────────────────────────── + +private val copySpec = CommandSpec( + name = "copy", + aliases = listOf("cp"), + summary = "Copy files or directories (into a directory if the target is one)", + usage = "copy [-r]", + minArgs = 2, + expandGlobs = true, + flags = listOf( + FlagSpec("recursive", 'r', false, "accepted for convenience; directories are always copied recursively"), + ), + execute = execute@ { ctx -> + val srcs = ctx.parsed.args.dropLast(1).map { VirtualPath.resolve(ctx.cwd, it) } + val dst = VirtualPath.resolve(ctx.cwd, ctx.parsed.args.last()) + val dstEntry = ctx.fs.stat(dst) + if (srcs.size > 1 && dstEntry?.isDirectory != true) { + return@execute CommandResult.Failure("With several sources the target must be an existing directory: $dst") + } + + class Job(val src: String, val target: String, val replaces: Boolean) + val jobs = ArrayList() + for (src in srcs) { + val entry = ctx.fs.stat(src) ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, src)) + val target = resolveTarget(ctx.fs, src, dst) + if (VirtualPath.normalize(src) == target) { + return@execute CommandResult.Failure("'$src' and '$target' are the same") + } + if (entry.isDirectory && isInside(src, target)) { + return@execute CommandResult.Failure("Cannot copy $src into itself ($target)") + } + val existing = ctx.fs.stat(target) + if (existing != null && existing.isDirectory != entry.isDirectory) { + return@execute CommandResult.Failure("Cannot overwrite $target: one is a file, the other a directory") + } + jobs += Job(src, target, existing != null && !entry.isDirectory) + } + + for ((i, j) in jobs.withIndex()) { + ctx.progress("Copying ${j.src} (${i + 1}/${jobs.size})") + ctx.fs.copy(j.src, j.target, recursive = true) + } + if (jobs.size == 1) { + val j = jobs[0] + CommandResult.Success(listOf("Copied ${j.src} -> ${j.target}" + if (j.replaces) " (replaced existing file)" else "")) + } else { + val replaced = jobs.count { it.replaces } + CommandResult.Success(listOf("Copied ${jobs.size} items to $dst" + if (replaced > 0) " ($replaced replaced)" else "")) + } + }, +) + +// ───────────────────────────── move / mv ───────────────────────────── + +private val moveSpec = CommandSpec( + name = "move", + aliases = listOf("mv"), + summary = "Move or rename (into a directory if the target is one)", + usage = "move ", + minArgs = 2, + expandGlobs = true, + execute = execute@ { ctx -> + val srcs = ctx.parsed.args.dropLast(1).map { VirtualPath.resolve(ctx.cwd, it) } + val dst = VirtualPath.resolve(ctx.cwd, ctx.parsed.args.last()) + val dstEntry = ctx.fs.stat(dst) + if (srcs.size > 1 && dstEntry?.isDirectory != true) { + return@execute CommandResult.Failure("With several sources the target must be an existing directory: $dst") + } + + val pairs = ArrayList>() + for (src in srcs) { + val entry = ctx.fs.stat(src) ?: return@execute CommandResult.Failure(notFoundMessage(ctx.fs, src)) + if (isProtectedRoot(src)) return@execute CommandResult.Failure("Refusing to move $src: it is a storage root") + val target = resolveTarget(ctx.fs, src, dst) + if (VirtualPath.normalize(src) == target) { + return@execute CommandResult.Failure("'$src' and '$target' are the same") + } + if (entry.isDirectory && isInside(src, target)) { + return@execute CommandResult.Failure("Cannot move $src into itself ($target)") + } + if (ctx.fs.exists(target)) return@execute CommandResult.Failure("Target exists: $target") + if (pairs.any { it.second == target }) return@execute CommandResult.Failure("Two sources would both become $target") + pairs += src to target + } + + for ((i, p) in pairs.withIndex()) { + ctx.progress("Moving ${p.first} (${i + 1}/${pairs.size})") + ctx.fs.move(p.first, p.second) + } + if (pairs.size == 1) CommandResult.Success(listOf("Moved ${pairs[0].first} -> ${pairs[0].second}")) + else CommandResult.Success(listOf("Moved ${pairs.size} items to $dst")) + }, +) + +// ───────────────────────────── rename ───────────────────────────── + +private val renameSpec = CommandSpec( + name = "rename", + summary = "Rename within the same directory", + usage = "rename ", + minArgs = 2, + maxArgs = 2, + execute = execute@ { ctx -> + val src = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(0)!!) + val newName = ctx.parsed.arg(1)!! + if (newName.isEmpty() || newName.contains('/')) { + return@execute CommandResult.Failure("rename takes a bare name; use move for paths") + } + if (!ctx.fs.exists(src)) return@execute CommandResult.Failure(notFoundMessage(ctx.fs, src)) + if (isProtectedRoot(src)) return@execute CommandResult.Failure("Refusing to rename $src: it is a storage root") + val parent = src.substringBeforeLast('/', missingDelimiterValue = "") + val dst = if (parent.isEmpty()) "/$newName" else "$parent/$newName" + if (ctx.fs.exists(dst)) return@execute CommandResult.Failure("Target exists: $dst") + ctx.fs.move(src, dst) + CommandResult.Success(listOf("Renamed $src -> $dst")) + }, +) + +// ───────────────────────────── search (filenames) ───────────────────────────── + +private val searchSpec = CommandSpec( + name = "search", + summary = "Find files by name (plain text = contains; or glob * ?)", + usage = "search [path] [-r] [-s] [--max N] [--offset N] [--count-only] [--json]", + minArgs = 1, + maxArgs = 2, + flags = listOf( + FlagSpec("recursive", 'r', false, "search recursively"), + FlagSpec("case", 's', false, "case-sensitive (default: ignore case unless the pattern has capitals)"), + FlagSpec("max", null, true, "maximum results to print (default ${Limits.SEARCH_RESULTS})"), + FlagSpec("offset", null, true, "skip the first N results (continue a capped list)"), + FlagSpec("count-only", null, false, "print only how many files match"), + FlagSpec("json", null, false, "machine-readable: {results:[{path,type,size,mtime}], count, truncated, next_offset}"), + ), + exitStatus = "0 = at least one match, 1 = no matches (grep-style), 2 = usage error, 3 = path not found", + execute = execute@ { ctx -> + val pattern = ctx.parsed.arg(0)!! + val root = VirtualPath.resolve(ctx.cwd, ctx.parsed.arg(1) ?: ".") + val recursive = ctx.parsed.hasFlag("recursive") + val json = ctx.parsed.hasFlag("json") + val countOnly = ctx.parsed.hasFlag("count-only") + val max = ctx.parsed.flagValue("max")?.toIntOrNull()?.coerceIn(1, 100_000) ?: Limits.SEARCH_RESULTS + val offset = ctx.parsed.flagValue("offset")?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + + // Smart matching: "report" means "*report*"; case is ignored unless the pattern has capitals. + val glob = if (Glob.hasWildcard(pattern)) pattern else "*$pattern*" + val ignoreCase = !ctx.parsed.hasFlag("case") && pattern.none { it.isUpperCase() } + val regex = Glob.toRegex(glob, ignoreCase) + + if (ctx.fs.stat(root) == null) return@execute CommandResult.Failure(notFoundMessage(ctx.fs, root, + wildcardHint = "'search' does not expand wildcards in : pass a folder, and put the glob in (search '*.md' /home -r).")) + + val entries = if (recursive) { + ctx.fs.walk(root).filter { it.path != root } + } else { + ctx.fs.list(root) + } + + val matches = entries + .filter { regex.matches(it.name) } + .sortedWith { a, b -> NaturalOrder.compare(a.path, b.path) } + + if (matches.isEmpty()) { + // Same convention as find/grep: nothing found is status 1. + val text = when { + json -> Json.obj("results" to emptyList(), "count" to 0, "truncated" to false, "next_offset" to null) + !recursive -> "No matches in $root. Try -r to search subfolders." + else -> "No matches." + } + return@execute CommandResult.Success(listOf(text), status = 1) + } + val page = matches.drop(offset).take(max) + val capped = offset + page.size < matches.size + when { + json -> CommandResult.Success(listOf(Json.obj( + "results" to page.map { e -> Json.Raw(Json.obj("path" to e.path, "type" to (if (e.isDirectory) "dir" else "file"), + "size" to (if (e.isDirectory) null else e.size), "mtime" to e.lastModified)) }, + "count" to matches.size, "truncated" to capped, "next_offset" to (if (capped) offset + page.size else null), + ))) + countOnly -> CommandResult.Success(listOf("${matches.size} match(es)")) + else -> { + val out = ArrayList(page.map { if (it.isDirectory) it.path + "/" else it.path }) + if (capped) out += "[showing ${page.size} of ${matches.size} — use --max to raise, or --offset ${offset + page.size} to continue]" + CommandResult.Success(out) + } + } + }, +) diff --git a/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ScriptCommands.kt b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ScriptCommands.kt new file mode 100644 index 0000000000..a19d7923da --- /dev/null +++ b/services/zipcli-core/src/main/kotlin/com/zipcli/ke/domain/commands/ScriptCommands.kt @@ -0,0 +1,203 @@ +package com.zipcli.ke.domain.commands + +import com.zipcli.ke.domain.CommandResult +import com.zipcli.ke.domain.CommandSpec +import com.zipcli.ke.domain.FlagSpec +import com.zipcli.ke.fs.VirtualPath + +/** Commands that exist mainly for scripts. They are ordinary commands, so they also work at the prompt. */ +fun buildScriptCommands(): List = listOf( + echoSpec, setSpec, unsetSpec, runSpec, checkSpec, exitSpec, testSpec, trueSpec, falseSpec, +) + +private val NAME_RE = Regex("[A-Za-z_][A-Za-z0-9_]*") +private val RESERVED = setOf("PWD", "CWD") + +private val echoSpec = CommandSpec( + name = "echo", + summary = "Print text (use -- before text that starts with -)", + usage = "echo [text...]", + execute = { ctx -> CommandResult.Success(listOf(ctx.parsed.args.joinToString(" "))) }, +) + +private val setSpec = CommandSpec( + name = "set", + summary = "Set variables or shell options (-e/+e stop on error, -x/+x trace, -y/+y auto-confirm, --reset)", + usage = "set [NAME=value ...] [-e|+e] [-x|+x] [-y|+y] [--reset]", + flags = listOf( + FlagSpec("yes", 'y', false, "answer yes to every confirmation prompt for the rest of the session. +y turns it off"), + FlagSpec("reset", null, false, "forget every variable and restore the defaults (errexit on, trace off, auto-yes off)"), + FlagSpec("errexit", 'e', false, "stop at the first failing command (default). +e turns it off"), + FlagSpec("trace", 'x', false, "print each command before running it. +x turns it off"), + ), + execute = execute@ { ctx -> + val s = ctx.session + val args = ctx.parsed.args + var changed = false + + if (ctx.parsed.hasFlag("reset")) { + s.vars.clear(); s.errexit = true; s.trace = false; s.autoYes = false; changed = true + } + if (ctx.parsed.hasFlag("yes")) { s.autoYes = true; changed = true } + if (ctx.parsed.hasFlag("errexit")) { s.errexit = true; changed = true } + if (ctx.parsed.hasFlag("trace")) { s.trace = true; changed = true } + + val assignments = ArrayList>() + val rest = ArrayList() + for (a in args) { + if (a.length >= 2 && a[0] == '+' && a.drop(1).all { it == 'e' || it == 'x' || it == 'y' }) { + if ('e' in a) s.errexit = false + if ('x' in a) s.trace = false + if ('y' in a) s.autoYes = false + changed = true + } else rest += a + } + // "set NAME value" is accepted as well as "set NAME=value" + if (rest.size == 2 && !rest[0].contains('=') && NAME_RE.matches(rest[0])) { + assignments += rest[0] to rest[1] + } else { + for (a in rest) { + val eq = a.indexOf('=') + if (eq <= 0) return@execute CommandResult.Failure("Usage: set NAME=value (got '$a')") + assignments += a.substring(0, eq) to a.substring(eq + 1) + } + } + for ((k, v) in assignments) { + if (!NAME_RE.matches(k)) return@execute CommandResult.Failure("Invalid variable name: $k") + if (k in RESERVED) return@execute CommandResult.Failure("$k is read-only") + s.vars[k] = v + changed = true + } + + if (!changed) { + val lines = ArrayList() + lines += "errexit=${if (s.errexit) "on" else "off"} trace=${if (s.trace) "on" else "off"} auto-yes=${if (s.autoYes) "on" else "off"}" + if (s.vars.isEmpty()) lines += "(no variables)" + s.vars.toSortedMap().forEach { (k, v) -> lines += "$k=$v" } + return@execute CommandResult.Success(lines) + } + CommandResult.Success() + }, +) + +private val unsetSpec = CommandSpec( + name = "unset", + summary = "Remove variables", + usage = "unset ", + minArgs = 1, + execute = { ctx -> + ctx.parsed.args.forEach { ctx.session.vars.remove(it) } + CommandResult.Success() + }, +) + +private val runSpec = CommandSpec( + name = "run", + aliases = listOf("source"), + summary = "Run a script file (own sub-session; arguments become \$1, \$2 …)", + usage = "run [-y]