# Tasks: yconn
## Foundation
> All foundation tasks must be complete and verified before
> any implementation task is started.
- [x] **Verify make build** [foundation] S
- Acceptance: `make build` exits 0 and produces `target/x86_64-unknown-linux-musl/release/yconn`
- Depends on: nothing
- [x] **Verify make lint** [foundation] S
- Acceptance: `make lint` exits 0, no warnings or errors
- Depends on: Verify make build
- [x] **Verify make test** [foundation] S
- Acceptance: `make test` exits 0 — all tests pass (stubs are fine at this stage)
- Depends on: Verify make lint
- [x] **Set up GitHub repository** [foundation] S
- Acceptance: remote configured, initial scaffold pushed to main, branch protection on main enabled
- Depends on: Verify make test
- [x] **Push scaffold branch and open PR** [foundation] S
- Acceptance: `chore/scaffold` pushed, PR opened, developer confirms PR is merged
- Depends on: Set up GitHub repository
- [x] **Verify CI pipeline is live** [foundation] S
- Acceptance: test PR opened, `ci.yml` runs end to end, all checks pass
- Depends on: Push scaffold branch and open PR
- [x] **Verify make package runs in CI** [foundation] M
- Acceptance: `release.yml` triggered by test tag `v0.0.1-test`, binary + `.deb` + `PKGBUILD` present in GitHub Release; test tag deleted after verification
- Depends on: Verify CI pipeline is live
## Implementation
> Start only after all Foundation tasks are checked off.
- [x] **Implement display module** [core] L
- Acceptance: `render_list`, `render_show`, `render_config`, `render_group_list`, `render_group_current` all produce correct output; unit tests cover each formatter; `make test` passes
- Depends on: Verify make package runs in CI
- Modify: src/display/mod.rs
- Create: none
- Reuse: none
- Risks: none
- [x] **Implement config module** [core] L
- Acceptance: loads all three layers in priority order, merges connections (higher-priority wins on name collision), retains shadowed entries, performs upward directory walk for project config, resolves active group from session.yml, surfaces docker block; unit tests cover all config-priority scenarios and group scenarios from the test strategy; `make test` passes
- Depends on: Implement display module
- Modify: src/config/mod.rs
- Create: none
- Reuse: none
- Risks: none
- [x] **Implement group module** [core] M
- Acceptance: reads and writes `~/.config/yconn/session.yml`; unknown keys ignored; active group defaults to `connections` when file absent or key missing; scans all layer directories to discover group files; unit tests cover all group scenarios from the test strategy; `make test` passes
- Depends on: Implement config module
- Modify: src/group/mod.rs
- Create: none
- Reuse: src/config/mod.rs (layer path constants)
- Risks: none
- [x] **Implement connect module** [core] M
- Acceptance: `build_args` produces correct SSH arg vectors for all four SSH-argument scenarios; exec replaces current process; password auth emits no `-i` flag; key passphrases delegated to ssh-agent; unit tests cover all SSH-argument scenarios; `make test` passes
- Depends on: Implement group module
- Modify: src/connect/mod.rs
- Create: none
- Reuse: src/config/mod.rs:Connection
- Risks: none
- [x] **Implement docker module** [core] L
- Acceptance: container detection via `/.dockerenv` and `CONN_IN_DOCKER=1`; `docker run` command constructed with correct mounts and args; user-supplied `docker.args` inserted before image name; `--pull` flag included when `pull: always`; docker block in user-layer config emits warning and is ignored; `--verbose` prints full docker command; exec replaces current process; unit tests and integration tests cover all Docker-bootstrap scenarios from the test strategy; `make test` passes
- Depends on: Implement connect module
- Modify: src/docker/mod.rs
- Create: none
- Reuse: src/config/mod.rs:DockerConfig, src/connect/mod.rs (exec pattern)
- Risks: none
- [x] **Implement security module** [core] M
- Acceptance: warns on config files with permissions wider than 0o600; detects and warns on credential fields in git-trackable layers; warns if docker block in user config; all warnings non-blocking; unit tests cover permission check and credential-field detection per layer type; `make test` passes
- Depends on: Implement docker module
- Modify: src/security/mod.rs
- Create: none
- Reuse: src/config/mod.rs:Layer, src/display/mod.rs:Renderer::warn
- Risks: none
- [x] **Implement CLI command handlers** [cli] L
- Acceptance: all commands (`list`, `connect`, `show`, `add`, `edit`, `remove`, `init`, `config`, `group list`, `group use`, `group clear`, `group current`) delegate correctly to their modules; global flags (`--all`, `--no-color`, `--verbose`, `--layer`) work as specified; `make test` passes
- Depends on: Implement security module
- Modify: src/cli/mod.rs, src/main.rs, src/commands/mod.rs, src/commands/list.rs, src/commands/connect.rs, src/commands/show.rs, src/commands/add.rs, src/commands/edit.rs, src/commands/remove.rs, src/commands/init.rs, src/commands/config.rs, src/commands/group.rs
- Create: none
- Reuse: all modules above
- Risks: none
- [x] **Write functional integration tests** [test] L
- Acceptance: functional tests in `tests/functional.rs` cover all config-priority scenarios, SSH-argument scenarios, group scenarios, and Docker-bootstrap scenarios listed in the test strategy; tests write real temporary config files and intercept exec calls; no real SSH or Docker invocations; `make test` passes
- Depends on: Implement CLI command handlers
- Modify: tests/functional.rs
- Create: none
- Reuse: all modules above
- Risks: none
- [x] **Implement `yconn ssh-config` command** [cli] M
- Acceptance: `yconn ssh-config` writes SSH `Host` blocks for all merged connections to `~/.ssh/yconn-connections` with 0o600 permissions; injects `Include ~/.ssh/yconn-connections` as the first line of `~/.ssh/config` (idempotent); `--dry-run` prints to stdout without writing files; `--user key:value` overrides `${key}` template tokens in `user` fields; `--skip-user` omits `User` lines from all blocks; unit tests in `src/commands/ssh_config.rs` cover block format, port omission, glob/range name translation, `${name}` → `%h` substitution, idempotent include injection, skip-user; functional tests cover write path and dry-run; `make test` passes
- Depends on: Write functional integration tests
- Modify: src/cli/mod.rs, src/main.rs, src/commands/mod.rs, src/commands/ssh_config.rs
- Create: src/commands/ssh_config.rs
- Reuse: src/config/mod.rs:LoadedConfig::connections, src/display/mod.rs:Renderer
- Risks: none
- [x] **Implement `yconn users` command** [cli] M
- Acceptance: `yconn users show` renders the merged `users:` map as a table with NAME, VALUE, SOURCE columns and shadowing info; `yconn users add` wizard writes a new key/value entry to the target layer's config file; `yconn users edit <key>` opens the source config in `$EDITOR`; unit tests in `src/commands/user.rs` cover show rendering, add wizard, edit; functional tests cover round-trip add then show; `make test` passes
- Depends on: Implement `yconn ssh-config` command
- Modify: src/cli/mod.rs, src/main.rs, src/commands/mod.rs, src/commands/user.rs
- Create: src/commands/user.rs
- Reuse: src/display/mod.rs:Renderer, src/config/mod.rs:LoadedConfig
- Risks: none
- [x] **Rename `yconn user` command to `yconn users`** [cli] S
- Acceptance: `yconn users show`, `yconn users add`, and `yconn users edit <key>` all work identically to the old `yconn user` variants; `yconn user` (old spelling) produces a clap "unrecognized subcommand" error; all references to the old command name are updated in source, tests, and docs; `make test` passes
- Depends on: Implement `yconn users` command
- Modify: src/cli/mod.rs, src/main.rs, tests/functional.rs
- Create: none
- Reuse: src/cli/mod.rs:Commands::User (rename variant to `Users`), src/main.rs:Commands::User match arm (rename to Commands::Users)
- Risks: none
- [x] **Display username header in `yconn user show` output** [cli] S
- Acceptance: `yconn users show` prints a `Username: <value>` header above the user table, where `<value>` is resolved from (in order): the `user` key in the merged `users:` map, then `$USER` environment variable, then `(not set)`; unit tests in `src/commands/user.rs` cover all three resolution paths; `make test` passes
- Depends on: Rename `yconn user` command to `yconn users`
- Modify: src/commands/user.rs, src/display/mod.rs
- Create: none
- Reuse: src/display/mod.rs:Renderer, src/config/mod.rs:LoadedConfig
- Risks: none
- [x] **Remove `yconn:` prefix from SSH config comments** [cli] S
- Acceptance: `render_ssh_config` emits comments as `# description: …`, `# auth: …`, `# link: …`, and `# user: … (unresolved)` — the `yconn: ` substring no longer appears in any generated output; unit tests in `src/commands/ssh_config.rs` are updated so all `assert!(out.contains("# yconn: …"))` assertions become `assert!(out.contains("# …"))` and a negative assertion `assert!(!out.contains("# yconn:"))` is added to the link test; `make test` passes
- Depends on: Rename `yconn user` command to `yconn users`
- Modify: src/commands/ssh_config.rs
- Create: none
- Reuse: src/commands/ssh_config.rs:render_ssh_config (the four format strings on lines 63–75 are the only change sites)
- Risks: none — pure string literal change with no logic impact; verify no external tooling parses the `# yconn:` prefix from generated files before stripping it
- [x] **Add `yconn show --dump` flag to print the fully merged config** [cli] S
- Acceptance: (1) `yconn show --dump` (no connection name required) prints the fully merged `connections:` and `users:` maps to stdout as valid YAML after all layers have been loaded and merged — active entries only, no shadowed rows; (2) the output is machine-readable YAML with a top-level `connections:` key (each entry serialised with all its resolved fields) and a top-level `users:` key (each entry as a flat `key: value` map); (3) `yconn show --dump` and `yconn show <name>` are mutually exclusive — if both a name and `--dump` are supplied clap surfaces an error; (4) the `--dump` flag is only valid on the `show` subcommand, not global; (5) unit tests in `src/commands/show.rs` cover: dump with connections only, dump with users only, dump with both, dump with empty config; (6) a functional test in `tests/functional.rs` writes a project config with at least two connections and a users map, runs `yconn show --dump`, and asserts stdout is valid YAML containing all connection names and user keys; `make test` passes
- Depends on: Remove `yconn:` prefix from SSH config comments
- Modify: src/cli/mod.rs, src/main.rs, src/commands/show.rs, src/display/mod.rs, tests/functional.rs
- Create: none
- Reuse: src/cli/mod.rs:Commands::Show (add `dump: bool` field and make `name` an `Option<String>`; use clap `required_unless_present` or a manual guard to require exactly one of name or --dump), src/config/mod.rs:LoadedConfig::connections (iterate to build serialisable connection map), src/config/mod.rs:LoadedConfig::users (iterate `HashMap<String, UserEntry>` to build flat key→value map), src/config/mod.rs:Connection (field names define the YAML key names in dump output), src/display/mod.rs:Renderer (add a new `dump` method that serialises to YAML and prints to stdout), tests/functional.rs:TestEnv (use existing harness — write project config, run binary, assert stdout contains expected YAML fragments)
- Risks: `name` in `Commands::Show` is currently a required positional `String` — changing it to `Option<String>` requires updating every match arm and call site in `src/main.rs` and all unit tests in `src/commands/show.rs` that call `run(cfg, renderer, name)`; clap's `required_unless_present` or a post-parse guard must enforce that exactly one of name or `--dump` is given — without this, bare `yconn show` gives a confusing error; the YAML serialisation must use `serde_yaml` (already a dependency via config loading) rather than hand-rolling strings — add `#[derive(Serialize)]` to a dump-specific struct or map type rather than modifying the existing `Connection` struct (which uses `Deserialize` only); `yconn show --dump` must not call the template expansion path — dump shows raw config field values identically to `yconn show <name>`; the `users:` section in dump output must be a plain `key: value` map (not the internal `UserEntry` struct with layer/source fields) — build a `HashMap<String, String>` from `cfg.users` before serialising; `connections:` entries in dump must include all fields including optional ones (`key`, `link`, `port`, `group`) — omit fields with `None` or default values using `#[serde(skip_serializing_if)]` to keep output clean
- [x] **Add `-F /dev/null` flag to SSH invocations to bypass `~/.ssh/config`** [core] S
- Acceptance: `build_args` in `src/connect/mod.rs` inserts `-F /dev/null` as the first flag immediately after `"ssh"` for all connection types (key auth, password auth, default port, custom port); all existing unit tests in `src/connect/mod.rs` and `src/commands/connect.rs` that assert exact SSH arg vectors are updated to include `-F /dev/null` at position 1; a new unit test asserts that `-F /dev/null` is always present regardless of auth type or port; `make test` passes
- Depends on: Add `yconn show --dump` flag to print the fully merged config
- Modify: src/connect/mod.rs, src/commands/connect.rs, tests/functional.rs
- Create: none
- Reuse: src/connect/mod.rs:build_args (insert `-F` and `/dev/null` after the initial `"ssh"` push, before any other flag), src/commands/connect.rs:test_connect_key_auth_default_port_ssh_args (update expected vec), src/commands/connect.rs:test_connect_password_auth_ssh_args (update expected vec)
- Risks: every call site that assembles or asserts exact SSH arg vectors must be updated — grep for `vec!["ssh"` and `assert_eq!(args` across all test files before submitting; the stderr "Connecting:" line printed by `renderer.print_connecting` will now include `-F /dev/null` — any functional tests that assert the exact connecting-line format must be updated; `-F /dev/null` suppresses `~/.ssh/config` entirely, meaning any `Include`, `IdentityFile`, `ServerAliveInterval`, or other user config directives will be ignored — document this trade-off clearly in a code comment in `build_args`; the `ssh-config` subcommand generates Host blocks for use inside `~/.ssh/config` and does not call `build_args` — it is unaffected by this change
- [x] **Remove `Username:` header from `yconn users show` and display `user` as a regular table row with env-var source label** [cli] S
- Acceptance: (1) `yconn users show` no longer prints a `Username:` header line — `Renderer::print_username_header` is removed and its call site in `src/commands/user.rs:show` is deleted; (2) when a `user` key is present in the merged `users:` map it appears as a normal row in the table with its layer/path as the SOURCE column value — no change to existing row rendering; (3) when no `user` key exists in the `users:` map but `$USER` is set, a synthetic row with key `user`, value equal to `$USER`, and SOURCE `env (environment variable $USER)` is appended to the rows passed to `render_user_list`; (4) when neither source is available no synthetic row is added; (5) existing unit tests in `src/commands/user.rs` that assert on `resolve_username_with_env` are removed or repurposed; new unit tests cover: `user` key in map renders as ordinary row (no header), no `user` key and `$USER` set renders synthetic row with `env (environment variable $USER)` source, neither available produces no extra row; (6) functional tests `user_show_prints_username_from_map` and `user_show_prints_username_from_env_var` are updated — the map test asserts `!stdout.contains("Username:")` and that `alice` appears as a table row value; the env-var test asserts `!stdout.contains("Username:")` and that `bob` appears as a row value with SOURCE containing `environment variable $USER`; `make test` passes
- Depends on: Add `-F /dev/null` flag to SSH invocations to bypass `~/.ssh/config`
- Modify: src/commands/user.rs, src/display/mod.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/user.rs:show (remove resolve_username/print_username_header calls; add logic to synthesise a UserRow when $USER provides the user value), src/display/mod.rs:render_user_list (accepts &[UserRow] — pass synthetic env-var row here; remove print_username_header method), src/display/mod.rs:UserRow (reuse directly for the synthetic env-var row: key="user", value=$USER, source="env (environment variable $USER)", shadowed=false), tests/functional.rs:user_show_prints_username_from_map (update assertion), tests/functional.rs:user_show_prints_username_from_env_var (update assertion)
- Risks: `Renderer::print_username_header` is only called from `src/commands/user.rs:show` — removing it should be safe, but grep all call sites before deleting; `user_list` currently takes `&[UserEntry]` and converts internally to `UserRow` — if the synthetic env-var row is constructed as a `UserRow` directly, either the public signature must accept `&[UserRow]` (requires updating all call sites) or a helper that accepts an optional extra row must be added; the env-var synthetic row must only be added when the `user` key is absent from `cfg.all_users` entirely (not merely shadowed) — check `cfg.users.get("user").is_none()`, not `cfg.all_users`; `resolve_username` and `resolve_username_with_env` become dead code once the header is removed — delete them along with their three unit tests to avoid lint warnings
- [x] **Rename `yconn group` command to `yconn groups`** [cli] S
- Acceptance: `yconn groups list`, `yconn groups use <name>`, `yconn groups clear`, and `yconn groups current` all work identically to the old `yconn group` variants; `yconn group` (old spelling) produces a clap "unrecognized subcommand" error; all references to the old command name are updated in source, tests, and docs; `make test` passes
- Depends on: Remove `Username:` header from `yconn users show` and display `user` as a regular table row with env-var source label
- Modify: src/cli/mod.rs, src/main.rs, src/commands/group.rs, src/display/mod.rs, tests/functional.rs, README.md, docs/configuration.md, docs/examples.md, docs/man/yconn.1.md
- Create: none
- Reuse: src/cli/mod.rs:Commands::Group (rename variant to `Groups` — clap lowercases it to `groups` automatically), src/cli/mod.rs:GroupCommands (no structural change — subcommand variants stay, only the parent CLI token changes), src/main.rs:Commands::Group match arm (rename to Commands::Groups), src/commands/group.rs (handler functions unchanged — only call sites in main.rs need updating)
- Risks: the Rust enum variant `Commands::Group` must be renamed to `Commands::Groups` and every match arm and import referencing it in src/main.rs updated; clap derives the CLI token from the variant name by default (lowercased), so renaming the variant to `Groups` automatically changes the token to `groups` with no extra annotation needed; doc comments inside `src/commands/group.rs` that reference `yconn group` must be updated to `yconn groups`; all documentation files (README.md, docs/configuration.md, docs/examples.md, docs/man/yconn.1.md) that reference `yconn group` must be updated to `yconn groups`; CLAUDE.md also references `yconn group` in several places — decide whether to update it or leave it as project-level spec (leave unchanged, as CLAUDE.md is the spec source and is not user-facing CLI docs); no YAML config format or session.yml format changes — only the CLI surface changes
- [x] **Add `yconn connections` subcommand and move `add`, `edit`, `remove`, `init`, and `show` under it** [cli] M
- Acceptance: `yconn connections add`, `yconn connections edit <name>`, `yconn connections remove <name>`, `yconn connections init`, and `yconn connections show [<name>|--dump]` all behave identically to the current top-level variants; the old top-level spellings (`yconn add`, `yconn edit`, etc.) produce a clap "unrecognized subcommand" error; all references to the old invocation forms are updated in source, tests, and docs; `make test` passes
- Depends on: Rename `yconn group` command to `yconn groups`
- Modify: src/cli/mod.rs, src/main.rs, tests/functional.rs, README.md, docs/configuration.md, docs/examples.md, docs/man/yconn.1.md
- Create: none
- Reuse: src/cli/mod.rs:Commands (add `Connections { subcommand: ConnectionCommands }` variant; remove top-level `Add`, `Edit`, `Remove`, `Init`, `Show` variants), src/cli/mod.rs:LayerArg (unchanged — still used by add/edit/remove subcommands), src/cli/mod.rs:InitLocation (unchanged — still used by init subcommand), src/main.rs:Commands match arms (replace five separate arms with a single `Commands::Connections { subcommand }` arm that delegates to `ConnectionCommands` variants), src/commands/add.rs:run (signature unchanged — call site in main.rs moves under the new arm), src/commands/edit.rs:run (signature unchanged), src/commands/remove.rs:run (signature unchanged), src/commands/init.rs:run (signature unchanged), src/commands/show.rs:run/run_dump (signatures unchanged)
- Risks: five top-level variants are removed simultaneously — every functional test that invokes `["add", ...]`, `["edit", ...]`, `["remove", ...]`, `["init", ...]`, or `["show", ...]` must be updated to `["connections", "add", ...]` etc. before the PR is mergeable; the `show` subcommand carries two modes (`--dump` and positional `<name>`) that must both be preserved under the new nesting — clap's `required_unless_present` and `conflicts_with` annotations on `Show` must be retained exactly; `Commands::Show` currently holds `name: Option<String>` and `dump: bool` — these fields migrate verbatim into `ConnectionCommands::Show`; functional tests referencing `["show", "--dump"]` must become `["connections", "show", "--dump"]`; clap derives the CLI token from the enum variant name by default (lowercased) — a new `ConnectionCommands` enum whose variants are `Add`, `Edit`, `Remove`, `Init`, `Show` will produce `connections add` etc. automatically with no extra annotations; `--layer` flags on `Add`, `Edit`, `Remove` subcommands are unaffected — they remain per-subcommand as before
- [x] **Add blank lines between connection entries and between the `connections:` and `users:` blocks in `yconn show --dump` output** [cli] S
- Acceptance: `yconn show --dump` stdout contains a blank line between every consecutive pair of connection entries in the `connections:` block and a blank line between the `connections:` block and the `users:` block; the output remains valid YAML that round-trips through `serde_yaml::from_str` without loss; unit tests in `src/commands/show.rs` assert that the dump string for a two-connection config contains at least two blank lines within the `connections:` section and one blank line before `users:`; the existing functional test `show_dump_outputs_merged_config_as_yaml` is extended (or a new test added) to assert blank-line separation; `make test` passes
- Depends on: Add `yconn connections` subcommand and move `add`, `edit`, `remove`, `init`, and `show` under it
- Modify: src/commands/show.rs, src/display/mod.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/show.rs:build_dump_yaml (post-process the `serde_yaml::to_string` output to inject blank lines, or switch to a hand-built YAML string approach), src/display/mod.rs:Renderer::dump (call site unchanged — receives the final string), tests/functional.rs:show_dump_outputs_merged_config_as_yaml (extend existing assertions)
- Risks: `serde_yaml::to_string` on a `HashMap<String, DumpConn>` does not emit blank lines between map entries — post-processing the raw string is the most contained fix, but requires parsing the indentation structure to identify entry boundaries; alternatively, serialize each connection individually and concatenate with blank lines, then wrap under a `connections:` key manually — this avoids fragile string scanning but requires hand-rolling the top-level YAML structure; the blank-line injection must not corrupt multi-line scalar values (e.g. descriptions containing newlines) — test with a description containing `\n`; insertion between the `connections:` and `users:` blocks relies on finding the `users:` key at column 0 in the serialized output — this is stable for `serde_yaml` 0.9 but should be verified; the `users:` block itself (flat `key: value` map) does not require blank lines between entries since each entry is a single line
- [x] **Move SSH config comment metadata into a contiguous block before the `Host` line** [cli] S
- Acceptance: `render_ssh_config` in `src/commands/ssh_config.rs` emits all comment lines (`# description:`, `# auth:`, `# link:`, `# user: … (unresolved)`) contiguously before the `Host` line, and no comment lines appear inside the Host block (between `Host` and the next blank line); this applies to both the file-write path and `--dry-run` stdout path; unit tests in `src/commands/ssh_config.rs` are updated so every test that asserts block structure confirms comments precede `Host` and no `#` lines follow `Host` until the next blank line; a new unit test covers a connection with all four comment fields present (description, auth, link, unresolved user) and asserts their order is description → auth → link → user-comment immediately before `Host`; a new unit test covers a connection with `skip_user=true` and a resolved user (no unresolved-user comment) and asserts no `#` lines appear inside the Host block; `make test` passes
- Depends on: Add blank lines between connection entries and between the `connections:` and `users:` blocks in `yconn show --dump` output
- Modify: src/commands/ssh_config.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/ssh_config.rs:render_ssh_config (reorder the push_str calls so all comment lines are emitted before `Host {ssh_host}\n`; move the unresolved-user comment from inside the User rendering block to the comment header section, conditioned on `!skip_user && conn.user.contains("${")`), tests/functional.rs:ssh_config_generate_writes_host_blocks_and_include (extend to assert no `#` lines appear after a `Host` line within a block)
- Risks: the unresolved-user comment is currently rendered inside the `if !skip_user` block after the `Host` line — moving it to the pre-Host comment section requires evaluating `conn.user.contains("${")` before the Host line and suppressing the `User` directive entirely, which is the existing behaviour; the condition `!skip_user && conn.user.contains("${")` must be evaluated once and the result used both for the pre-Host comment and for suppressing the `User` line — extract to a boolean to avoid duplicating the check; functional tests that assert `content.contains("Host myhost\n")` are unaffected since the Host line itself does not move — only what precedes and follows it changes; the dry-run path calls `println!("{content}")` on the same string returned by `render_ssh_config` so no separate change is needed there; verify that the existing `test_link_field_appears_in_comment` test still passes after the reorder since it only checks `out.contains("# link: …")` without asserting position relative to `Host`
- [x] **Upsert Host blocks in `~/.ssh/yconn-connections` by connection name instead of overwriting the file** [cli] M
- Acceptance: (1) `run_generate` reads the existing `~/.ssh/yconn-connections` file (if present), parses it into a map of `Host` name → block text, merges in the newly rendered blocks (new names appended, existing names replaced in place), and writes the merged result — blocks belonging to other projects that are not in the current config are preserved unchanged; (2) when `~/.ssh/yconn-connections` does not exist the behaviour is identical to before — the file is created with exactly the rendered blocks; (3) `--dry-run` prints the merged output that would be written, not just the current config's blocks; (4) block order in the file is: existing blocks that are being kept (in their original order, with matching names updated in place), followed by any newly added blocks not previously in the file; (5) a unit test in `src/commands/ssh_config.rs` covers: existing file with two foreign blocks and one matching block — the matching block is replaced, the two foreign blocks are preserved, the result has three blocks total; (6) a unit test covers the absent-file case — output equals the rendered blocks exactly; (7) a functional test writes `yconn-connections` with a pre-existing foreign Host block (`myother-host`), runs `yconn ssh-config`, and asserts the resulting file contains both `Host myother-host` and the newly written blocks; (8) a second functional test runs `yconn ssh-config` twice with different project configs pointed at the same home directory and asserts blocks from both runs are present in the file after the second run; `make test` passes
- Depends on: Move SSH config comment metadata into a contiguous block before the `Host` line
- Modify: src/commands/ssh_config.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/ssh_config.rs:render_ssh_config (unchanged — still renders a connection list to a string; the upsert logic wraps around it), src/commands/ssh_config.rs:write_secure (unchanged — called with the merged content string), src/commands/ssh_config.rs:output_path (unchanged — path determination is the same), src/commands/ssh_config.rs:ensure_ssh_dir (unchanged), tests/functional.rs:ssh_config_generate_writes_host_blocks_and_include (extend to assert foreign blocks survive a second run)
- Risks: parsing the existing file into per-Host blocks must handle the comment-before-Host format produced by `render_ssh_config` (comments preceding the `Host` line belong to the same block) — the parser must treat any lines before a `Host` line that follow the previous block's trailing blank line as part of the next block's preamble; a block boundary is a blank line followed by comment lines and then a `Host` line — using a simple state machine over lines is safer than regex for this; the `--dry-run` path currently calls `println!("{content}")` on the rendered-only string — it must be updated to print the merged string instead so the preview accurately reflects what would be written; Host names in the existing file are extracted from lines matching `^Host <name>$` (exact match, no leading whitespace, single token after `Host`) — wildcard Host patterns (e.g. `Host web-*`) must be matched by their exact pattern string, not expanded; the merge must key on the SSH `Host` pattern string (after `translate_name_for_ssh`), not the yconn connection name, because that is what appears in the file; if two connections in the current config translate to the same SSH Host pattern (e.g. a range and a glob that both become `server*`) the last one wins in `render_ssh_config` output — the upsert sees only one block for that pattern and replaces it correctly; the existing `test_idempotent_include_injection` and `test_include_prepended_when_absent` tests in `ssh_config.rs` do not touch `run_generate` and are unaffected; all existing functional tests that assert exact file content after `yconn ssh-config` must be reviewed — they may now need to assert `contains` rather than exact equality if foreign blocks could appear
- [x] **Add `--user <key>:<value>` flag to `yconn users add` to skip the wizard** [cli] S
- Acceptance: (1) `yconn users add --user key1:val1 --user key2:val2` writes all supplied entries to the target layer's `connections.yaml` without prompting — the wizard is completely bypassed when one or more `--user` flags are present; (2) supplying zero `--user` flags falls back to the existing interactive wizard unchanged; (3) each `--user` value is split on the first `:` — if no `:` is present clap or the handler exits with a clear error message; (4) duplicate keys (key already exists in the file) error out with the same "already exists" message produced by the wizard path; (5) the `--user` flag is repeatable (`clap::ArgAction::Append`) and accepts multiple values per invocation; (6) unit tests in `src/commands/user.rs` cover: single `--user` flag writes the entry and exits without reading stdin, two `--user` flags both write entries, `--user` value missing `:` returns an error, `--user` with a duplicate key returns an error; (7) a functional test in `tests/functional.rs` runs `yconn users add --user foo:bar --user baz:qux` and asserts both `foo` and `baz` appear in a subsequent `yconn users show` without any stdin input; `make test` passes
- Depends on: Upsert Host blocks in `~/.ssh/yconn-connections` by connection name instead of overwriting the file
- Modify: src/cli/mod.rs, src/main.rs, src/commands/user.rs, tests/functional.rs
- Create: none
- Reuse: src/cli/mod.rs:UserCommands::Add (add `user_overrides: Vec<String>` field with `clap::ArgAction::Append` and `long = "user"`, `value_name = "KEY:VALUE"`), src/commands/user.rs:add (accept overrides vec; branch on `overrides.is_empty()` to choose wizard vs non-interactive path), src/commands/user.rs:add_impl (unchanged — still used for the wizard path), src/commands/user.rs:write_user_entry (reuse directly for each supplied key/value pair in the non-interactive path), src/commands/user.rs:layer_arg_to_layer (unchanged — layer resolution is the same for both paths), src/commands/user.rs:layer_path (unchanged), tests/functional.rs:user_add_round_trip_show_reflects_new_entry (follow structure for the new non-interactive functional test)
- Risks: the `--user` long name on `UserCommands::Add` must not conflict with the global `--user` flag on `Commands::Connect` or `Commands::SshConfig` — these are different subcommands so clap scopes them independently, but verify there is no accidental shadowing; split on first `:` only (`splitn(2, ':')`) so values containing `:` are accepted without error; the non-interactive path must call `write_user_entry` once per supplied pair in order — if a later pair is a duplicate, entries written by earlier pairs in the same invocation must still be persisted (do not roll back); `main.rs` currently calls `commands::user::add(layer)` for `UserCommands::Add` — the call site must be updated to pass the overrides vec, requiring a signature change on `add()`; the functional test must not provide any stdin to the binary for the non-interactive case — verify TestEnv passes an empty or closed stdin so the test does not hang waiting for wizard input
- [x] **Print `Updating: <path>` to stdout when `yconn users add` or `yconn users edit` modifies a config file** [cli] S
- Acceptance: (1) `yconn users add` (both wizard and `--user` non-interactive paths) prints `Updating: <absolute-path>` to stdout immediately before the file is written, where `<absolute-path>` is the resolved path of `connections.yaml` in the target layer; (2) `yconn users edit <key>` prints `Updating: <absolute-path>` to stdout before launching `$EDITOR`, where `<absolute-path>` is the source file resolved for that key; (3) the message is always printed regardless of whether the file is being created for the first time or appended to; (4) unit tests in `src/commands/user.rs` cover: wizard `add_impl` output contains `Updating:` followed by the target path, non-interactive `add` (when `--user` flag path is implemented) output contains `Updating:`, `edit` output captured via a helper contains `Updating:` followed by the resolved source path; (5) a functional test in `tests/functional.rs` runs `yconn users add` with stdin input and asserts stdout contains `Updating:` and the config file path; a second functional test runs `yconn users add --user foo:bar` and asserts the same; `make test` passes
- Depends on: Add `--user <key>:<value>` flag to `yconn users add` to skip the wizard
- Modify: src/commands/user.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/user.rs:add_impl (add a `writeln!(output, "Updating: {}", target.display())` call before the `write_user_entry` call — `output` is already threaded through as `&mut dyn Write`), src/commands/user.rs:write_user_entry (call site is in `add_impl`; the print happens at the call site, not inside `write_user_entry`, to keep the helper free of I/O side effects), src/commands/user.rs:edit (add `println!("Updating: {}", path.display())` before the `open_editor` call — `edit` currently has no output writer, so a direct `println!` is consistent with how `open_editor` itself has no writer), tests/functional.rs:user_add_round_trip_show_reflects_new_entry (extend to assert stdout contains `Updating:`)
- Risks: `add_impl` already prints `"Adding user entry to {layer} layer ({path})"` — decide whether to keep that line alongside the new `Updating:` line or replace it; the task specifies the `Updating:` format so both can coexist, but verify the test assertions do not conflict; the non-interactive `--user` path (added by the preceding task) must also emit the `Updating:` line — if it calls `write_user_entry` directly without going through `add_impl`, a separate `writeln!` or `println!` must be added at that call site too; `edit` does not currently accept a `&mut dyn Write` output parameter — adding `println!` directly is the simplest fix and consistent with the existing `open_editor` call, but if the project later requires test-capturing of `edit` output, a writer parameter will need to be threaded in; ensure the path printed is the absolute resolved path (use `target.display()` / `path.display()` on the `PathBuf` values already computed in both functions, which are absolute by construction via `layer_path`)
- [x] **Extend unresolved-user warning in `yconn ssh-config` to include the `yconn users add` fix command** [cli] S
- Acceptance: when `run_generate` in `src/commands/ssh_config.rs` emits a warning for an unresolved `${key}` template token in a connection's `user` field, the warning message includes a suggested fix command of the form `yconn users add --user <key>:<value>`; the warning is still emitted via `renderer.warn` to stderr and remains non-blocking; the `# user: ${key} (unresolved)` comment in the generated SSH config block is unchanged; unit tests in `src/commands/ssh_config.rs` cover: unresolved token `${t1user}` produces a warning containing `yconn users add --user t1user:<value>`; the existing `test_unresolved_template_produces_warning` test is updated to also assert the fix command is present in the warning string; a functional test in `tests/functional.rs` runs `yconn ssh-config` with a config containing `user: "${t1user}"` (no users map entry), captures stderr, and asserts it contains both `unresolved` and `yconn users add --user t1user:<value>`; `make test` passes
- Depends on: Print `Updating: <path>` to stdout when `yconn users add` or `yconn users edit` modifies a config file
- Modify: src/commands/ssh_config.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/ssh_config.rs:run_generate (the `for w in &warnings { renderer.warn(w); }` loop at line 305–307 is the only change site — append the fix hint to each warning string before passing to `renderer.warn`, or post-process warnings by extracting the key from the warning text and appending the hint), src/config/mod.rs:expand_user_field (produces the warning `Vec<String>` consumed by `run_generate`; warning text format is `"user field template '${key}' is unresolved: no users: entry for key '{key}'"` — parse the key from this string or change the return type to carry the key separately), src/display/mod.rs:Renderer::warn (unchanged — receives the enriched string), tests/functional.rs:ssh_config_generate_writes_host_blocks_and_include (extend or add a test variant that asserts stderr contains the fix command)
- Risks: `expand_user_field` is also called on the `connect` path (`src/commands/connect.rs` line 25) — if the fix hint is added inside `expand_user_field` itself the `connect` warning will also include it, which may or may not be desired; the safest scope is to enrich the warning only at the `run_generate` call site in `ssh_config.rs`, keeping `connect` warnings unchanged; to extract the key at the `run_generate` call site without reparsing the warning string, consider changing `expand_user_field`'s return type from `Vec<String>` to `Vec<UnresolvedToken>` where `UnresolvedToken` carries both the message and the raw key — this is a cleaner interface but requires updating the `connect` call site too; alternatively, parse the key from the existing warning string using a simple `split("key '")` approach — fragile but avoids a signature change; the `<value>` placeholder in `yconn users add --user t1user:<value>` must be a literal placeholder string, not a real value (since the value is unknown at warning time); the fix command hint must use the correct subcommand spelling `yconn users add` (not the old `yconn user add`); do not alter the `# user: ${key} (unresolved)` comment in `render_ssh_config` — only the stderr warning changes
- [x] **Restructure `yconn ssh-config` into subcommands: `install`, `print`, `uninstall`, `disable`, `enable`** [cli] M
- Acceptance: (1) `yconn ssh-config install` behaves identically to the current `yconn ssh-config` (writes Host blocks to `~/.ssh/yconn-connections`, injects `Include` into `~/.ssh/config`, prints an informative message per action), supports `--dry-run`, `--user`, and `--skip-user`; (2) `yconn ssh-config print` renders the fully merged SSH config to stdout without writing any files, supports `--user` and `--skip-user`, no `--dry-run` flag; (3) `yconn ssh-config uninstall` removes `~/.ssh/yconn-connections` and removes the `Include ~/.ssh/yconn-connections` line from `~/.ssh/config` (if present), prints a message for each action taken; (4) `yconn ssh-config disable` removes the `Include` line from `~/.ssh/config` but keeps `~/.ssh/yconn-connections` intact, prints a message; (5) `yconn ssh-config enable` adds the `Include` line back to `~/.ssh/config` if it is currently absent, prints a message, is a no-op with a message if already present; (6) bare `yconn ssh-config` (no subcommand) prints the clap-generated help text and exits with a non-zero status; (7) the old `yconn ssh-config --dry-run` (without subcommand) produces a clap "missing subcommand" error; (8) unit tests in `src/commands/ssh_config.rs` cover: `remove_include_line` removes only the Include line and preserves surrounding content; `remove_include_line` is a no-op when the line is absent; `inject_include` (already tested) is unchanged; (9) functional tests in `tests/functional.rs` cover: `ssh-config install` writes file and injects Include; `ssh-config install --dry-run` prints to stdout, no files written; `ssh-config print` prints Host blocks to stdout; `ssh-config uninstall` removes file and Include line; `ssh-config disable` removes Include line, file remains; `ssh-config enable` adds Include line when absent; `make test` passes
- Depends on: Extend unresolved-user warning in `yconn ssh-config` to include the `yconn users add` fix command
- Modify: src/cli/mod.rs, src/main.rs, src/commands/ssh_config.rs, tests/functional.rs
- Create: none
- Reuse: src/cli/mod.rs:Commands::SshConfig (replace the current flat variant with `SshConfig { subcommand: SshConfigCommands }` holding a new `SshConfigCommands` enum with variants `Install`, `Print`, `Uninstall`, `Disable`, `Enable`), src/commands/ssh_config.rs:run_generate (rename to `run_install`; keep signature identical — all existing callers become the `Install` arm in main.rs), src/commands/ssh_config.rs:render_ssh_config (reused by both `Install` and `Print` — `Print` calls it directly and writes to stdout, skipping file I/O), src/commands/ssh_config.rs:inject_include (reused by `Install` and `Enable`), src/commands/ssh_config.rs:write_secure (reused by `Install`), src/commands/ssh_config.rs:output_path (reused by `Install`, `Uninstall`, `Disable`), src/commands/ssh_config.rs:INCLUDE_LINE (reused by all subcommands that touch `~/.ssh/config`), tests/functional.rs:ssh_config_generate_writes_host_blocks_and_include (update invocation from `["ssh-config"]` to `["ssh-config", "install"]`), tests/functional.rs:ssh_config_generate_dry_run_prints_to_stdout_no_files_written (update to `["ssh-config", "install", "--dry-run"]`)
- Risks: every existing functional test that calls `env.run(&["ssh-config", ...])` or `env.run(&["ssh-config", "--dry-run", ...])` must be updated to `["ssh-config", "install", ...]` — audit all tests in `tests/functional.rs` matching `ssh.config` before merging; the `--user` and `--skip-user` flags belong only on `Install` and `Print` — they must not be global to `SshConfig` or clap will accept them on `Uninstall`/`Disable`/`Enable` where they are meaningless; `run_install` (the renamed `run_generate`) already expands user templates and calls `render_ssh_config` — `run_print` is a thin wrapper that does the same expansion and render but writes to stdout instead of calling `write_secure` and `inject_include`; `remove_include_line` is new — it reads `~/.ssh/config`, filters out lines equal to `INCLUDE_LINE`, and writes back; it must preserve the rest of the file exactly, including blank lines between remaining entries; `uninstall` must handle the case where `~/.ssh/yconn-connections` does not exist (print "nothing to remove" or silently succeed); `disable` must be a no-op (with message) when the Include line is already absent; `enable` must call the existing `inject_include` which is already idempotent; bare `yconn ssh-config` triggering clap help requires the subcommand to be marked as required — use `#[command(subcommand_required = true, arg_required_else_help = true)]` on the `SshConfig` variant's subcommand field or on the `SshConfigCommands` enum
- [x] **Add `yconn install` command to install project connections into a target layer** [cli] M
- Acceptance: (1) `yconn install` reads the project `.yconn/connections.yaml` discovered by the upward walk from the current directory and copies all connections into the target layer file (`~/.config/yconn/connections.yaml` by default, `/etc/yconn/connections.yaml` with `--layer system`); (2) for each connection not present in the target file, the entry is appended and `Writing: <path>` is printed to stdout; (3) for each connection whose name already exists in the target file, the user is prompted interactively `Connection '<name>' already exists — update? [y/N]`; if the user answers `y` or `Y` the entry is replaced and `Updating: <path>` is printed; if the user answers anything else the connection is skipped and `Skipping: <name> (already exists)` is printed; (4) when no project config is found the command exits with a clear error; (5) the `--layer` flag accepts `user` (default) or `system` only — `project` is rejected with a clear error since installing into the project layer is circular; (6) unit tests in `src/commands/install.rs` cover: new connections appended and `Writing:` line emitted, existing connection with `y` answer replaced and `Updating:` line emitted, existing connection with `N` answer skipped and `Skipping:` line emitted, missing project config returns error, `--layer project` returns error; (7) a functional test in `tests/functional.rs` writes a project config with two connections (`alpha`, `beta`), runs `yconn install`, and asserts both appear in the user layer file; a second functional test pre-populates the user layer with `alpha`, runs `yconn install` with `y` stdin for the update prompt, and asserts `alpha` is updated and `beta` is appended; `make test` passes
- Depends on: Restructure `yconn ssh-config` into subcommands: `install`, `print`, `uninstall`, `disable`, `enable`
- Modify: src/cli/mod.rs, src/main.rs, src/commands/mod.rs, tests/functional.rs
- Create: src/commands/install.rs
- Reuse: src/commands/add.rs:entry_exists (detect existing connection name in target file content), src/commands/add.rs:insert_connection (append new connection block under `connections:`), src/commands/add.rs:build_entry (construct YAML block from Connection fields), src/commands/add.rs:set_private_permissions (apply 0o600 after writing), src/commands/add.rs:layer_arg_to_layer (convert LayerArg to Layer), src/commands/add.rs:layer_path (resolve target directory path from Layer), src/config/mod.rs:LoadedConfig::project_dir (already-discovered `.yconn/` path from upward walk — use as the source directory), src/config/mod.rs:Connection (iterate `cfg.connections` to get all source entries with their field values), src/display/mod.rs:Renderer (route all output through renderer; use `renderer.print_line` or equivalent for `Writing:`/`Updating:`/`Skipping:` messages), tests/functional.rs:TestEnv (write_project_config, write_user_config, run)
- Risks: `build_entry` in `src/commands/add.rs` takes individual field arguments (`host`, `user`, `port`, etc.) rather than a `&Connection` — the install handler must destructure each `Connection` to call it, or a thin wrapper must be added; the `replace_connection` operation (for update path) does not exist yet — it must be implemented in `install.rs` by reading the target file, locating the existing entry block by name, replacing it, and writing back; the upward-walk result is available as `cfg.project_dir` (a `PathBuf` to the `.yconn/` directory) — derive the source file path as `cfg.project_dir.unwrap().join("connections.yaml")`; `--layer project` must be explicitly rejected before dispatching — check `layer_arg == Some(LayerArg::Project)` at the top of the handler and bail with a clear message; the interactive prompt for existing connections must read from stdin line by line — use the same `prompt` / `BufRead` pattern as `add.rs` and `user.rs` so the handler is testable with injected input; connections loaded via `cfg.connections` are the fully merged active set — the install command must re-read only the project-layer connections from `cfg.project_dir` rather than iterating all merged connections, to avoid accidentally installing user- or system-layer entries into the target
- [x] **Improve yconn install progress output to show connection name per write/skip** [cli] S
- Acceptance: (1) for each new connection written, `run_impl` prints `Writing: connection <name> -> <filepath>` instead of `Writing: <filepath>`; (2) for each connection skipped (user answered N or defaulted), `run_impl` prints `Skipping: connection <name> -> <filepath> (already up to date)` instead of `Skipping: <name> (already exists)`; (3) for each connection updated (user answered y), `run_impl` prints `Updating: connection <name> -> <filepath>` instead of `Updating: <filepath>`; (4) unit tests in `src/commands/install.rs` are updated so `test_new_connections_appended_and_writing_emitted` asserts the output contains `Writing: connection alpha ->` and the target path, `test_existing_connection_y_replaces_and_updating_emitted` asserts `Updating: connection alpha ->` and the target path, and `test_existing_connection_n_skipped_and_skipping_emitted` asserts `Skipping: connection alpha ->` and the target path; (5) a functional test in `tests/functional.rs` runs `yconn install` with two connections and asserts stdout contains `Writing: connection alpha ->` and `Writing: connection beta ->`; `make test` passes
- Depends on: Add `yconn install` command to install project connections into a target layer
- Modify: src/commands/install.rs, tests/functional.rs
- Create: none
- Reuse: src/commands/install.rs:run_impl (the three `writeln!` call sites at lines 134, 137, and 146 are the only change sites — update the format strings to include the connection name and `->` separator before the path), src/commands/install.rs:extract_connection_names (unchanged — `name` is already in scope at each writeln! site inside the `for name in &connection_names` loop)
- Risks: the three existing unit tests each assert on specific output strings (`Writing: {target_str}`, `Updating: {target_str}`, `Skipping: alpha (already exists)`) — all three assertions must be updated to match the new format or the tests will fail; any functional test in `tests/functional.rs` that currently asserts exact `Writing:` or `Skipping:` output from `yconn install` must be audited and updated; the `->` separator must be a literal ` -> ` (space-arrow-space) consistent with the format used by `yconn ssh-config install` output elsewhere in the codebase — verify no other install output format uses a different separator before committing to this choice
- [x] **Show user variable resolution table above connections in yconn list output** [cli] M
- Acceptance: `yconn list` prints a `Users:` table before the connections table (separated by a blank line) whenever one or more `${...}` placeholders appear in any active connection's fields; each placeholder row shows its resolved value and source (layer + path) or `[unresolved]` with a `→ yconn users add --user KEY:VALUE` hint; the Users table is omitted entirely when no placeholders exist; connections table USER column preserves the raw `${variable}` syntax unchanged; unit tests in `src/commands/list.rs` cover: placeholder present and resolved shows correct value and source, placeholder present and unresolved shows `[unresolved]` and fix hint, no placeholders produces no Users table output, mixed resolved and unresolved placeholders both appear; `make test` passes
- Depends on: Improve yconn install progress output to show connection name per write/skip
- Modify: src/commands/list.rs, src/display/mod.rs
- Create: none
- Reuse: src/config/mod.rs:LoadedConfig::users (resolve placeholder keys against this map), src/config/mod.rs:expand_user_field (call with empty inline_overrides to detect unresolved tokens without mutating connection data), src/config/mod.rs:UserEntry (source layer and path for the resolved-value SOURCE column), src/display/mod.rs:UserRow (reuse directly — key=variable name without `${}`, value=resolved value or `[unresolved]`, source=layer+path or fix hint, shadowed=false), src/display/mod.rs:Renderer::user_list (renders the Users table; called before `list` when rows is non-empty), src/display/mod.rs:render_user_list (already formats VARIABLE/VALUE/SOURCE columns with `${key}` display — reuse without modification)
- Risks: scanning all active connection fields for `${...}` tokens must cover all fields that support template expansion (primarily `user:`, but verify whether `host:` or `key:` can also carry placeholders — if yes, those must be scanned too); `render_user_list` displays each key as `${key}` already, so the VARIABLE column output is correct without changes; the Users table must use `Renderer::user_list` rather than a new renderer method to avoid duplicating the table-formatting logic — verify `user_list` accepts `&[UserRow]` (it does, per the existing signature); the blank line between the Users table and the connections table must be emitted by `list::run` (via `println!()` or a `Renderer` helper) after calling `renderer.user_list` and before calling `renderer.list`, not inside either renderer method, to keep the renderers stateless; deduplication of placeholder keys is required — if `${deploy_user}` appears in three connections it should produce only one row in the Users table; placeholder extraction must handle fields that contain multiple `${...}` tokens (e.g. a hypothetical `user: "${prefix}_${suffix}"`) by emitting one row per unique key; the `--all` flag passes shadowed connections to the renderer — decide whether placeholders from shadowed connections should appear in the Users table (safest: scan only active connections, not `all_connections`, to avoid surfacing variables from overridden entries)
- [x] **Prompt for missing user during yconn install / ssh-config install** [cli] M
- Acceptance: (1) `yconn install` and `yconn ssh-config install` scan all project connections for `${key}` user-field tokens that are not present in `cfg.users`; when one or more unresolved keys are found the command halts before writing any files and prints a grouped prompt listing each missing key and the connection names that reference it (e.g. `Missing user variable '${t1user}' used by: conn-a, conn-b`); the user is then prompted to provide a value for each missing key following the same flow as `yconn users add` (value is written to the target layer's config via `write_user_entry`); after all missing keys are resolved the command proceeds normally; (2) when all user variables are already resolved the commands behave identically to today with no extra prompts; (3) unit tests in `src/commands/install.rs` cover: connections with unresolved `${key}` trigger prompt and supplied value is written, connections with all keys resolved skip prompting, multiple connections referencing the same missing key produce a single prompt listing all connection names; (4) unit tests in `src/commands/ssh_config.rs` cover: `run_install` with unresolved key halts and prompts, supplied value resolves the token in generated Host blocks; (5) functional tests in `tests/functional.rs` cover: `yconn install` with missing user variable prompts and writes the value then completes the install, `yconn ssh-config install` with missing user variable prompts and writes the value then generates correct Host blocks; `make test` passes
- Depends on: Show user variable resolution table above connections in yconn list output
- Modify: src/commands/install.rs, src/commands/ssh_config.rs, tests/functional.rs
- Create: none
- Reuse: src/config/mod.rs:expand_user_field (detect unresolved keys before proceeding), src/config/mod.rs:LoadedConfig::users (check which keys are already defined), src/commands/ssh_config.rs:extract_unresolved_key (extract key name from unresolved token), src/commands/user.rs:write_user_entry (write prompted value to target layer config), src/commands/user.rs:layer_path (resolve target directory for writing user entries)
- Risks: `run_impl` in `install.rs` currently receives `project_file` and `target_file` paths but has no access to a `LoadedConfig` — it cannot call `expand_user_field` without either threading `cfg` through or re-implementing token scanning; the simplest approach is to scan for `${...}` tokens in raw YAML user fields via regex or string matching in `run_impl` and cross-reference against the target file's existing `users:` section; `run_install` in `ssh_config.rs` already calls `expand_user_field` per connection — the prompt-and-write logic must run as a pre-pass before the per-connection loop so that all missing keys are collected first and each key is prompted only once regardless of how many connections reference it; the prompted value must be persisted via `write_user_entry` to the user-layer config so subsequent runs do not re-prompt — this means the install commands gain a write side-effect on `~/.config/yconn/connections.yaml` even though their primary target may be a different file; `extract_unresolved_key` is currently `fn` (private) in `ssh_config.rs` — it must be made `pub(crate)` or moved to a shared location so `install.rs` can reuse it; the interactive prompt must read from the same `input: &mut dyn BufRead` already threaded through `run_impl` (install) and must be added to `run_install` (ssh-config) which currently uses `stdin` implicitly via `println!` — threading a `BufRead` into `run_install` requires a signature change and updating all call sites including tests
- [x] **Restructure `auth` from a string field into a structured node with `type`, `key`, and `cmd` fields** [core] L
- Acceptance: (1) `auth` is a YAML mapping node with `type` (required: `"key"` or `"password"`), `key` (required when type=key, path to private key), and `cmd` (optional, shell command string); (2) the old flat format (`auth: key` + sibling `key:` field) is removed — this is a breaking change; (3) `Auth` is a serde-tagged Rust enum with `Key { key, cmd }` and `Password` variants; (4) `RawConn.auth` is `Option<Auth>`, `Connection.auth` is `Auth`; (5) `build_args` in `connect` uses `Auth::Key { ref key, .. }` to emit `-i` flag; (6) `yconn show` displays `Auth:`, `Key:`, and `Cmd:` (when present) lines; (7) `yconn list` shows auth type label in the AUTH column; (8) `yconn ssh-config` outputs `# auth:` comment with type label and `IdentityFile` from `auth.key()`; (9) `yconn connections add` wizard emits nested `auth:` YAML block; (10) `yconn show --dump` serializes `auth` as a nested YAML mapping; (11) `cmd` field is parsed and stored only — no execution logic; (12) all unit tests, functional tests, and example configs updated to new format; (13) `make test` passes; (14) `make lint` passes
- Depends on: Add `yconn install` command to install project connections into a target layer
- Modify: src/config/mod.rs, src/connect/mod.rs, src/display/mod.rs, src/commands/show.rs, src/commands/list.rs, src/commands/connect.rs, src/commands/ssh_config.rs, src/commands/add.rs, src/security/mod.rs, tests/functional.rs, config/connections.yaml, CLAUDE.md
- Create: none
- Reuse: src/config/mod.rs:RawConn (replace `auth: Option<String>` + `key: Option<String>` with `auth: Option<Auth>`), src/config/mod.rs:Connection (replace `auth: String` + `key: Option<String>` with `auth: Auth`), src/config/mod.rs:build_connection (map `raw.auth` directly to `conn.auth`), src/config/mod.rs:validate_connections (check `auth` is `Some`), src/connect/mod.rs:build_args (match on `Auth` enum for `-i` flag), src/display/mod.rs:ConnectionDetail (add `cmd: Option<String>` field and render it), src/commands/show.rs:DumpConn (serialize `Auth` enum as nested YAML), src/commands/add.rs:build_entry (emit nested `auth:` YAML block)
- Risks: serde `#[serde(tag = "type")]` internally-tagged enum requires the YAML mapping to contain a `type` key — verify `serde_yaml` handles this correctly for both serialization and deserialization; all inline YAML test fixtures (~30+ in `tests/functional.rs` alone) must be updated with correct nested indentation or deserialization will fail silently; `DumpConn` in `show.rs` uses `Serialize` — the `Auth` enum must derive both `Serialize` and `Deserialize` with matching tag configuration; `build_entry` in `add.rs` constructs YAML by hand — the nested `auth:` block requires careful indentation (6 spaces for inner fields if connection fields use 4); the `security` module's credential-field scanning checks YAML mapping keys not values, so `type: password` (where "password" is a value) should not trigger a false positive — verify this
- [x] **Add identity-only connection type for ssh-config-only entries (e.g. git hosts)** [core] M
- Acceptance: (1) a new `auth: identity` type is accepted in connection YAML with a required `key` field (path to private key); (2) `yconn ssh-config install` generates Host blocks for identity connections containing `IdentityFile <key>` and `IdentitiesOnly yes` directives; (3) `yconn connect` allows connecting to identity connections but prints a warning to stderr that the host may not support interactive SSH; (4) `yconn list` and `yconn show` display the auth type as `identity` in the AUTH column and detail view respectively; (5) `yconn connections add` wizard includes `identity` as a third option alongside `key` and `password` and prompts for `key` when selected; (6) `yconn show --dump` serializes `auth: identity` connections correctly; (7) config validation rejects `auth: identity` without a `key` field (same as `auth: key`); (8) unit tests in `src/config/mod.rs` cover: identity connection parsed correctly, identity without key rejected; unit tests in `src/connect/mod.rs` cover: `build_args` for identity auth produces `ssh -F /dev/null -i <key> user@host` (same as key auth) and warning is emitted; unit tests in `src/commands/ssh_config.rs` cover: `render_ssh_config` for identity auth emits `IdentityFile` and `IdentitiesOnly yes`; unit tests in `src/commands/add.rs` cover: wizard with identity choice emits correct YAML; functional tests in `tests/functional.rs` cover: identity connection round-trip (add, list, show, ssh-config install); `make test` passes
- Depends on: Restructure `auth` from a string field into a structured node with `type`, `key`, and `cmd` fields
- Modify: src/config/mod.rs, src/connect/mod.rs, src/display/mod.rs, src/commands/ssh_config.rs, src/commands/show.rs, src/commands/list.rs, src/commands/connect.rs, src/commands/add.rs, tests/functional.rs, config/connections.yaml
- Create: none
- Reuse: src/config/mod.rs:Auth (add `Identity { key, cmd }` variant to the serde-tagged enum), src/config/mod.rs:validate_connections (extend key-required check to cover identity type), src/connect/mod.rs:build_args (extend Auth match to handle Identity variant — emit `-i <key>` same as Key), src/commands/ssh_config.rs:render_ssh_config (extend key-emission branch to also emit `IdentitiesOnly yes` for identity auth), src/commands/add.rs:prompt_choice (add "identity" to the choices list), src/commands/add.rs:build_entry (handle identity auth type in YAML generation), src/display/mod.rs:ConnectionRow (auth field already renders as string — "identity" will display automatically), src/display/mod.rs:ConnectionDetail (auth field renders as string — "identity" will display automatically)
- Risks: the `IdentitiesOnly yes` directive must appear after `IdentityFile` in the generated Host block — verify `render_ssh_config` emits them in the correct order; if the auth restructuring task uses `#[serde(tag = "type")]` the new `Identity` variant must use the same tag scheme — the YAML representation will be `auth: { type: identity, key: ... }` and `serde_yaml` must round-trip this correctly; `build_args` for identity auth is functionally identical to key auth (both emit `-i <key>`) but the connect handler must additionally emit a warning via `renderer.warn` — this requires threading a renderer or warning callback into the connect path, which currently has no such parameter; alternatively the warning can be emitted by the CLI command handler in `src/commands/connect.rs` before calling `connect::run`, by inspecting `conn.auth`; the `IdentitiesOnly yes` directive tells SSH to use only the specified identity file and ignore ssh-agent — this is the correct behaviour for git hosts but may surprise users who expect agent-forwarded keys to work; the warning message for `yconn connect` should clearly state "this connection is configured as identity-only (e.g. for git hosts) and may not support interactive SSH sessions"
- [x] **Extend example config/connections.yaml to showcase all documented features** [docs] S
- Acceptance: `config/connections.yaml` includes working examples of: (1) all three `auth` types (`key`, `password`, `identity`) using the nested `auth:` mapping format with `type`, `key`, and `cmd` fields; (2) at least one connection with `auth.cmd` populated; (3) at least one connection with a `group` field (inline group tag); (4) at least one connection with a `${variable}` template in the `user` field; (5) a `users:` block with at least two entries including one that resolves a template used in a connection; (6) an uncommented `docker:` block with `image`, `pull`, and `args` fields all present; (7) at least five connections demonstrating different field combinations (with/without port, with/without link, different auth types, with/without group); (8) the file remains valid YAML that parses without error via `serde_yaml`; (9) comments are present explaining each feature for quick-start reference; no code changes, no new tests required
- Depends on: Add identity-only connection type for ssh-config-only entries (e.g. git hosts)
- Modify: config/connections.yaml
- Create: none
- Reuse: src/config/mod.rs:RawConn (field names define valid YAML keys), src/config/mod.rs:Auth (enum variants define valid auth type/key/cmd combinations), src/config/mod.rs:DockerConfig (fields define valid docker block keys)
- Risks: the example file is loaded by some functional tests or used as a documentation reference -- verify no test imports or parses `config/connections.yaml` directly before changing it; the `cmd` field is parsed and stored only (no execution logic) so any example value is safe; ensure the `${variable}` template in the example matches a key in the `users:` block so the example is self-consistent
- [ ] **Rewrite make install to build and install a distro-native package (deb or Arch) instead of raw file copy** [packaging] S
- Acceptance: `make install` detects the distro via `/etc/os-release` (Debian/Ubuntu vs Arch), depends on `make package` to produce the `.deb` or `.pkg.tar.zst`, then runs `sudo dpkg -i` or `sudo pacman -U` as appropriate; unrecognized distros get a clear error message suggesting manual installation; `sudo` is scoped to only the package-manager invocation (the user never runs `sudo make install`); the existing `make package`, `build-deb`, and `build-pkg` targets are unchanged; manually running `make install` on a Debian-based system installs the `.deb` and on Arch installs the `.pkg.tar.zst`
- Depends on: Extend example config/connections.yaml to showcase all documented features
- Modify: Makefile
- Create: none
- Reuse: scripts/install-deps.sh:is_debian_like (distro-detection pattern using /etc/os-release, ID, ID_LIKE), scripts/install-deps.sh:is_arch_like (same pattern for Arch detection), scripts/build-deb.sh (output path convention: dist/${BINARY}_${VERSION}_amd64.deb), scripts/build-pkg.sh (output path convention: dist/${BINARY}-${VERSION}-1-x86_64.pkg.tar.zst)
- Risks: `make install` currently accepts a `PREFIX` variable for the install path — removing raw-copy semantics is a breaking change for anyone using `PREFIX=/opt make install`; the `sudo` prompt inside a Makefile recipe may confuse users who expect `make install` to be non-interactive — the error message for unrecognized distros should also mention that `make package` can be used standalone to build packages without installing; the `.deb` and `.pkg.tar.zst` filenames are constructed from `BINARY` and `VERSION` Makefile variables which must stay in sync with the scripts; `dpkg -i` does not resolve dependencies (unlike `apt install`) — if the `.deb` declares dependencies the user may need to run `sudo apt-get install -f` afterwards, or the recipe should use `sudo apt-get install ./dist/<pkg>.deb` instead of `dpkg -i`