Alias for bash commands #326

Closed
opened 2026-10-25 09:50:28 +01:00 by kfickel · 2 comments
Owner

Extend alias so that it can also execute bash commands in the context of the folder.

Keep the syntax clear and understandable to distinguish which is which.

Example use case is a bash script streamd sync I'd like to include into .streamd.toml that adds all streamd files, commits them, pulls, and then pushes.

Make editing aliases available in the Android app, but not usable.

Extend alias so that it can also execute bash commands in the context of the folder. Keep the syntax clear and understandable to distinguish which is which. Example use case is a bash script `streamd sync` I'd like to include into .streamd.toml that adds all streamd files, commits them, pulls, and then pushes. Make editing aliases available in the Android app, but not usable.
Collaborator

Plan: shell aliases

Decisions

  • Syntax: type = "alias" keeps its two kinds apart by the field: exactly one of command (a streamd command, as today) or shell (a script). Both, or neither, is an error.
  • Interpreter: $SHELL -c SCRIPT; if $SHELL is unset or empty, sh.
  • Arguments: what follows the alias goes to the script as $1, $2, … ($SHELL -c SCRIPT streamd-<key> ARGS…; "$@" is all of them). Nothing is spliced into the text of the script.
  • Working directory: the stream folder (the base folder). STREAMD_BASE_FOLDER is set to it, so a streamd the script calls uses the same stream.
  • Android: the alias form can edit both kinds. A shell alias is marked as only running from the command line. The app never runs it.
[commands.sync]
type = "alias"
description = "Commit, pull and push the notes"
shell = """
git add -A
git commit -m "streamd sync" || true
git pull --rebase
git push
"""

1. File model (core, config/project/file.rs), with serde types

The table is read through a derived raw struct and turned into an enum by try_from. The raw struct keeps deny_unknown_fields, so an unknown field is still reported at its line with the list of fields. The either/or error lands on the command's table, where a missing required field is reported today.

/// What an alias stands for: exactly one of its two fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AliasTarget {
    /// `command = "new edit"`: a configured command and its arguments.
    Command(String),
    /// `shell = "git pull && git push"`: a script for `$SHELL -c`.
    Shell(String),
}

/// A `type = "alias"` command, as written.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "AliasFields")]
pub struct AliasCommandFile {
    pub description: Option<String>,
    pub target: AliasTarget,
}

/// The table as written, before it is known which of the two it is.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AliasFields {
    description: Option<String>,
    command: Option<String>,
    shell: Option<String>,
}

impl TryFrom<AliasFields> for AliasCommandFile {
    type Error = String;

    fn try_from(fields: AliasFields) -> Result<Self, String> {
        let target = match (fields.command, fields.shell) {
            (Some(command), None) => AliasTarget::Command(command),
            (None, Some(script)) => AliasTarget::Shell(script),
            (None, None) => return Err("needs `command` (a streamd command, like \"new edit\") \
                                        or `shell` (a script for $SHELL)".into()),
            (Some(_), Some(_)) => return Err("has both `command` and `shell`; keep one of them".into()),
        };
        Ok(Self { description: fields.description, target })
    }
}

Why not #[serde(flatten)] on an externally tagged enum: flatten turns off deny_unknown_fields and buffers the table, which loses the error lines (the same reason ProjectConfigFile::parse avoids tagged enums). Tests: both kinds parse; both fields, neither field and an unknown field give errors at the right line.

2. Runtime type and validation (core)

  • commands/alias/mod.rs: AliasCommand { description, target: Alias }, with enum Alias { Command(Vec<String>), Shell(String) }. stands_for() gives streamd new edit or the first line of the script (with … if there is more).
  • validate.rs: the command checks stay as they are. A shell that is empty or only whitespace is an error at commands.<key>.shell. A command alias MUST NOT name a shell alias either (it is still an alias of an alias). default_command MAY name a shell alias.
  • schema.rs: add shell to ALIAS_COMMAND ("A script run by $SHELL in the stream folder; arguments are $1, $2, …") and reword command. The schema test holds this against the serde model.
  • change.rs: write command or shell and remove the other one when the kind changes. Use a multi-line string if the script has a newline. Test the round trip of both kinds and a switch between them.
  • complete.rs / index.rs / rename.rs: only command refers to a command key. shell is not indexed, so rename_command leaves a script that calls streamd old alone (say so in R32).

