Skip to content

feat(0.30.7): project edit --new-alias + --dry-run (cascading rename) - #266

Merged
padak merged 1 commit into
keboola:mainfrom
ottomansky:feat/project-edit-new-alias
May 11, 2026
Merged

feat(0.30.7): project edit --new-alias + --dry-run (cascading rename)#266
padak merged 1 commit into
keboola:mainfrom
ottomansky:feat/project-edit-new-alias

Conversation

@ottomansky

Copy link
Copy Markdown
Contributor

Summary

Adds --new-alias NEW to kbagent project edit so users can rename a project alias without project remove + project add (which forces token re-entry). Cascading scope:

  • config.json projects dict key + default_project field
  • nested-layout sync directory <cwd>/<old-alias>/ -> <cwd>/<new-alias>/ (-2 collision suffix, git mv with shutil.move fallback -- mirrors kbagent config rename precedent)
  • WARNS on *.lineage.json -- caches embed FQNs and are NOT auto-rewritten (partial rewrites are worse than no rewrite)

Combined with --url/--token in one call, those mutations target the new alias post-rename: kbagent project edit --project foo --new-alias bar --token NEW is one atomic operation.

Closes the gap noted in user feedback after v0.29.0 -- previously the only way to rename was hand-editing `~/.config/keboola-agent-cli/config.json`.

Live validation results

Against project 1143 (`99_Playground_Max`, europe-west3.gcp.keboola.com):

# Step Result
1 Baseline `kbagent project list` shows `99_playground_max`, default_project = same
2 `project edit --project 99_playground_max --new-alias playground` exit 0; result has `old_alias`, `rename.default_project_updated: true`
3 `config.json jq '.projects
4 `project list` post-rename shows `playground` as default
5 Pre-create `playground/.keboola/manifest.json` nested sync workspace set up
6 Reverse rename `--new-alias 99_playground_max` exit 0; `sync_dir.method = "shutil_move"`; old dir gone, new dir present
7 Error path: `--new-alias " "` exit 5 `Invalid --new-alias: must not be empty or whitespace-only`
8 Error path: `--new-alias "has spaces"` exit 5 `must not contain whitespace`
9 Error path: no flags at all exit 5 `No changes specified. Provide --url, --token, and/or --new-alias`
10 Workspace cleanup back to baseline, no orphan artifacts

Files changed

Layer File
Command `src/keboola_agent_cli/commands/project.py` -- new `--new-alias` Typer option, dual human/JSON output
Service `src/keboola_agent_cli/services/project_service.py` -- `edit_project` accepts `new_alias` + `search_root`; new helpers `_rename_project_alias`, `_validate_alias_format`, `_rename_nested_sync_dir`, `_move_directory`, `_detect_lineage_cache_warning`
Store `src/keboola_agent_cli/config_store.py` -- new `rename_project(old, new)` (atomic dict-key swap + `default_project` cascade in one save)
Tests `tests/test_project_edit.py` (28 service tests) + `tests/test_project_edit_cli.py` (4 CLI tests)
Version `pyproject.toml` 0.30.2 -> 0.30.3; `plugin.json` / `marketplace.json` / `uv.lock` synced via `make version-sync`
Changelog `src/keboola_agent_cli/changelog.py` -- 0.30.3 entries (feat + security hardening + tests)
Sync map `commands/context.py` AGENT_CONTEXT, `CLAUDE.md` All-CLI-Commands, `commands-reference.md`, `gotchas.md` (new `(since v0.30.3)` entry on lineage cache rebuild), `keboola-expert.md` VERSION GATE + tool selection matrix row

Security hardening

Iter-2 review surfaced two BLOCKING items that are now closed:

  • Path-traversal validator: `new_alias` regex `[A-Za-z0-9_][A-Za-z0-9_.-]*` plus explicit `..` rejection. Rejects path separators, NUL bytes, leading dot/dash, whitespace, and characters outside the slug alphabet. Stricter than `project add`'s no-op check; rationale is the rename's filesystem interaction.
  • Disk-rename rollback: any `OSError` during the disk-side rename triggers a config rollback (`rename_project(new, old)`) so config and disk never end up out of sync; rollback's own failure is suppressed via `contextlib.suppress` so the original error wins.

Plus from iter 2 NON-BLOCKING:

  • `search_root` resolved via `Path.resolve()` once before disk rename to collapse symlinks (defense against malicious cwd).
  • Lineage cache scan depth-capped at 2 levels (`.lineage.json`, `/.lineage.json`, `//.lineage.json`) to bound cost when search_root is a deep tree.
  • Symlink target collision triggers `-2` suffix bump (covers both real-dir and symlink-to-elsewhere cases).

