keywharf 1.0.1__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 (93) hide show
  1. keywharf-1.0.1/LICENSE +21 -0
  2. keywharf-1.0.1/PKG-INFO +155 -0
  3. keywharf-1.0.1/README.md +127 -0
  4. keywharf-1.0.1/pyproject.toml +57 -0
  5. keywharf-1.0.1/setup.cfg +4 -0
  6. keywharf-1.0.1/src/keywharf/__init__.py +3 -0
  7. keywharf-1.0.1/src/keywharf/__main__.py +11 -0
  8. keywharf-1.0.1/src/keywharf/cli.py +75 -0
  9. keywharf-1.0.1/src/keywharf/commands/__init__.py +15 -0
  10. keywharf-1.0.1/src/keywharf/commands/_invocation.py +154 -0
  11. keywharf-1.0.1/src/keywharf/commands/_privilege.py +96 -0
  12. keywharf-1.0.1/src/keywharf/commands/apply.py +81 -0
  13. keywharf-1.0.1/src/keywharf/commands/context.py +87 -0
  14. keywharf-1.0.1/src/keywharf/commands/deselect.py +47 -0
  15. keywharf-1.0.1/src/keywharf/commands/init.py +65 -0
  16. keywharf-1.0.1/src/keywharf/commands/install_include.py +61 -0
  17. keywharf-1.0.1/src/keywharf/commands/local.py +121 -0
  18. keywharf-1.0.1/src/keywharf/commands/output.py +125 -0
  19. keywharf-1.0.1/src/keywharf/commands/pull.py +45 -0
  20. keywharf-1.0.1/src/keywharf/commands/remote/__init__.py +3 -0
  21. keywharf-1.0.1/src/keywharf/commands/remote/group.py +22 -0
  22. keywharf-1.0.1/src/keywharf/commands/remote/helpers.py +43 -0
  23. keywharf-1.0.1/src/keywharf/commands/remote/host.py +195 -0
  24. keywharf-1.0.1/src/keywharf/commands/remote/output.py +71 -0
  25. keywharf-1.0.1/src/keywharf/commands/render.py +21 -0
  26. keywharf-1.0.1/src/keywharf/commands/select.py +63 -0
  27. keywharf-1.0.1/src/keywharf/commands/validate.py +21 -0
  28. keywharf-1.0.1/src/keywharf/config/__init__.py +3 -0
  29. keywharf-1.0.1/src/keywharf/config/loader.py +91 -0
  30. keywharf-1.0.1/src/keywharf/config/merge.py +26 -0
  31. keywharf-1.0.1/src/keywharf/config/models.py +65 -0
  32. keywharf-1.0.1/src/keywharf/config/resolver.py +107 -0
  33. keywharf-1.0.1/src/keywharf/config/resources.py +60 -0
  34. keywharf-1.0.1/src/keywharf/config_defaults/manager.json +8 -0
  35. keywharf-1.0.1/src/keywharf/domain/__init__.py +3 -0
  36. keywharf-1.0.1/src/keywharf/domain/errors.py +15 -0
  37. keywharf-1.0.1/src/keywharf/domain/models.py +354 -0
  38. keywharf-1.0.1/src/keywharf/domain/results.py +187 -0
  39. keywharf-1.0.1/src/keywharf/runtime/__init__.py +3 -0
  40. keywharf-1.0.1/src/keywharf/runtime/paths.py +130 -0
  41. keywharf-1.0.1/src/keywharf/services/__init__.py +3 -0
  42. keywharf-1.0.1/src/keywharf/services/apply.py +124 -0
  43. keywharf-1.0.1/src/keywharf/services/init.py +212 -0
  44. keywharf-1.0.1/src/keywharf/services/install_include.py +38 -0
  45. keywharf-1.0.1/src/keywharf/services/local_view.py +94 -0
  46. keywharf-1.0.1/src/keywharf/services/managed_config_applier.py +47 -0
  47. keywharf-1.0.1/src/keywharf/services/managed_config_renderer.py +10 -0
  48. keywharf-1.0.1/src/keywharf/services/managed_hosts.py +31 -0
  49. keywharf-1.0.1/src/keywharf/services/privilege.py +56 -0
  50. keywharf-1.0.1/src/keywharf/services/pull.py +28 -0
  51. keywharf-1.0.1/src/keywharf/services/remote_host_editor.py +339 -0
  52. keywharf-1.0.1/src/keywharf/services/remote_hosts.py +293 -0
  53. keywharf-1.0.1/src/keywharf/services/render.py +97 -0
  54. keywharf-1.0.1/src/keywharf/services/selections.py +88 -0
  55. keywharf-1.0.1/src/keywharf/services/validate.py +79 -0
  56. keywharf-1.0.1/src/keywharf/ssh_config/__init__.py +3 -0
  57. keywharf-1.0.1/src/keywharf/ssh_config/builder.py +96 -0
  58. keywharf-1.0.1/src/keywharf/ssh_config/parser.py +47 -0
  59. keywharf-1.0.1/src/keywharf/ssh_config/render.py +52 -0
  60. keywharf-1.0.1/src/keywharf/storage/__init__.py +3 -0
  61. keywharf-1.0.1/src/keywharf/storage/git_repo.py +81 -0
  62. keywharf-1.0.1/src/keywharf/storage/json_store.py +47 -0
  63. keywharf-1.0.1/src/keywharf/storage/managed_files.py +184 -0
  64. keywharf-1.0.1/src/keywharf/storage/remote_repo.py +27 -0
  65. keywharf-1.0.1/src/keywharf/storage/ssh_files.py +90 -0
  66. keywharf-1.0.1/src/keywharf/storage/state_store.py +76 -0
  67. keywharf-1.0.1/src/keywharf/templates/include_block.j2 +2 -0
  68. keywharf-1.0.1/src/keywharf/templates/init_state.json +4 -0
  69. keywharf-1.0.1/src/keywharf/templates/workspace_README.md.j2 +21 -0
  70. keywharf-1.0.1/src/keywharf/templates/workspace_gitignore.j2 +5 -0
  71. keywharf-1.0.1/src/keywharf/version.py +5 -0
  72. keywharf-1.0.1/src/keywharf.egg-info/PKG-INFO +155 -0
  73. keywharf-1.0.1/src/keywharf.egg-info/SOURCES.txt +91 -0
  74. keywharf-1.0.1/src/keywharf.egg-info/dependency_links.txt +1 -0
  75. keywharf-1.0.1/src/keywharf.egg-info/entry_points.txt +2 -0
  76. keywharf-1.0.1/src/keywharf.egg-info/requires.txt +9 -0
  77. keywharf-1.0.1/src/keywharf.egg-info/top_level.txt +1 -0
  78. keywharf-1.0.1/tests/test_apply_service.py +121 -0
  79. keywharf-1.0.1/tests/test_cli.py +57 -0
  80. keywharf-1.0.1/tests/test_config.py +52 -0
  81. keywharf-1.0.1/tests/test_import_surfaces.py +9 -0
  82. keywharf-1.0.1/tests/test_init.py +73 -0
  83. keywharf-1.0.1/tests/test_install_include.py +55 -0
  84. keywharf-1.0.1/tests/test_local_commands.py +101 -0
  85. keywharf-1.0.1/tests/test_package_resources.py +47 -0
  86. keywharf-1.0.1/tests/test_privilege.py +89 -0
  87. keywharf-1.0.1/tests/test_remote_host_commands.py +220 -0
  88. keywharf-1.0.1/tests/test_render_service.py +81 -0
  89. keywharf-1.0.1/tests/test_runtime_paths.py +128 -0
  90. keywharf-1.0.1/tests/test_select_commands.py +115 -0
  91. keywharf-1.0.1/tests/test_state_store.py +39 -0
  92. keywharf-1.0.1/tests/test_validate_service.py +156 -0
  93. keywharf-1.0.1/tests/test_version.py +17 -0