3. CLI

  • alias/cli.rs: the clap command is the same for both kinds (no flags of its own, trailing args). The help is Runs \`` when there is no description.
  • commands/cli.rs::alias_arguments returns None for a shell alias. run gets the shell arm: new alias/run.rs with a pure shell_invocation(shell_env: Option<OsString>, script, key, args) -> (program, Vec<OsString>) (unit-tested), then std::process::Command with current_dir(base_folder), STREAMD_BASE_FOLDER, inherited stdio. streamd exits with the script's exit code (1 if the script was killed by a signal). A shell that cannot be started is a StreamdError naming the program.
  • Front-end (R30): unchanged. A shell alias is not a tab. A default_command that is a shell alias selects nothing; the picker opens as without a default.
  • LSP: never runs anything. Completion offers shell through the schema.
  • Integration tests in crates/streamd-cli/tests/cli.rs with SHELL=/bin/sh: runs in the stream folder (pwd), gets $1/"$@", the exit code is passed on, STREAMD_BASE_FOLDER is set, the --help text.

4. Bindings (streamd-ffi/src/outline.rs)

AliasSettings { description, target: AliasTarget } with a UniFFI enum AliasTarget { Command { command }, Shell { script } }, converted both ways. Extend the round-trip test with a shell alias.

5. Android

  • ConfigCommandForms.kt AliasFields: a ConnectedChoice "streamd command" / "Shell script" above the field. Switching keeps the text. Shell shows a multi-line monospace TextEntry with the supporting text "Runs only from the command line, in the stream folder; arguments are $1, $2, …".
  • ConfigList.kt commandText: = new edit for a command, $ <first line> for a shell alias. ConfigDraft.newCommand keeps starting as a command alias.
  • Nothing else runs an alias. Aliases stay out of All, the bar and the pins, as today.
  • Tests: ConfigListTest for the text. A screenshot scene for the form of a shell alias in ConfigEditScreenshots.kt (record the reference). Add a shell alias to the fixture of ConfigScreenshots.kt (update the reference).
  • Strings in res/values/strings.xml.

6. Starter template and docs

  • init.toml: a commented-out [commands.sync] shell alias like the example above, next to edit. It must stay valid when uncommented (existing template tests).
  • REQUIREMENTS.md R32 (both kinds, interpreter, arguments, working directory, env, exit code, validation, rename and front-end behaviour; note that only an explicit streamd <key> runs a script, never the LSP or the app), R28's list of fields (alias: description, and command or shell), R21c. Also README.md and AGENTS.md (commands/alias/: run.rs, AliasTarget).
  • Finish with nix flake check and nix build .#streamd-app.