Test plan

  • `make check` clean (2807 passed, 7 skipped, 0 failed)
  • 32 new tests (28 service + 4 CLI) including parametrized 9-input path-traversal validator + rollback + symlink coverage
  • Live: 10 scenarios against project 1143 (table above)
  • Self-review (iter 1) -- 0 findings
  • Independent reviewer (iter 2) -- 2 BLOCKING + 5 NON-BLOCKING + 4 NIT, all addressed
  • Convergence (iter 3) -- CONVERGED, zero material findings
  • Sync map walked end-to-end (5 surfaces touched)
  • No tokens / scratch IDs in diff

Out-of-scope follow-ups

I'll file these as separate issues if the maintainer agrees they're worth tracking; flagging here so the deferred scope doesn't orphan:

  • Lineage cache auto-rewrite -- caches may live anywhere on disk; partial rewrites are worse than no rewrite. Manual rebuild via `kbagent lineage build` is documented in `gotchas.md` and surfaced via stderr warning at rename time.
  • Cross-device partial-copy cleanup (iter-3 NIT) -- if `shutil.move` fails mid-copy across mounts, partial files at the target are NOT cleaned by the rollback. Failure mode is non-destructive (config rolls back, original dir untouched, partial files at the new path require manual cleanup).
  • `project.edit` permission split (iter-2 deferred per orphan-prevention rule) -- current `admin` classification covers credential rotation; new `--new-alias` is local-only and arguably `write`. Worth considering `project.edit-credentials` (admin) vs `project.edit-local-alias` (write).

@ottomansky
ottomansky force-pushed the feat/project-edit-new-alias branch from 5a05101 to 4a66ead Compare May 7, 2026 14:16
@ottomansky ottomansky changed the title feat(0.30.3): project edit --new-alias (cascading rename) feat(0.30.4): project edit --new-alias (cascading rename) May 7, 2026
@ottomansky

Copy link
Copy Markdown
Contributor Author

Rebase note

Rebased onto upstream/main after PR #265 (closes #263) merged at `8dedfd1` and took the 0.30.3 slot. This PR now ships as 0.30.4:

State: `mergeStateStatus: CLEAN`, `mergeable: MERGEABLE`. Ready for human review whenever you have a moment, @padak.

@padak padak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Well-executed PR addressing a real UX gap (rename without re-entering token, cascading config + nested-sync-dir rename in one atomic call). 3-layer separation is clean, security hardening from iter 2 (path-traversal validator, disk-rename rollback, Path.resolve(), depth-cap, symlink-target collision) is solid, and the silent-drift surfaces are all updated correctly:

  • gotchas.md carries the (since v0.30.4) tag
  • keboola-expert.md has both Rule 6 VERSION GATE and Tool Selection Matrix entries
  • commands-reference.md, commands/context.py AGENT_CONTEXT, and CLAUDE.md All-CLI-Commands all match
  • Changelog is detailed and accurate
  • 32 new tests (28 service + 4 CLI), including parametrized 9-input path-traversal surface, rollback (both happy + rollback-also-fails), and symlink collision

One blocker before merge, plus a NIT.

BLOCKING: E2E coverage missing

CONTRIBUTING.md "Tests (mandatory!)" / coding convention #16 in CLAUDE.md: Every CLI command must have E2E coverage. The existing _test_project_edit_and_remove() at tests/test_e2e.py:2836 only exercises --url; --new-alias is a new on-disk operation surface (filesystem rename + rollback contract) and warrants its own hop in the E2E flow.

Suggested minimal round-trip insertion at the top of _test_project_edit_and_remove (before the existing --url step):

