panorama-super-cli 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/CHANGELOG.md +24 -0
  2. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/PKG-INFO +1 -1
  3. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/cli.md +17 -0
  4. panorama_super_cli-0.4.2/docs/superpowers/specs/2026-06-08-move-objects-to-shared-design.md +143 -0
  5. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/_version.py +1 -1
  6. panorama_super_cli-0.4.2/psc/cli/_options.py +42 -0
  7. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/app.py +5 -0
  8. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/decommission_cmds.py +3 -6
  9. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/dedup_cmds.py +12 -20
  10. panorama_super_cli-0.4.2/psc/cli/move_cmds.py +64 -0
  11. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/name_cmds.py +3 -12
  12. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/refs_cmds.py +3 -4
  13. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/rule_cmds.py +4 -9
  14. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/set_cmds.py +22 -28
  15. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/loader.py +3 -1
  16. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/apply_live.py +2 -1
  17. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/apply_xml.py +2 -1
  18. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/changeset.py +10 -0
  19. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/crud.py +14 -9
  20. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/decommission.py +24 -17
  21. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/dedup.py +0 -4
  22. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/normalize.py +15 -8
  23. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/parse.py +8 -7
  24. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/refs.py +10 -14
  25. panorama_super_cli-0.4.2/psc/core/relocate.py +308 -0
  26. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/setcmd.py +2 -1
  27. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/source.py +32 -32
  28. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/format.py +3 -6
  29. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/pyproject.toml +1 -1
  30. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/skills/panorama-super-cli/SKILL.md +20 -0
  31. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_changeset.py +11 -0
  32. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli.py +24 -0
  33. panorama_super_cli-0.4.2/tests/test_cli_move.py +143 -0
  34. panorama_super_cli-0.4.2/tests/test_relocate.py +246 -0
  35. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/uv.lock +577 -577
  36. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/CODEOWNERS +0 -0
  37. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  38. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  39. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  40. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/dependabot.yml +0 -0
  41. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/docs.yml +0 -0
  42. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/lint.yml +0 -0
  43. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/release.yml +0 -0
  44. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.github/workflows/test.yml +0 -0
  45. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.gitignore +0 -0
  46. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.pre-commit-config.yaml +0 -0
  47. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/.python-version +0 -0
  48. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/AGENTS.md +0 -0
  49. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/CLAUDE.md +0 -0
  50. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/LICENSE +0 -0
  51. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/README.md +0 -0
  52. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/branching.md +0 -0
  53. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/development.md +0 -0
  54. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/contributing/release-process.md +0 -0
  55. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/concepts.md +0 -0
  56. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/first-run.md +0 -0
  57. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/getting-started/install.md +0 -0
  58. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/coverage-and-limitations.md +0 -0
  59. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/duplicates-and-merging.md +0 -0
  60. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/editing-objects.md +0 -0
  61. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/finding-objects.md +0 -0
  62. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/live-vs-offline.md +0 -0
  63. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/naming.md +0 -0
  64. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/output-formats.md +0 -0
  65. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/references-and-audit.md +0 -0
  66. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/safety.md +0 -0
  67. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/guides/using-with-ai-agents.md +0 -0
  68. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/index.md +0 -0
  69. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/config.md +0 -0
  70. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/reference/exit-codes.md +0 -0
  71. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/docs/superpowers/specs/2026-06-04-where-used-all-rulebases-design.md +0 -0
  72. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/justfile +0 -0
  73. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/mkdocs.yml +0 -0
  74. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/__init__.py +0 -0
  75. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/__main__.py +0 -0
  76. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/__init__.py +0 -0
  77. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/_plan.py +0 -0
  78. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/audit_cmds.py +0 -0
  79. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/auth_cmds.py +0 -0
  80. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/find_cmds.py +0 -0
  81. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/profile_cmds.py +0 -0
  82. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/runtime.py +0 -0
  83. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/cli/version_cmds.py +0 -0
  84. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/__init__.py +0 -0
  85. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/config/models.py +0 -0
  86. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/__init__.py +0 -0
  87. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/audit.py +0 -0
  88. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/models.py +0 -0
  89. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/naming.py +0 -0
  90. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/resolve.py +0 -0
  91. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/rule_edit.py +0 -0
  92. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/rulebases.py +0 -0
  93. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/core/version_check.py +0 -0
  94. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/__init__.py +0 -0
  95. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/psc/output/errors.py +0 -0
  96. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/scripts/sync_agents_md.py +0 -0
  97. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/__init__.py +0 -0
  98. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/conftest.py +0 -0
  99. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/all-rulebases.xml +0 -0
  100. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/decommission-config.xml +0 -0
  101. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/dedup-groups.xml +0 -0
  102. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/nested-device-groups.xml +0 -0
  103. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/fixtures/panorama-config.xml +0 -0
  104. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_apply_xml.py +0 -0
  105. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_audit.py +0 -0
  106. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_auth.py +0 -0
  107. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_audit.py +0 -0
  108. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_auth.py +0 -0
  109. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_decommission.py +0 -0
  110. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_profile.py +0 -0
  111. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_rule.py +0 -0
  112. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_cli_set.py +0 -0
  113. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_crud.py +0 -0
  114. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_decommission.py +0 -0
  115. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_dedup.py +0 -0
  116. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_hardening.py +0 -0
  117. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_live_apply.py +0 -0
  118. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_naming.py +0 -0
  119. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_nested_dg.py +0 -0
  120. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_normalize.py +0 -0
  121. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_output.py +0 -0
  122. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_parse.py +0 -0
  123. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_refs.py +0 -0
  124. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_resolve.py +0 -0
  125. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_rule_edit.py +0 -0
  126. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_rulebases.py +0 -0
  127. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_setcmd.py +0 -0
  128. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_source.py +0 -0
  129. {panorama_super_cli-0.4.0 → panorama_super_cli-0.4.2}/tests/test_version_check.py +0 -0
@@ -7,6 +7,30 @@ project will follow [Semantic Versioning](https://semver.org/). While on
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## v0.4.2 — 2026-06-08
11
+
12
+ ### Added
13
+
14
+ - **`psc move <kind> <name> --from <loc> --to <loc>`** (#74) — promote an
15
+ object (`address`/`address-group`/`service`/`service-group`/`tag`) from a
16
+ device-group toward `shared`. Restricted to the safe direction: `--to` must be
17
+ `shared` or an *ancestor* of `--from`, where references fall through to the
18
+ destination with no repoint. Blocks (exit `6`) on a sibling/child/unrelated
19
+ destination (would orphan references), an intermediate device-group that
20
+ already defines the name (a shadow), the object's own dependencies
21
+ (members/tags) not being visible at the destination, or a collision with a
22
+ different-valued object already there. A collision with an identical-valued
23
+ object simply drops the source copy. Dry-run by default; offline `--apply`
24
+ round-trips through the rewritten config.
25
+
26
+ ## v0.4.1 — 2026-06-08
27
+
28
+ ### Fixed
29
+
30
+ - **`psc dedup addresses|services|groups`** (#72) — the table view now draws a
31
+ horizontal rule between each group of duplicates, so the blocks are easy to
32
+ tell apart at a glance. Machine formats (json/jsonl/yaml/csv) are unchanged.
33
+
10
34
  ## v0.4.0 — 2026-06-05
11
35
 
12
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panorama-super-cli
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Agent-friendly CLI for Palo Alto Panorama object management: find, dedup/merge, rename, and audit address/service objects safely.
5
5
  Project-URL: Homepage, https://github.com/thomaschristory/panorama-super-cli
6
6
  Project-URL: Documentation, https://thomaschristory.github.io/panorama-super-cli/
@@ -150,6 +150,23 @@ NAT-translation/PBF-next-hop references and DAG-filter-tag matches; orphan-rule
150
150
  deletions are warnings. See
151
151
  [Editing objects](../guides/editing-objects.md#decommission-an-address).
152
152
 
153
+ ### move
154
+
155
+ ```
156
+ psc move <address|address-group|service|service-group|tag> <name>
157
+ --from <shared|DG> --to <shared|DG> [--apply] [--out PATH] [-of xml|set]
158
+ ```
159
+
160
+ Promote one object from a device-group toward `shared` (create at the
161
+ destination, delete at the source). `--to` must be `shared` or an *ancestor* of
162
+ `--from` — the only direction in which references fall through to the
163
+ destination with no repoint. Blocks (exit `6`) on a sibling/child/unrelated
164
+ destination, an intermediate device-group that already defines the name (a
165
+ shadow), the object's own dependencies (members/tags) not being visible at the
166
+ destination, or a collision with a different-valued object already there. An
167
+ identical-valued collision drops the source copy. Single object per run;
168
+ dry-run by default.
169
+
153
170
  ### init
154
171
 
155
172
  ```
@@ -0,0 +1,143 @@
1
+ # move objects toward shared — design (issue #74)
2
+
3
+ ## Problem
4
+
5
+ While cleaning a Panorama, objects are routinely stranded in a device-group
6
+ when their natural home is `shared` (or a parent device-group that several
7
+ siblings inherit from). Today `psc` can find, dedup, rename, audit, and
8
+ decommission objects, but it cannot *relocate* one — the user has to delete and
9
+ recreate by hand, which is exactly the reference-unsafe operation `psc` exists
10
+ to make safe.
11
+
12
+ A "move" is, in `ChangeSet` terms, **create-at-destination + delete-at-source**.
13
+ The danger is identical to merge/rename: references are by *name*, resolved up
14
+ the device-group chain (`Snapshot.ancestors`), with nearer definitions
15
+ shadowing farther ones. A naive move can dangle references or silently change
16
+ which object a rule matches.
17
+
18
+ ## Scope (confirmed with the user)
19
+
20
+ 1. **Direction: promote *toward* shared only.** `--to` must be `shared` or a
21
+ *strict ancestor* of `--from`. This is the safe-by-construction direction:
22
+ `shared`/an ancestor is in the chain of every location in `--from`'s subtree
23
+ and is *farther* than `--from`, so any reference that resolved to the object
24
+ at `--from` still resolves to it at the destination — **no repoint needed**.
25
+ A sibling / unrelated / child destination would orphan references in
26
+ `--from`'s scope, so it is a hard **blocker**, not supported in v1.
27
+ 2. **Collision at destination → identical-value merges, different-value blocks.**
28
+ Because the move only promotes *toward* shared, an identical-value collision
29
+ is just a **delete-source**: the destination object already holds the value,
30
+ so removing the source copy lets every reference in the source subtree fall
31
+ through (ordinary shadowing) onto the destination object — no repoint, no
32
+ second copy. This works uniformly for all five kinds, so v1 supports the
33
+ identical-value merge everywhere, not only addresses.
34
+ - "identical value" per kind: address → `AddressType` + normalized value;
35
+ service → protocol + normalized ports (`normalize.service_key`);
36
+ address-group → same static members (set) + same dynamic filter;
37
+ service-group → same members (set); tag → always mergeable (a tag carries
38
+ no match-affecting value; a differing colour/comment is a warning).
39
+ - different value → **blocker** (consolidate or rename first).
40
+
41
+ We deliberately do *not* delegate to `dedup.plan_merge`: there the source
42
+ object *shadows* the destination one in the source scope, so dedup's
43
+ "kept object visible here" check would wrongly block. The delete-source +
44
+ fall-through plan is both simpler and correct for the promote direction.
45
+ 3. **Dependencies must already be visible at the destination — no auto-cascade.**
46
+ The moved object's own downward references (a group's `static` members, an
47
+ object's `tags`, a service-group's members, a dynamic group's filter tags)
48
+ must each resolve from the destination. Any that don't → **blocker** listing
49
+ exactly what to move first.
50
+ 4. **Single object per invocation.** Bulk/filtered moves are a follow-up.
51
+ 5. Dry-run by default; `--apply` to commit; same `complete()` contract as every
52
+ other mutating command.
53
+
54
+ ## Key insight
55
+
56
+ Because the move is restricted to *promote-toward-shared*, the create-at-dest +
57
+ delete-at-source plan needs **zero reference edits**. Every reference that
58
+ pointed at the object falls through, by ordinary PAN-OS shadowing, onto the new
59
+ (farther-up) definition once the source copy is removed — *provided* nothing
60
+ between source and destination already defines the name. That one caveat is the
61
+ **intermediate-shadow guard**: if any device-group strictly between `--from` and
62
+ `--to` already defines the name (same namespace), removing the source would
63
+ re-resolve references to that intermediate instead of the destination —
64
+ **blocker**.
65
+
66
+ This is why the engine is small: the two genuinely hard cases (collision,
67
+ shadowing) are delegated to `dedup` / handled by a single guard; the common
68
+ case is one `ObjectUpsert` + one `ObjectDelete`.
69
+
70
+ ## Architecture
71
+
72
+ New engine `psc/core/relocate.py` (pure, returns a `ChangeSet`) + one thin CLI
73
+ command `psc/cli/move_cmds.py`. No new `ChangeSet` op kind, no new applier code —
74
+ `ObjectUpsert`/`ObjectDelete` already render (`setcmd`) and apply
75
+ (`apply_xml`/`apply_live`).
76
+
77
+ ### `relocate.plan_move(snapshot, graph, *, kind, name, source_name, dest_name) -> ChangeSet`
78
+
79
+ Order of checks (each returns a blocked, zero-op `ChangeSet` on failure, per the
80
+ repo invariant):
81
+
82
+ 1. **Existence.** Object of `kind` named `name` defined *directly* at `source`.
83
+ Absent → blocker.
84
+ 2. **Direction.** `dest` must be in `ancestors(source)` and `dest != source`.
85
+ Otherwise → blocker ("`move` only promotes toward shared…").
86
+ 3. **Intermediate-shadow guard.** No location strictly between `source` and
87
+ `dest` may already define `name` in the object's namespace → blocker.
88
+ 4. **Dependency gate.** Every downward dependency must `graph.resolve(...)` from
89
+ `dest`. Unresolved ones → blocker listing them.
90
+ 5. **Collision dispatch.**
91
+ - destination has no same-kind `name` → **clean promote**: build the
92
+ destination `ObjectUpsert` by calling the matching `crud.plan_*` with
93
+ `location=dest` (reuses validation + the leaf-key contract + cross-kind
94
+ collision check), then append `ObjectDelete` at `source`.
95
+ - destination has same-kind `name`, identical value → **merge by
96
+ delete-source**: emit only `ObjectDelete` at `source` (no upsert), with a
97
+ warning that references now resolve to the destination object (and a second
98
+ warning if cosmetic metadata — tag colour/comment — differs).
99
+ - destination has same-kind `name`, different value → blocker.
100
+ In both non-blocked cases, if destination is `shared`, add a warning naming
101
+ any currently-*dangling* references elsewhere that will now newly resolve to
102
+ the promoted object.
103
+ 6. Enforce the zero-op-when-blocked invariant.
104
+
105
+ The namespace per kind mirrors `naming.plan_rename` / `refs`: address &
106
+ address-group → `address`, service & service-group → `service`, tag → `tag`.
107
+
108
+ ### CLI: `psc move <kind> <name> --from <loc> --to <loc> [--apply] [--out] [-of]`
109
+
110
+ `kind` is an `ObjectKind` positional (`address|address-group|service|service-group|tag`);
111
+ `--from`/`--to` are required location strings (`shared` or a device-group name);
112
+ dry-run by default. Wired as a single top-level command (like `decommission`),
113
+ not a sub-app. Delegates rendering/apply to `cli/_plan.complete`.
114
+
115
+ ## Testing (TDD; safety-critical paths first)
116
+
117
+ Engine (`tests/test_relocate.py`):
118
+ - clean promote DG→shared: one upsert@shared (exists=False) + one delete@source;
119
+ no reference edits; referrers still resolve (assert via a rebuilt graph or by
120
+ inspecting ops).
121
+ - promote DG→ancestor DG (nested fixture).
122
+ - sibling/child/unrelated destination → blocked.
123
+ - dest == source → blocked.
124
+ - object absent at source → blocked.
125
+ - collision, identical value → merge plan (delete source, no second copy).
126
+ - collision, different value → blocked.
127
+ - intermediate-shadow guard → blocked.
128
+ - dependency not visible at dest (group member / tag) → blocked, names listed.
129
+ - promote-to-shared revives a sibling dangling ref → warning emitted.
130
+
131
+ CLI (`tests/test_cli_move.py`):
132
+ - dry-run prints plan, exits 0, writes nothing.
133
+ - `--apply --out` offline round-trips (object now under destination scope, gone
134
+ from source) using `apply_xml`.
135
+ - blocked plan → exit 6 (`CONFLICT`), nothing written.
136
+ - `-o set` renders create+delete lines.
137
+
138
+ ## Out of scope (v1)
139
+
140
+ - Moving *away* from shared / to a sibling or child (would require repointing or
141
+ copying — deliberately blocked).
142
+ - Auto-cascading dependencies.
143
+ - Bulk / filtered selection.
@@ -4,4 +4,4 @@
4
4
  Keep this in sync with `[project].version` in `pyproject.toml`.
5
5
  """
6
6
 
7
- __version__ = "0.4.0"
7
+ __version__ = "0.4.2"
@@ -0,0 +1,42 @@
1
+ """Shared Typer Option constants and location helpers for the CLI layer.
2
+
3
+ These canonical Option objects exist so the mutating commands that genuinely
4
+ share a flag (`--apply`, `--out`, `--location`) declare it once. They are only
5
+ reused where the flags, defaults, AND help text are byte-identical — commands
6
+ that phrase their `--apply`/`--location`/`--out` help differently keep their own
7
+ local Option (sharing would silently change `--help`).
8
+
9
+ The two helpers collapse the shared-aware `Location` conversion duplicated
10
+ across command modules. They live here (in `cli/`, never `core/`) because the
11
+ "shared" sentinel string is a CLI-surface concern, not a domain-model one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import typer
17
+
18
+ from psc.core.models import Location
19
+
20
+ # Canonical mutating-command flags. Shared only where help/default are identical
21
+ # to these strings (currently `psc set` for all three; the `--out` help is also
22
+ # shared by dedup/name/rule, whose wording matches verbatim).
23
+ LOCATION_OPTION = typer.Option(
24
+ None, "--location", help="Target location (default: global --device-group, else shared)."
25
+ )
26
+ APPLY_OPTION = typer.Option(False, "--apply", help="Execute the change (default: dry-run).")
27
+ OUT_OPTION = typer.Option(
28
+ None,
29
+ "--out",
30
+ help="Write the plan artifact (set script or rewritten config) to this file; "
31
+ "honoured even in a dry-run (see --output-format).",
32
+ )
33
+
34
+
35
+ def location_from_name(name: str) -> Location:
36
+ """Convert a location *name* to a `Location`, honouring the "shared" sentinel."""
37
+ return Location.shared() if name == "shared" else Location.dg(name)
38
+
39
+
40
+ def optional_location(name: str | None) -> Location | None:
41
+ """Like `location_from_name`, but pass `None` through unchanged (no scope)."""
42
+ return None if name is None else location_from_name(name)
@@ -16,6 +16,7 @@ from psc.cli import (
16
16
  decommission_cmds,
17
17
  dedup_cmds,
18
18
  find_cmds,
19
+ move_cmds,
19
20
  name_cmds,
20
21
  profile_cmds,
21
22
  refs_cmds,
@@ -153,6 +154,10 @@ app.command(
153
154
  "decommission",
154
155
  help="Reference-safe teardown of address objects matching an IP/CIDR.",
155
156
  )(decommission_cmds.decommission)
157
+ app.command(
158
+ "move",
159
+ help="Promote an object toward shared (to shared or an ancestor device-group).",
160
+ )(move_cmds.move)
156
161
  app.command(
157
162
  "init",
158
163
  help="Interactively bootstrap a live profile (fetches an API key from a username/password).",
@@ -12,10 +12,11 @@ from pathlib import Path
12
12
 
13
13
  import typer
14
14
 
15
+ from psc.cli._options import location_from_name
15
16
  from psc.cli._plan import OUT_FORMAT_OPTION, complete
16
17
  from psc.cli.runtime import Runtime
17
18
  from psc.core.decommission import plan_decommission
18
- from psc.core.models import Address, Location
19
+ from psc.core.models import Address
19
20
  from psc.core.normalize import MatchKind
20
21
  from psc.core.refs import ReferenceGraph
21
22
  from psc.core.resolve import find_ips
@@ -86,7 +87,7 @@ def decommission(
86
87
  raise PscError("provide one or more IP/CIDR/range targets, or --file", ErrorType.VALIDATION)
87
88
 
88
89
  snap = rt.snapshot()
89
- loc = _resolve_scope(scope) if scope is not None else rt.scope()
90
+ loc = location_from_name(scope) if scope is not None else rt.scope()
90
91
 
91
92
  results = find_ips(snap, items, loc)
92
93
  index = snap.address_index()
@@ -109,7 +110,3 @@ def decommission(
109
110
  snap, graph, matched, scope=loc, keep_groups=keep_groups, keep_rules=keep_rules
110
111
  )
111
112
  complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
112
-
113
-
114
- def _resolve_scope(name: str) -> Location:
115
- return Location.shared() if name == "shared" else Location.dg(name)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from psc.cli._options import OUT_OPTION, optional_location
7
8
  from psc.cli._plan import OUT_FORMAT_OPTION, complete
8
9
  from psc.cli.runtime import Runtime
9
10
  from psc.core.dedup import (
@@ -15,7 +16,6 @@ from psc.core.dedup import (
15
16
  plan_merge,
16
17
  plan_merge_group,
17
18
  )
18
- from psc.core.models import Location
19
19
  from psc.core.refs import ReferenceGraph
20
20
  from psc.core.source import ConfigFormat
21
21
  from psc.output.errors import ErrorType, PscError
@@ -58,6 +58,7 @@ def addresses(
58
58
  model=groups,
59
59
  rows=_dup_rows(groups),
60
60
  table_title="duplicate addresses",
61
+ group_by="group",
61
62
  )
62
63
 
63
64
 
@@ -69,7 +70,12 @@ def services(ctx: typer.Context) -> None:
69
70
  if rt.strict and not groups:
70
71
  raise PscError("no duplicate services", ErrorType.NOT_FOUND)
71
72
  render(
72
- rt.stdout, rt.output, model=groups, rows=_dup_rows(groups), table_title="duplicate services"
73
+ rt.stdout,
74
+ rt.output,
75
+ model=groups,
76
+ rows=_dup_rows(groups),
77
+ table_title="duplicate services",
78
+ group_by="group",
73
79
  )
74
80
 
75
81
 
@@ -94,12 +100,7 @@ def groups(
94
100
  rt: Runtime = ctx.obj
95
101
  snap = rt.snapshot()
96
102
  graph = ReferenceGraph.build(snap)
97
- loc_name = location or rt.device_group
98
- loc = (
99
- None
100
- if loc_name is None
101
- else (Location.shared() if loc_name == "shared" else Location.dg(loc_name))
102
- )
103
+ loc = optional_location(location or rt.device_group)
103
104
  result = find_duplicate_groups(snap, graph, loc)
104
105
  if result.dynamic_skipped or result.unresolvable_skipped:
105
106
  rt.stderr.print(
@@ -116,6 +117,7 @@ def groups(
116
117
  model=result.buckets,
117
118
  rows=_dup_rows(result.buckets),
118
119
  table_title="duplicate address-groups",
120
+ group_by="group",
119
121
  )
120
122
 
121
123
 
@@ -133,12 +135,7 @@ def merge(
133
135
  False, "--allow-value-change", help="Permit merging objects with different values."
134
136
  ),
135
137
  apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
136
- out: str | None = typer.Option(
137
- None,
138
- "--out",
139
- help="Write the plan artifact (set script or rewritten config) to this "
140
- "file; honoured even in a dry-run (see --output-format).",
141
- ),
138
+ out: str | None = OUT_OPTION,
142
139
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
143
140
  ) -> None:
144
141
  """Collapse one address object into another, repointing every reference.
@@ -174,12 +171,7 @@ def merge_group(
174
171
  keep_location: str | None = typer.Option(None, "--keep-location"),
175
172
  remove_location: str | None = typer.Option(None, "--remove-location"),
176
173
  apply: bool = typer.Option(False, "--apply", help="Execute the merge (default: dry-run)."),
177
- out: str | None = typer.Option(
178
- None,
179
- "--out",
180
- help="Write the plan artifact (set script or rewritten config) to this "
181
- "file; honoured even in a dry-run (see --output-format).",
182
- ),
174
+ out: str | None = OUT_OPTION,
183
175
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
184
176
  ) -> None:
185
177
  """Collapse one address-group into an equivalent one, repointing references.
@@ -0,0 +1,64 @@
1
+ """`psc move` — promote an object toward shared, reference-safely (issue #74).
2
+
3
+ A thin shell over `core.relocate.plan_move`: resolve the runtime source, build
4
+ the reference graph, plan the promotion, and hand the `ChangeSet` to the shared
5
+ dry-run/apply completion. All the safety logic lives in the engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from psc.cli._plan import OUT_FORMAT_OPTION, complete
13
+ from psc.cli.runtime import Runtime
14
+ from psc.core.changeset import ObjectKind
15
+ from psc.core.refs import ReferenceGraph
16
+ from psc.core.relocate import plan_move
17
+ from psc.core.source import ConfigFormat
18
+
19
+
20
+ def move(
21
+ ctx: typer.Context,
22
+ kind: ObjectKind = typer.Argument(
23
+ ..., help="Object kind: address, address-group, service, service-group, or tag."
24
+ ),
25
+ name: str = typer.Argument(..., help="Name of the object to move."),
26
+ from_location: str = typer.Option(
27
+ ..., "--from", help="Current location: 'shared' or a device-group name."
28
+ ),
29
+ to_location: str = typer.Option(
30
+ ...,
31
+ "--to",
32
+ help="Destination: 'shared' or an ancestor device-group of --from. "
33
+ "move only promotes toward shared.",
34
+ ),
35
+ apply: bool = typer.Option(False, "--apply", help="Execute the move (default: dry-run)."),
36
+ out: str | None = typer.Option(
37
+ None,
38
+ "--out",
39
+ help="Write the plan artifact (set script or rewritten config) to this file.",
40
+ ),
41
+ output_format: ConfigFormat = OUT_FORMAT_OPTION,
42
+ ) -> None:
43
+ """Promote an object from a device-group toward `shared`.
44
+
45
+ A move is create-at-destination + delete-at-source. It is restricted to the
46
+ safe direction — `--to` must be `shared` or an *ancestor* of `--from` — so
47
+ references fall through to the destination without any repoint. Dry-run by
48
+ default; refuses (exit 6) if the move would orphan references, hit a
49
+ shadowing device-group between source and destination, leave the object's
50
+ own dependencies unresolved at the destination, or collide with a
51
+ different-valued object already at the destination.
52
+ """
53
+ rt: Runtime = ctx.obj
54
+ snap = rt.snapshot()
55
+ graph = ReferenceGraph.build(snap)
56
+ cs = plan_move(
57
+ snap,
58
+ graph,
59
+ kind=kind,
60
+ name=name,
61
+ source_name=from_location,
62
+ dest_name=to_location,
63
+ )
64
+ complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from psc.cli._options import OUT_OPTION
7
8
  from psc.cli._plan import OUT_FORMAT_OPTION, complete
8
9
  from psc.cli.runtime import Runtime
9
10
  from psc.core.changeset import ObjectKind
@@ -50,12 +51,7 @@ def rename(
50
51
  kind: ObjectKind = typer.Option(ObjectKind.ADDRESS, "--kind"),
51
52
  location: str | None = typer.Option(None, "--location"),
52
53
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
53
- out: str | None = typer.Option(
54
- None,
55
- "--out",
56
- help="Write the plan artifact (set script or rewritten config) to this "
57
- "file; honoured even in a dry-run (see --output-format).",
58
- ),
54
+ out: str | None = OUT_OPTION,
59
55
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
60
56
  ) -> None:
61
57
  """Rename one object, repointing every reference (refuses on shadow collisions)."""
@@ -73,12 +69,7 @@ def apply_scheme(
73
69
  object_name: str = typer.Option(..., "--object", help="Object to rename to its scheme name."),
74
70
  location: str | None = typer.Option(None, "--location"),
75
71
  apply: bool = typer.Option(False, "--apply", help="Execute the rename (default: dry-run)."),
76
- out: str | None = typer.Option(
77
- None,
78
- "--out",
79
- help="Write the plan artifact (set script or rewritten config) to this "
80
- "file; honoured even in a dry-run (see --output-format).",
81
- ),
72
+ out: str | None = OUT_OPTION,
82
73
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
83
74
  ) -> None:
84
75
  """Rename one object to the name the configured scheme implies for its value."""
@@ -4,8 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from psc.cli._options import location_from_name
7
8
  from psc.cli.runtime import Runtime
8
- from psc.core.models import Location
9
9
  from psc.core.refs import ReferenceGraph
10
10
  from psc.core.resolve import find_object
11
11
  from psc.output.errors import ErrorType, PscError
@@ -41,7 +41,7 @@ def used(
41
41
  kind = kind or hits[0].kind
42
42
  location = location or hits[0].location
43
43
 
44
- loc = Location.shared() if location == "shared" else Location.dg(location)
44
+ loc = location_from_name(location)
45
45
  refs = graph.where_used(kind, name, loc)
46
46
  rows = [
47
47
  {
@@ -68,10 +68,9 @@ def unused(
68
68
  graph = ReferenceGraph.build(rt.snapshot())
69
69
  targets = graph.unused(kind)
70
70
  rows = [{"kind": t.kind, "name": t.name, "location": t.location.name} for t in targets]
71
- model = [{"kind": t.kind, "name": t.name, "location": t.location.name} for t in targets]
72
71
  if rt.strict and not targets:
73
72
  raise PscError(f"no unused {kind}", ErrorType.NOT_FOUND)
74
- render(rt.stdout, rt.output, model=model, rows=rows, table_title=f"unused {kind}")
73
+ render(rt.stdout, rt.output, model=rows, rows=rows, table_title=f"unused {kind}")
75
74
  if targets:
76
75
  # `unused` only sees device-group objects + policy rulebases. Objects
77
76
  # referenced from templates/network config, NAT-rule tags, or matched
@@ -4,9 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import typer
6
6
 
7
+ from psc.cli._options import OUT_OPTION, location_from_name
7
8
  from psc.cli._plan import OUT_FORMAT_OPTION, complete
8
9
  from psc.cli.runtime import Runtime
9
- from psc.core.models import Location, Rulebase
10
+ from psc.core.models import Rulebase
10
11
  from psc.core.rule_edit import plan_rule_member_edit
11
12
  from psc.core.source import ConfigFormat
12
13
  from psc.output.errors import ErrorType, PscError
@@ -32,12 +33,7 @@ def edit_member(
32
33
  add: str | None = typer.Option(None, "--add", help="Member to add (idempotent)."),
33
34
  remove: str | None = typer.Option(None, "--remove", help="Member to remove (idempotent)."),
34
35
  apply: bool = typer.Option(False, "--apply", help="Execute the edit (default: dry-run)."),
35
- out: str | None = typer.Option(
36
- None,
37
- "--out",
38
- help="Write the plan artifact (set script or rewritten config) to this "
39
- "file; honoured even in a dry-run (see --output-format).",
40
- ),
36
+ out: str | None = OUT_OPTION,
41
37
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
42
38
  ) -> None:
43
39
  """Add or remove one member of a rule field, idempotently.
@@ -58,8 +54,7 @@ def edit_member(
58
54
  f"invalid --rulebase '{rulebase}' (choose pre or post)", ErrorType.VALIDATION
59
55
  ) from exc
60
56
 
61
- loc_name = location or rt.device_group or "shared"
62
- loc = Location.shared() if loc_name == "shared" else Location.dg(loc_name)
57
+ loc = location_from_name(location or rt.device_group or "shared")
63
58
 
64
59
  cs = plan_rule_member_edit(rt.snapshot(), rule, loc, rb, field, add=add, remove=remove)
65
60
  complete(rt, cs, apply=apply, out_path=out, out_format=output_format)
@@ -16,6 +16,12 @@ from __future__ import annotations
16
16
 
17
17
  import typer
18
18
 
19
+ from psc.cli._options import (
20
+ APPLY_OPTION,
21
+ LOCATION_OPTION,
22
+ OUT_OPTION,
23
+ location_from_name,
24
+ )
19
25
  from psc.cli._plan import OUT_FORMAT_OPTION, complete
20
26
  from psc.cli.runtime import Runtime
21
27
  from psc.core import crud
@@ -24,21 +30,9 @@ from psc.core.source import ConfigFormat
24
30
 
25
31
  app = typer.Typer(no_args_is_help=True)
26
32
 
27
- _LOCATION_OPTION = typer.Option(
28
- None, "--location", help="Target location (default: global --device-group, else shared)."
29
- )
30
- _APPLY_OPTION = typer.Option(False, "--apply", help="Execute the change (default: dry-run).")
31
- _OUT_OPTION = typer.Option(
32
- None,
33
- "--out",
34
- help="Write the plan artifact (set script or rewritten config) to this file; "
35
- "honoured even in a dry-run (see --output-format).",
36
- )
37
-
38
33
 
39
34
  def _resolve_location(rt: Runtime, location: str | None) -> Location:
40
- name = location or rt.device_group or "shared"
41
- return Location.shared() if name == "shared" else Location.dg(name)
35
+ return location_from_name(location or rt.device_group or "shared")
42
36
 
43
37
 
44
38
  @app.command("address")
@@ -51,9 +45,9 @@ def address(
51
45
  value: str = typer.Option(..., "--value", help="The address value (stored verbatim)."),
52
46
  description: str | None = typer.Option(None, "--description"),
53
47
  tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
54
- location: str | None = _LOCATION_OPTION,
55
- apply: bool = _APPLY_OPTION,
56
- out: str | None = _OUT_OPTION,
48
+ location: str | None = LOCATION_OPTION,
49
+ apply: bool = APPLY_OPTION,
50
+ out: str | None = OUT_OPTION,
57
51
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
58
52
  ) -> None:
59
53
  """Create or update an address object.
@@ -94,9 +88,9 @@ def address_group(
94
88
  ),
95
89
  description: str | None = typer.Option(None, "--description"),
96
90
  tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
97
- location: str | None = _LOCATION_OPTION,
98
- apply: bool = _APPLY_OPTION,
99
- out: str | None = _OUT_OPTION,
91
+ location: str | None = LOCATION_OPTION,
92
+ apply: bool = APPLY_OPTION,
93
+ out: str | None = OUT_OPTION,
100
94
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
101
95
  ) -> None:
102
96
  """Create or update an address-group (exactly one of --member.../--filter).
@@ -130,9 +124,9 @@ def service(
130
124
  ),
131
125
  description: str | None = typer.Option(None, "--description"),
132
126
  tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
133
- location: str | None = _LOCATION_OPTION,
134
- apply: bool = _APPLY_OPTION,
135
- out: str | None = _OUT_OPTION,
127
+ location: str | None = LOCATION_OPTION,
128
+ apply: bool = APPLY_OPTION,
129
+ out: str | None = OUT_OPTION,
136
130
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
137
131
  ) -> None:
138
132
  """Create or update a service object (--dest-port required; --source-port optional).
@@ -160,9 +154,9 @@ def service_group(
160
154
  name: str = typer.Option(..., "--name", help="Group name."),
161
155
  member: list[str] = typer.Option(..., "--member", help="Member name (repeatable, >=1)."),
162
156
  tag: list[str] = typer.Option([], "--tag", help="Tag name (repeatable)."),
163
- location: str | None = _LOCATION_OPTION,
164
- apply: bool = _APPLY_OPTION,
165
- out: str | None = _OUT_OPTION,
157
+ location: str | None = LOCATION_OPTION,
158
+ apply: bool = APPLY_OPTION,
159
+ out: str | None = OUT_OPTION,
166
160
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
167
161
  ) -> None:
168
162
  """Create or update a service-group.
@@ -187,9 +181,9 @@ def tag(
187
181
  name: str = typer.Option(..., "--name", help="Tag name (<=127 chars)."),
188
182
  color: str | None = typer.Option(None, "--color", help="color1..color42."),
189
183
  comments: str | None = typer.Option(None, "--comments"),
190
- location: str | None = _LOCATION_OPTION,
191
- apply: bool = _APPLY_OPTION,
192
- out: str | None = _OUT_OPTION,
184
+ location: str | None = LOCATION_OPTION,
185
+ apply: bool = APPLY_OPTION,
186
+ out: str | None = OUT_OPTION,
193
187
  output_format: ConfigFormat = OUT_FORMAT_OPTION,
194
188
  ) -> None:
195
189
  """Create or update a tag.