## Plan: shell aliases ### Decisions - **Syntax:** `type = "alias"` keeps its two kinds apart by the field: exactly one of `command` (a streamd command, as today) or `shell` (a script). Both, or neither, is an error. - **Interpreter:** `$SHELL -c SCRIPT`; if `$SHELL` is unset or empty, `sh`. - **Arguments:** what follows the alias goes to the script as `$1`, `$2`, … (`$SHELL -c SCRIPT streamd-<key> ARGS…`; `"$@"` is all of them). Nothing is spliced into the text of the script. - **Working directory:** the stream folder (the base folder). `STREAMD_BASE_FOLDER` is set to it, so a `streamd` the script calls uses the same stream. - **Android:** the alias form can edit both kinds. A shell alias is marked as only running from the command line. The app never runs it. ```toml [commands.sync] type = "alias" description = "Commit, pull and push the notes" shell = """ git add -A git commit -m "streamd sync" || true git pull --rebase git push """ ``` ### 1. File model (core, `config/project/file.rs`), with serde types The table is read through a derived raw struct and turned into an enum by `try_from`. The raw struct keeps `deny_unknown_fields`, so an unknown field is still reported at its line with the list of fields. The either/or error lands on the command's table, where a missing required field is reported today. ```rust /// What an alias stands for: exactly one of its two fields. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AliasTarget { /// `command = "new edit"`: a configured command and its arguments. Command(String), /// `shell = "git pull && git push"`: a script for `$SHELL -c`. Shell(String), } /// A `type = "alias"` command, as written. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(try_from = "AliasFields")] pub struct AliasCommandFile { pub description: Option<String>, pub target: AliasTarget, } /// The table as written, before it is known which of the two it is. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct AliasFields { description: Option<String>, command: Option<String>, shell: Option<String>, } impl TryFrom<AliasFields> for AliasCommandFile { type Error = String; fn try_from(fields: AliasFields) -> Result<Self, String> { let target = match (fields.command, fields.shell) { (Some(command), None) => AliasTarget::Command(command), (None, Some(script)) => AliasTarget::Shell(script), (None, None) => return Err("needs `command` (a streamd command, like \"new edit\") \ or `shell` (a script for $SHELL)".into()), (Some(_), Some(_)) => return Err("has both `command` and `shell`; keep one of them".into()), }; Ok(Self { description: fields.description, target }) } } ``` Why not `#[serde(flatten)]` on an externally tagged enum: `flatten` turns off `deny_unknown_fields` and buffers the table, which loses the error lines (the same reason `ProjectConfigFile::parse` avoids tagged enums). Tests: both kinds parse; both fields, neither field and an unknown field give errors at the right line. ### 2. Runtime type and validation (core) - `commands/alias/mod.rs`: `AliasCommand { description, target: Alias }`, with `enum Alias { Command(Vec<String>), Shell(String) }`. `stands_for()` gives `streamd new edit` or the first line of the script (with `…` if there is more). - `validate.rs`: the `command` checks stay as they are. A `shell` that is empty or only whitespace is an error at `commands.<key>.shell`. A command alias MUST NOT name a shell alias either (it is still an alias of an alias). `default_command` MAY name a shell alias. - `schema.rs`: add `shell` to `ALIAS_COMMAND` ("A script run by $SHELL in the stream folder; arguments are $1, $2, …") and reword `command`. The schema test holds this against the serde model. - `change.rs`: write `command` or `shell` and remove the other one when the kind changes. Use a multi-line string if the script has a newline. Test the round trip of both kinds and a switch between them. - `complete.rs` / `index.rs` / `rename.rs`: only `command` refers to a command key. `shell` is not indexed, so `rename_command` leaves a script that calls `streamd old` alone (say so in R32). ### 3. CLI - `alias/cli.rs`: the clap command is the same for both kinds (no flags of its own, trailing args). The help is `Runs \`<first line>\`` when there is no description. - `commands/cli.rs::alias_arguments` returns `None` for a shell alias. `run` gets the shell arm: new `alias/run.rs` with a pure `shell_invocation(shell_env: Option<OsString>, script, key, args) -> (program, Vec<OsString>)` (unit-tested), then `std::process::Command` with `current_dir(base_folder)`, `STREAMD_BASE_FOLDER`, inherited stdio. streamd exits with the script's exit code (1 if the script was killed by a signal). A shell that cannot be started is a `StreamdError` naming the program. - Front-end (R30): unchanged. A shell alias is not a tab. A `default_command` that is a shell alias selects nothing; the picker opens as without a default. - LSP: never runs anything. Completion offers `shell` through the schema. - Integration tests in `crates/streamd-cli/tests/cli.rs` with `SHELL=/bin/sh`: runs in the stream folder (`pwd`), gets `$1`/`"$@"`, the exit code is passed on, `STREAMD_BASE_FOLDER` is set, the `--help` text. ### 4. Bindings (`streamd-ffi/src/outline.rs`) `AliasSettings { description, target: AliasTarget }` with a UniFFI enum `AliasTarget { Command { command }, Shell { script } }`, converted both ways. Extend the round-trip test with a shell alias. ### 5. Android - `ConfigCommandForms.kt` `AliasFields`: a `ConnectedChoice` "streamd command" / "Shell script" above the field. Switching keeps the text. Shell shows a multi-line monospace `TextEntry` with the supporting text "Runs only from the command line, in the stream folder; arguments are $1, $2, …". - `ConfigList.kt` `commandText`: `= new edit` for a command, `$ <first line>` for a shell alias. `ConfigDraft.newCommand` keeps starting as a command alias. - Nothing else runs an alias. Aliases stay out of All, the bar and the pins, as today. - Tests: `ConfigListTest` for the text. A screenshot scene for the form of a shell alias in `ConfigEditScreenshots.kt` (record the reference). Add a shell alias to the fixture of `ConfigScreenshots.kt` (update the reference). - Strings in `res/values/strings.xml`. ### 6. Starter template and docs - `init.toml`: a commented-out `[commands.sync]` shell alias like the example above, next to `edit`. It must stay valid when uncommented (existing template tests). - `REQUIREMENTS.md` R32 (both kinds, interpreter, arguments, working directory, env, exit code, validation, rename and front-end behaviour; note that only an explicit `streamd <key>` runs a script, never the LSP or the app), R28's list of fields (`alias`: `description`, and `command` or `shell`), R21c. Also `README.md` and `AGENTS.md` (`commands/alias/`: `run.rs`, `AliasTarget`). - Finish with `nix flake check` and `nix build .#streamd-app`.
Collaborator

Implemented in PR #329, branch 326_shell-command-aliases, in four GPG-signed commits (head 6d3f097).

Aliases support exactly one of command and shell. Shell scripts run through $SHELL -c (fallback sh) in the stream folder, with separate positional arguments, inherited stdio, an absolute STREAMD_BASE_FOLDER and exit-code propagation. Android edits both kinds, preserves text when switching, and labels scripts as CLI-only; it never executes aliases. The starter template includes a commented sync example, and README, requirements and agent documentation are updated.

Validation completed locally:

  • nix flake check: passed, including 1,734 Rust tests, architecture checks, clippy, formatting, audit and 102 Kotlin binding checks.
  • nix build .#streamd-app: passed for the final commit, including the offline APK build, app unit tests using the native bindings, and exact screenshot comparison.
  • All six changed/new light and dark screenshot references were inspected.
  • Working tree clean and branch pushed. Forgejo CI is still running; its commit-message check has passed.

Findings:

  • Serde try_from drops the error span for contradictory/missing alias targets. These errors now point at the command table, while unknown fields retain their precise positions.
  • Shell code remains opaque to command indexing and renames. Command aliases cannot target either alias kind; a shell alias target gets a direct-invocation hint.
  • Arguments containing whitespace, shell syntax, --help and -i are passed unchanged, without interpolation into script text. No dependencies were added.

Time spent: about 30 minutes (2026-10-25, 09:03–09:33 UTC).

Token usage from the session metric immediately before this closing note: 10,082,323 total — 168,799 uncached input tokens, 9,876,224 cached input tokens and 37,300 output tokens. Cached context is included in the total.

Implemented in [PR #329](https://git.konstantinfickel.de/kfickel/streamd/pulls/329), branch `326_shell-command-aliases`, in four GPG-signed commits (head `6d3f097`). Aliases support exactly one of `command` and `shell`. Shell scripts run through `$SHELL -c` (fallback `sh`) in the stream folder, with separate positional arguments, inherited stdio, an absolute `STREAMD_BASE_FOLDER` and exit-code propagation. Android edits both kinds, preserves text when switching, and labels scripts as CLI-only; it never executes aliases. The starter template includes a commented sync example, and README, requirements and agent documentation are updated. Validation completed locally: - `nix flake check`: passed, including 1,734 Rust tests, architecture checks, clippy, formatting, audit and 102 Kotlin binding checks. - `nix build .#streamd-app`: passed for the final commit, including the offline APK build, app unit tests using the native bindings, and exact screenshot comparison. - All six changed/new light and dark screenshot references were inspected. - Working tree clean and branch pushed. [Forgejo CI](https://git.konstantinfickel.de/kfickel/streamd/actions/runs/843) is still running; its commit-message check has passed. Findings: - Serde `try_from` drops the error span for contradictory/missing alias targets. These errors now point at the command table, while unknown fields retain their precise positions. - Shell code remains opaque to command indexing and renames. Command aliases cannot target either alias kind; a shell alias target gets a direct-invocation hint. - Arguments containing whitespace, shell syntax, `--help` and `-i` are passed unchanged, without interpolation into script text. No dependencies were added. Time spent: about 30 minutes (2026-10-25, 09:03–09:33 UTC). Token usage from the session metric immediately before this closing note: 10,082,323 total — 168,799 uncached input tokens, 9,876,224 cached input tokens and 37,300 output tokens. Cached context is included in the total.
Sign in to join this conversation.
No labels
android
cli
epic
planned
tui
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
kfickel/streamd#326
No description provided.