# Rename round-trip -- exercise --new-alias against a real config dir
new_alias = f"{self.alias}-renamed"
data = self._run_ok(
    "project", "edit", "--project", self.alias, "--new-alias", new_alias
)
assert data["data"]["old_alias"] == self.alias
assert data["data"]["alias"] == new_alias
# Rename back so subsequent steps keep using self.alias unchanged
data = self._run_ok(
    "project", "edit", "--project", new_alias, "--new-alias", self.alias
)
assert data["data"]["alias"] == self.alias

Then make test-e2e to confirm.

NIT (optional): consider --dry-run

CONTRIBUTING.md "UX considerations": Destructive operations have --dry-run and --yes flags. Rename isn't classically destructive (rollback exists, no remote data lost), but a dry-run would surface (a) collision detection, (b) the lineage-cache warning, (c) the planned method (git_mv vs shutil_move) without any mutation. Skip if you'd rather defer.

Explicitly NOT flagged

  • --hint client / --hint service -- intentionally skipped. project edit --new-alias is a purely local operation (config.json + filesystem), no Keboola API counterpart. The whole project.edit family is hint-less today (hints/definitions/project.py only registers description-get, description-set, info) and that's appropriate for non-API commands. Not a regression introduced by this PR.

  • Permission split (project.edit-credentials admin vs project.edit-local-alias write) -- you already flagged this as out-of-scope. Default-deny admin is the safer default; can ship as-is and revisit only if a user actually hits the policy friction.

  • Cross-device partial-copy cleanup -- already documented in your iter-3 NIT log; non-destructive failure mode.

Other observations

  • Rollback contract is well documented and tested (test_oserror_in_disk_rename_restores_config, test_rollback_failure_is_swallowed_original_error_wins) -- exactly the right belt-and-suspenders.
  • Path-traversal validator's stricter-than-project add rationale is correctly documented inline at _validate_alias_format.
  • Mirror pattern with ConfigService._move_directory (returning git_mv / shutil_move strings) keeps JSON-consumer vocabulary consistent across rename surfaces.

Once E2E lands, this is good to go.

Adds `--new-alias NEW` to `kbagent project edit` so users can rename a
project alias without going through `project remove` + `project add`
(which forces token re-entry). Adds `--dry-run` for read-only preview.
Mirrors the `kbagent config rename` precedent for the on-disk part of
the cascade.

Cascading scope:
- config.json `projects` dict key (`pop(old)` + insert under `new`)
- config.json `default_project` field (when it matched the old alias)
- nested-layout sync directory `<cwd>/<old-alias>/` -> `<cwd>/<new-alias>/`
  (with -2 collision suffix, git-mv-with-shutil-fallback)
- WARNS on `*.lineage.json` -- caches embed alias FQNs and are NOT
  auto-rewritten (partial rewrites are worse than no rewrite)