keywharf-1.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keywharf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: keywharf
3
+ Version: 1.0.1
4
+ Summary: Manage local SSH config alongside a remote key repository.
5
+ Author: keywharf
6
+ License: MIT
7
+ Keywords: ssh,automation,cli,configuration
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: System Administrators
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: jinja2<4,>=3.1
20
+ Requires-Dist: pydantic<3,>=2.7
21
+ Requires-Dist: rich>=13
22
+ Requires-Dist: typer>=0.12
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == "dev"
25
+ Requires-Dist: ruff>=0.5; extra == "dev"
26
+ Requires-Dist: mypy>=1.8; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # keywharf
30
+
31
+ `keywharf` is a Python 3.11+ CLI for selecting remote SSH host definitions into a local desired state, then materializing only manager-owned SSH artifacts.
32
+
33
+ It manages only:
34
+
35
+ - one explicit local state file
36
+ - one managed SSH config fragment
37
+ - one managed key directory
38
+
39
+ It does not take over the user's whole `~/.ssh/config`. Only `install-include` may minimally append one `Include` block to the main SSH config.
40
+
41
+ ## Recommended Workflow
42
+
43
+ ```bash
44
+ keywharf --data-root ~/keywharf init
45
+ keywharf --data-root ~/keywharf pull
46
+ keywharf --data-root ~/keywharf remote host list
47
+ keywharf --data-root ~/keywharf remote host show demo
48
+ keywharf --data-root ~/keywharf remote host add demo --hostname demo.example.com --user fox --identity-file keys/id_demo
49
+ keywharf --data-root ~/keywharf select demo --endpoint public --auth home
50
+ keywharf --data-root ~/keywharf validate
51
+ keywharf --data-root ~/keywharf render
52
+ keywharf --data-root ~/keywharf apply
53
+ keywharf --data-root ~/keywharf install-include
54
+ ```
55
+
56
+ If the manager config lives outside the default workspace root, use `--config <path>` instead of `--data-root`.
57
+
58
+ ## Ownership Boundary
59
+
60
+ `keywharf` manages:
61
+
62
+ - `state_path`
63
+ - `managed_config_path`
64
+ - `managed_keys_dir`
65
+
66
+ `keywharf` does not manage:
67
+
68
+ - unrelated `Host` entries in the main SSH config
69
+ - `Match` blocks
70
+ - other `Include` lines
71
+ - user comments and ordering in the main SSH config
72
+
73
+ ## Workspace Discovery
74
+
75
+ Workspace resolution is explicit and predictable:
76
+
77
+ 1. `--data-root`
78
+ 2. `KEYWHARF_DATA_ROOT`
79
+ 3. current directory, if it already contains both the `KEYWHARF_DATA_ROOT` marker and `config.json`
80
+ 4. nearest ancestor workspace marker
81
+ 5. `~/keywharf`
82
+ 6. fail with the checked candidate paths listed
83
+
84
+ `keywharf init` creates the marker, `config.json`, `state/state.json`, directory skeleton, and small workspace text files from package resources.
85
+
86
+ ## Formal Config And Templates
87
+
88
+ Manager config is a formal runtime config:
89
+
90
+ - defaults come from `pkg://keywharf/config_defaults/manager.json`
91
+ - file or mapping input is override only
92
+ - defaults and overrides are deep-merged before Pydantic v2 validation
93
+ - runtime path resolution is separate from raw config loading
94
+
95
+ Resource roles are intentionally split:
96
+
97
+ - `config_defaults/*.json`: formal defaults for manager config
98
+ - `templates/*.json`: structured starter data such as the empty state file
99
+ - `templates/*.j2`: human-facing text templates such as workspace `README.md`, workspace `.gitignore`, and the include block text
100
+
101
+ ## Remote Host CRUD
102
+
103
+ `remote host` edits only the local checkout copy of the remote repository config:
104
+
105
+ - `remote host list`
106
+ - `remote host show`
107
+ - `remote host add`
108
+ - `remote host update`
109
+ - `remote host remove`
110
+
111
+ These commands do not commit, push, or mutate git metadata. They perform structured JSON reads/writes, preserve array order, and revalidate the resulting host set before writing.
112
+
113
+ This round only adds Host-level CRUD. `ExtraConfig` is preserved and rendered, but it is not exposed as a CLI editor yet.
114
+
115
+ ## `--sudo`
116
+
117
+ Mutating commands support `--sudo`:
118
+
119
+ - `init`
120
+ - `pull`
121
+ - `select`
122
+ - `deselect`
123
+ - `apply`
124
+ - `install-include`
125
+ - `remote host add`
126
+ - `remote host update`
127
+ - `remote host remove`
128
+
129
+ Privilege handling is centralized:
130
+
131
+ - normal writable paths run without sudo
132
+ - unwritable paths fail fast with concrete path-based reasons
133
+ - `--sudo` re-execs the full command through `sudo`
134
+
135
+ ## Installation
136
+
137
+ ```bash
138
+ python3.11 -m venv .venv
139
+ . .venv/bin/activate
140
+ python -m pip install -e '.[dev]'
141
+ pytest
142
+ ```
143
+
144
+ Runtime requirements:
145
+
146
+ - Python 3.11+
147
+ - system `git`
148
+
149
+ ## Documentation
150
+
151
+ - [`docs/architecture.md`](docs/architecture.md)
152
+ - [`docs/configuration.md`](docs/configuration.md)
153
+ - [`docs/cli.md`](docs/cli.md)
154
+ - [`docs/development.md`](docs/development.md)
155
+ - [`CHANGELOG.md`](CHANGELOG.md)
@@ -0,0 +1,127 @@
1
+ # keywharf
2
+
3
+ `keywharf` is a Python 3.11+ CLI for selecting remote SSH host definitions into a local desired state, then materializing only manager-owned SSH artifacts.
4
+
5
+ It manages only:
6
+
7
+ - one explicit local state file
8
+ - one managed SSH config fragment
9
+ - one managed key directory
10
+
11
+ It does not take over the user's whole `~/.ssh/config`. Only `install-include` may minimally append one `Include` block to the main SSH config.
12
+
13
+ ## Recommended Workflow
14
+
15
+ ```bash
16
+ keywharf --data-root ~/keywharf init
17
+ keywharf --data-root ~/keywharf pull
18
+ keywharf --data-root ~/keywharf remote host list
19
+ keywharf --data-root ~/keywharf remote host show demo
20
+ keywharf --data-root ~/keywharf remote host add demo --hostname demo.example.com --user fox --identity-file keys/id_demo
21
+ keywharf --data-root ~/keywharf select demo --endpoint public --auth home
22
+ keywharf --data-root ~/keywharf validate
23
+ keywharf --data-root ~/keywharf render
24
+ keywharf --data-root ~/keywharf apply
25
+ keywharf --data-root ~/keywharf install-include
26
+ ```
27
+
28
+ If the manager config lives outside the default workspace root, use `--config <path>` instead of `--data-root`.
29
+
30
+ ## Ownership Boundary
31
+
32
+ `keywharf` manages:
33
+
34
+ - `state_path`
35
+ - `managed_config_path`
36
+ - `managed_keys_dir`
37
+
38
+ `keywharf` does not manage:
39
+
40
+ - unrelated `Host` entries in the main SSH config
41
+ - `Match` blocks
42
+ - other `Include` lines
43
+ - user comments and ordering in the main SSH config
44
+
45
+ ## Workspace Discovery
46
+
47
+ Workspace resolution is explicit and predictable:
48
+
49
+ 1. `--data-root`
50
+ 2. `KEYWHARF_DATA_ROOT`
51
+ 3. current directory, if it already contains both the `KEYWHARF_DATA_ROOT` marker and `config.json`
52
+ 4. nearest ancestor workspace marker
53
+ 5. `~/keywharf`
54
+ 6. fail with the checked candidate paths listed
55
+
56
+ `keywharf init` creates the marker, `config.json`, `state/state.json`, directory skeleton, and small workspace text files from package resources.
57
+
58
+ ## Formal Config And Templates
59
+
60
+ Manager config is a formal runtime config:
61
+
62
+ - defaults come from `pkg://keywharf/config_defaults/manager.json`
63
+ - file or mapping input is override only
64
+ - defaults and overrides are deep-merged before Pydantic v2 validation
65
+ - runtime path resolution is separate from raw config loading
66
+
67
+ Resource roles are intentionally split:
68
+
69
+ - `config_defaults/*.json`: formal defaults for manager config
70
+ - `templates/*.json`: structured starter data such as the empty state file
71
+ - `templates/*.j2`: human-facing text templates such as workspace `README.md`, workspace `.gitignore`, and the include block text
72
+
73
+ ## Remote Host CRUD
74
+
75
+ `remote host` edits only the local checkout copy of the remote repository config:
76
+
77
+ - `remote host list`
78
+ - `remote host show`
79
+ - `remote host add`
80
+ - `remote host update`
81
+ - `remote host remove`
82
+
83
+ These commands do not commit, push, or mutate git metadata. They perform structured JSON reads/writes, preserve array order, and revalidate the resulting host set before writing.
84
+
85
+ This round only adds Host-level CRUD. `ExtraConfig` is preserved and rendered, but it is not exposed as a CLI editor yet.
86
+
87
+ ## `--sudo`
88
+
89
+ Mutating commands support `--sudo`:
90
+
91
+ - `init`
92
+ - `pull`
93
+ - `select`
94
+ - `deselect`
95
+ - `apply`
96
+ - `install-include`
97
+ - `remote host add`
98
+ - `remote host update`
99
+ - `remote host remove`
100
+
101
+ Privilege handling is centralized:
102
+
103
+ - normal writable paths run without sudo
104
+ - unwritable paths fail fast with concrete path-based reasons
105
+ - `--sudo` re-execs the full command through `sudo`
106
+
107
+ ## Installation
108
+
109
+ ```bash
110
+ python3.11 -m venv .venv
111
+ . .venv/bin/activate
112
+ python -m pip install -e '.[dev]'
113
+ pytest
114
+ ```
115
+
116
+ Runtime requirements:
117
+
118
+ - Python 3.11+
119
+ - system `git`
120
+
121
+ ## Documentation
122
+
123
+ - [`docs/architecture.md`](docs/architecture.md)
124
+ - [`docs/configuration.md`](docs/configuration.md)
125
+ - [`docs/cli.md`](docs/cli.md)
126
+ - [`docs/development.md`](docs/development.md)
127
+ - [`CHANGELOG.md`](CHANGELOG.md)
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keywharf"
7
+ dynamic = ["version"]
8
+ description = "Manage local SSH config alongside a remote key repository."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "keywharf" }]
12
+ license = { text = "MIT" }
13
+ keywords = ["ssh", "automation", "cli", "configuration"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: System :: Systems Administration",
23
+ ]
24
+ dependencies = [
25
+ "jinja2>=3.1,<4",
26
+ "pydantic>=2.7,<3",
27
+ "rich>=13",
28
+ "typer>=0.12",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=7",
34
+ "ruff>=0.5",
35
+ "mypy>=1.8",
36
+ ]
37
+
38
+ [project.scripts]
39
+ keywharf = "keywharf.cli:main"
40
+
41
+ [tool.setuptools]
42
+ package-dir = {"" = "src"}
43
+ license-files = ["LICENSE"]
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+ include = ["keywharf*"]
48
+
49
+ [tool.setuptools.package-data]
50
+ keywharf = ["config_defaults/**/*.json", "templates/**/*.json", "templates/**/*.j2"]
51
+
52
+ [tool.setuptools.dynamic]
53
+ version = {attr = "keywharf.version.__version__"}
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """keywharf package."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,11 @@
1
+ """Run the keywharf CLI."""
2
+
3
+ from keywharf.cli import main as cli_main
4
+
5
+
6
+ def main() -> None:
7
+ cli_main()
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,75 @@
1
+ """CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from keywharf.commands import register
10
+ from keywharf.commands.context import build_cli_state
11
+ from keywharf.domain.errors import KeywharfError
12
+ from keywharf.version import __version__
13
+
14
+
15
+ app = typer.Typer(
16
+ name="keywharf",
17
+ help="Select remote SSH hosts into local desired state, then render/apply keywharf owned SSH config fragments.",
18
+ invoke_without_command=True,
19
+ )
20
+
21
+
22
+ @app.callback()
23
+ def callback(
24
+ ctx: typer.Context,
25
+ config: Path | None = typer.Option(
26
+ None,
27
+ "--config",
28
+ "-c",
29
+ help="Path to the keywharf config.json file (defaults to resolved data root/config.json).",
30
+ dir_okay=False,
31
+ exists=False,
32
+ readable=True,
33
+ ),
34
+ data_root: Path | None = typer.Option(
35
+ None,
36
+ "--data-root",
37
+ help="Explicit keywharf workspace root.",
38
+ file_okay=False,
39
+ dir_okay=True,
40
+ exists=False,
41
+ readable=True,
42
+ ),
43
+ version: bool = typer.Option(
44
+ False,
45
+ "--version",
46
+ help="Show version and exit.",
47
+ is_eager=True,
48
+ ),
49
+ ) -> None:
50
+ if version:
51
+ typer.echo(__version__)
52
+ raise typer.Exit()
53
+
54
+ ctx.obj = build_cli_state(
55
+ config,
56
+ data_root.expanduser().resolve() if data_root is not None else None,
57
+ )
58
+ if ctx.invoked_subcommand is None:
59
+ typer.echo(ctx.get_help())
60
+ raise typer.Exit()
61
+
62
+
63
+ register(app)
64
+
65
+
66
+ def main() -> None:
67
+ try:
68
+ app()
69
+ except KeywharfError as exc:
70
+ typer.echo(str(exc), err=True)
71
+ raise typer.Exit(code=exc.exit_code) from exc
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,15 @@
1
+ """Typer command registration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from keywharf.commands import apply, deselect, init, install_include, local, pull, render, select, validate
8
+ from keywharf.commands.remote.group import app as remote_app
9
+
10
+
11
+ def register(app: typer.Typer) -> None:
12
+ for module in [init, pull, validate, render, apply, install_include, select, deselect]:
13
+ module.register(app)
14
+ app.add_typer(local.app, name="local")
15
+ app.add_typer(remote_app, name="remote")
@@ -0,0 +1,154 @@
1
+ """Canonical CLI invocation helpers for retry hints and sudo re-exec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ import shlex
10
+ import sys
11
+ from typing import Any, Mapping
12
+
13
+ import click
14
+ from click.core import ParameterSource
15
+
16
+
17
+ _AUTO_EXCLUDED_PARAMETER_NAMES = {"install_completion", "show_completion"}
18
+ _DEFAULT_PARAMETER_SOURCES = {None, ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP}
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class CommandInvocation:
23
+ """Stable, canonical argv for the current command."""
24
+
25
+ argv: list[str]
26
+
27
+ def display(self) -> str:
28
+ return shlex.join(["keywharf", *self.argv])
29
+
30
+ def with_sudo_flag(self) -> "CommandInvocation":
31
+ if "--sudo" in self.argv:
32
+ return CommandInvocation([*self.argv])
33
+ return CommandInvocation([*self.argv, "--sudo"])
34
+
35
+ def sudo_exec_args(self) -> list[str]:
36
+ cli_path = Path(sys.executable).resolve().with_name("keywharf")
37
+ sudo_argv = self.with_sudo_flag().argv
38
+ if cli_path.exists() and os.access(cli_path, os.X_OK):
39
+ return ["sudo", str(cli_path), *sudo_argv]
40
+ return ["sudo", sys.executable, "-m", "keywharf", *sudo_argv]
41
+
42
+
43
+ def build_command_invocation(
44
+ ctx: click.Context,
45
+ *,
46
+ overrides: Mapping[str, Any] | None = None,
47
+ exclude: tuple[str, ...] = ("sudo",),
48
+ ) -> CommandInvocation:
49
+ """Build one canonical argv for the current Click/Typer command context."""
50
+
51
+ override_map = dict(overrides or {})
52
+ excluded_names = set(exclude) | _AUTO_EXCLUDED_PARAMETER_NAMES
53
+ argv: list[str] = []
54
+ context_chain = _context_chain(ctx)
55
+
56
+ root_ctx = context_chain[0]
57
+ argv.extend(
58
+ _serialize_context_params(root_ctx, excluded_names=excluded_names, overrides=override_map)
59
+ )
60
+
61
+ for command_ctx in context_chain[1:]:
62
+ command_name = command_ctx.info_name or command_ctx.command.name
63
+ if command_name:
64
+ argv.append(command_name)
65
+ argv.extend(
66
+ _serialize_context_params(
67
+ command_ctx,
68
+ excluded_names=excluded_names,
69
+ overrides=override_map,
70
+ )
71
+ )
72
+
73
+ return CommandInvocation(argv)
74
+
75
+
76
+ def _context_chain(ctx: click.Context) -> list[click.Context]:
77
+ chain: list[click.Context] = []
78
+ current: click.Context | None = ctx
79
+ while current is not None:
80
+ chain.append(current)
81
+ current = current.parent
82
+ chain.reverse()
83
+ return chain
84
+
85
+
86
+ def _serialize_context_params(
87
+ ctx: click.Context,
88
+ *,
89
+ excluded_names: set[str],
90
+ overrides: Mapping[str, Any],
91
+ ) -> list[str]:
92
+ argv: list[str] = []
93
+ for parameter in ctx.command.params:
94
+ if not parameter.expose_value:
95
+ continue
96
+ name = parameter.name
97
+ if name is None or name in excluded_names:
98
+ continue
99
+ value = overrides[name] if name in overrides else ctx.params.get(name)
100
+ if isinstance(parameter, click.Argument):
101
+ argv.extend(_serialize_argument(value))
102
+ continue
103
+ if name not in overrides and ctx.get_parameter_source(name) in _DEFAULT_PARAMETER_SOURCES:
104
+ continue
105
+ argv.extend(_serialize_option(parameter, value))
106
+ return argv
107
+
108
+
109
+ def _serialize_argument(value: Any) -> list[str]:
110
+ return [_serialize_scalar(item) for item in _iter_values(value)]
111
+
112
+
113
+ def _serialize_option(parameter: click.Option, value: Any) -> list[str]:
114
+ if value is None:
115
+ return []
116
+
117
+ if parameter.is_bool_flag:
118
+ if value:
119
+ return [parameter.opts[0]]
120
+ if parameter.secondary_opts:
121
+ return [parameter.secondary_opts[0]]
122
+ return []
123
+
124
+ if parameter.multiple:
125
+ argv: list[str] = []
126
+ for item in _iter_values(value):
127
+ argv.append(parameter.opts[0])
128
+ argv.extend(_serialize_composite_value(item))
129
+ return argv
130
+
131
+ return [parameter.opts[0], *_serialize_composite_value(value)]
132
+
133
+
134
+ def _serialize_composite_value(value: Any) -> list[str]:
135
+ return [_serialize_scalar(item) for item in _iter_values(value)]
136
+
137
+
138
+ def _iter_values(value: Any) -> list[Any]:
139
+ if value is None:
140
+ return []
141
+ if isinstance(value, tuple):
142
+ return list(value)
143
+ if isinstance(value, list):
144
+ return value
145
+ return [value]
146
+
147
+
148
+ def _serialize_scalar(value: Any) -> str:
149
+ if isinstance(value, Path):
150
+ return str(value)
151
+ if isinstance(value, Enum):
152
+ return str(value.value)
153
+ return str(value)
154
+