`--dry-run` (PR keboola#266 review NIT, addressed) previews collision detection,
planned disk-rename method (`git_mv` vs `shutil_move`), and the lineage-
cache warning without mutating any state. Validation errors raise the
same `ConfigError` exit-5 codes as the live path -- callers can rely on
`--dry-run` as a 1:1 pre-flight.

Combined with `--url` and/or `--token` in one call, those mutations
target the new alias post-rename: `kbagent project edit --project foo
--new-alias bar --token NEW` is one atomic operation.

Service / Command:
- `commands/project.py` -- new `--new-alias` and `--dry-run` Typer
  options; human formatter branches on `dry_run` / `old_alias`
- `services/project_service.py` -- `edit_project` accepts `new_alias`,
  `search_root`, `dry_run`; new helpers `_rename_project_alias`,
  `_validate_alias_format`, `_rename_nested_sync_dir`, `_move_directory`,
  `_detect_lineage_cache_warning` for the live path; `_plan_project_alias_rename`
  and `_plan_nested_sync_dir` for the read-only dry-run path

ConfigStore:
- `config_store.py` -- new `rename_project(old, new)` method (atomic
  dict-key swap + `default_project` cascade in one save() call)

Security hardening (from review iter 2):
- Validator regex `[A-Za-z0-9_][A-Za-z0-9_.-]*` plus explicit `..`
  rejection; rejects path traversal, NUL bytes, leading dot/dash,
  whitespace, and characters outside the slug alphabet. Stricter than
  `project add`'s no-op check; rationale is the rename's filesystem
  interaction (alias becomes a directory name).
- `search_root` resolved via `Path.resolve()` once before the disk
  rename to collapse symlinks; closes a malicious-cwd vector.
- Disk rename failures (`OSError`) trigger a config rollback so config
  and disk never end up out of sync; rollback's own failure is
  suppressed via `contextlib.suppress` so the original error wins.
- Lineage cache scan depth-capped at 2 levels (top + `*/` + `*/*/`)
  to bound cost when search_root is a deep tree.

Tests: 38 new (32 service + 6 CLI). Pin alias-key swap, collision
rejection, default_project cascade, sync-dir disk rename, no-sync-dir
no-op, sync-dir collision -2 suffix, combined edit-and-rename, no-op
on same-alias-only, parametrized 9-input path-traversal validator,
legal slug shapes accepted, OS failure rolls config back, rollback
failure surfaces original error, symlink target collision triggers
suffix bump, dry-run no-mutation happy path, dry-run collision still
raises, dry-run format validation still raises, dry-run predicts disk
method without touching disk, dry-run human DRY-RUN label, dry-run
JSON planned-block shape.

E2E: `tests/test_e2e.py::_test_project_edit_and_remove` extended with
a `--dry-run` preview (planned-block assertion) followed by a live
`--new-alias` round-trip (rename + reverse-rename to baseline) before
the existing `--url` step. Pinned by Padak's PR keboola#266 review BLOCKING --
every CLI command must have E2E coverage per CONTRIBUTING.md /
convention keboola#16.

Sync map updates:
- AGENT_CONTEXT (commands/context.py) -- new flags mentioned
- CLAUDE.md `## All CLI Commands` -- same wording
- commands-reference.md -- expanded with cascade scope + dry-run
- gotchas.md -- new `(since v0.30.7)` entry on lineage cache rebuild
- keboola-expert.md -- VERSION GATE clause + tool selection matrix row

Live-validated against project 1143 (`99_Playground_Max`,
europe-west3.gcp.keboola.com): rename to `playground` + reverse rename
to baseline; nested sync dir moved on disk; default_project cascaded;
all error paths produce correct ConfigError exit-5 messages.

Three review iterations on the original PR: self -> independent ->
convergence; zero material findings on the convergence pass. Padak's
post-merge review found 1 BLOCKING (E2E coverage) + 1 NIT (`--dry-run`);
both addressed in this iteration.

Rebased onto upstream/main after Padak shipped 0.30.4 (keboola#268),
0.30.5 (keboola#272), and 0.30.6 (keboola#273) since the original PR opened.
This PR ships as 0.30.7.
@ottomansky
ottomansky force-pushed the feat/project-edit-new-alias branch from 4a66ead to ee5f77c Compare May 8, 2026 22:02
@ottomansky ottomansky changed the title feat(0.30.4): project edit --new-alias (cascading rename) feat(0.30.7): project edit --new-alias + --dry-run (cascading rename) May 8, 2026
@ottomansky

Copy link
Copy Markdown
Contributor Author

Iter 2 — addressed your review (BLOCKING + NIT both done)

@padak Both review items landed in commit `ee5f77c`:

BLOCKING — E2E coverage ✅

Extended `tests/test_e2e.py::_test_project_edit_and_remove` with the rename round-trip you suggested PLUS a `--dry-run` preview hop, both before the existing `--url` step:

```python

1. dry-run pre-flight

data = self._run_ok("project", "edit", "--project", self.alias,
"--new-alias", new_alias, "--dry-run")
assert data["data"]["dry_run"] is True
assert data["data"]["alias"] == self.alias # unchanged
assert data["data"]["planned"]["new_alias"] == new_alias

2. live rename + reverse-rename round-trip

data = self._run_ok("project", "edit", "--project", self.alias,
"--new-alias", new_alias)
assert data["data"]["old_alias"] == self.alias
assert data["data"]["alias"] == new_alias
assert data["data"]["rename"]["new_alias"] == new_alias

rename back so subsequent steps keep using self.alias unchanged

data = self._run_ok("project", "edit", "--project", new_alias,
"--new-alias", self.alias)
```

Round-trip leaves `self.alias` unchanged so the existing `--url` step + `project remove` still work as before.

NIT — `--dry-run` ✅

Added `--dry-run` to `kbagent project edit` end-to-end:

  • CLI: new `--dry-run` Typer option in `commands/project.py:project_edit`. Human formatter prepends `[bold yellow]DRY RUN[/bold yellow]`.
  • Service: `edit_project` accepts `dry_run: bool`. New `_plan_project_alias_rename()` and `_plan_nested_sync_dir()` helpers in `services/project_service.py` -- pure read-only mirrors of the live `_rename_project_alias` / `_rename_nested_sync_dir`. Validation errors (`..` path-traversal, collision, invalid format) raise the same `ConfigError` exit-5 codes as the live path. Token re-verification skipped in dry-run mode (no API hit).
  • Result shape: `{dry_run: True, alias: , planned: {new_alias, default_project_would_update, sync_dir_would_move: {planned_method: "git_mv"|"shutil_move", collision_suffix}, lineage_cache_warning}}`. Matches the `config update --dry-run` precedent.

Live verification

Smoke-tested against project 1143 in my testing workspace:

```json
$ kbagent --json project edit --project 99_playground_max --new-alias playground --dry-run
{
"status": "ok",
"data": {
"alias": "99_playground_max",
"dry_run": true,
"planned": {
"old_alias": "99_playground_max",
"new_alias": "playground",
"rename": {
"default_project_would_update": true,
"sync_dir_would_move": null,
"lineage_cache_warning": null
}
}
}
}
```

Config file unchanged after the dry-run.

Test count: 32 → 38 (+6 new)

`tests/test_project_edit.py` (32 service): added `TestRenameAliasDryRun` with happy-path, collision-still-raises, format-validation-still-raises, predicts-method-no-disk-change.

`tests/test_project_edit_cli.py` (6 CLI): added human-DRY-RUN-label and JSON-planned-block tests.

Rebase note

Rebased onto upstream/main after you shipped #268 (0.30.4), #272 (0.30.5), and #273 (0.30.6) overnight. PR now ships as 0.30.7. No code conflicts on my files; only the `changelog.py` 0.30.4 slot needed to move — your sync-bug-fix entries from #268 stayed at 0.30.4 verbatim, mine moved up to a new 0.30.7 block.

State

  • `make check` clean: 2873 passed, 0 failed
  • CI green on `ee5f77c` (run 25581728804, 1m37s)
  • `mergeStateStatus: CLEAN`, `mergeable: MERGEABLE`
  • PR title bumped to `feat(0.30.7):`

Ready for re-review whenever you have a moment, @padak.

@ottomansky

Copy link
Copy Markdown
Contributor Author

@padak — iter-2 addressed both review items (E2E round-trip + --dry-run end-to-end), CI green on ee5f77c, mergeable: CLEAN against main (0.30.6), rebased to 0.30.7. Mind a final look + merge when you get a moment? Thanks!

@padak
padak merged commit 0dfde55 into keboola:main May 11, 2026
1 check passed
padak added a commit that referenced this pull request May 11, 2026
…276)

Both PR #266 (feat: project edit --new-alias + --dry-run) and PR #275
(fix: per-element sql_resplit closes ODBC statement-count crash on
#274) shipped as patch bumps (0.30.7 / 0.30.8) but never carried an
external release. Consolidate them into a single minor release 0.31.0
-- a feature warrants the minor bump and the bug-fix rides along.

No code changes; this is purely the version-label rename across the
silent-drift sync surfaces:

- pyproject.toml + plugin.json + marketplace.json + uv.lock -> 0.31.0
  (via make version-sync)
- changelog.py: 0.30.8 + 0.30.7 keys merged into one 0.31.0 block,
  features first (highlight ordering), then the SQL fix, then tests
- gotchas.md: 5 references to (since v0.30.7) / (since v0.30.8) ->
  (since v0.31.0)
- commands-reference.md: project edit gotcha pointer + config update
  auto-normalize version-gate annotation -> 0.31.0
- keboola-expert.md: 6 references in Rule 6 VERSION GATE, Tool
  Selection Matrix rows, and inline gotchas -> 0.31.0

make check (lint + format + skill freshness + version sync + changelog
completeness + 2895 tests) clean. No regressions.
@ottomansky
ottomansky deleted the feat/project-edit-new-alias branch May 14, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants