fogies 0.0.0.dev11__tar.gz → 0.0.0.dev12__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 (45) hide show
  1. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/PKG-INFO +2 -1
  2. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/pyproject.toml +3 -1
  3. fogies-0.0.0.dev12/src/fogies/age_key.py +48 -0
  4. fogies-0.0.0.dev12/src/fogies/includes/templates/claude_sync.toml.template +16 -0
  5. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/access_key.py +5 -0
  6. fogies-0.0.0.dev12/src/fogies/tasks/claude_sync.py +145 -0
  7. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/format.py +3 -1
  8. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/lint.py +3 -1
  9. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/poetry.py +6 -2
  10. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/terraform.py +52 -28
  11. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/terraform_backend.py +13 -24
  12. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/test.py +3 -1
  13. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/templates.py +4 -0
  14. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/backend.py +9 -7
  15. fogies-0.0.0.dev12/src/fogies/tools/claude_sync.py +302 -0
  16. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/terraform.py +4 -2
  17. fogies-0.0.0.dev11/src/fogies/tools/claude_sync.py +0 -130
  18. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/README.md +0 -0
  19. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/__init__.py +0 -0
  20. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/aws_access_key.py +0 -0
  21. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/boto_clients.py +0 -0
  22. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/download_verified.py +0 -0
  23. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/aws.toml.template +0 -0
  24. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/backend_status.toml.template +0 -0
  25. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/includes/templates/poetry.toml.template +0 -0
  26. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/py.typed +0 -0
  27. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/pyfogies.py +0 -0
  28. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/ready_poll.py +0 -0
  29. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/retry.py +0 -0
  30. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tasks/__init__.py +0 -0
  31. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/__init__.py +0 -0
  32. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/alb.py +0 -0
  33. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/cloudwatch.py +0 -0
  34. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/ecr.py +0 -0
  35. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/ecs.py +0 -0
  36. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/hosted_zone.py +0 -0
  37. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/terraform/network.py +0 -0
  38. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/__init__.py +0 -0
  39. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/aws_environ.py +0 -0
  40. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/boto.py +0 -0
  41. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/command.py +0 -0
  42. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/environ.py +0 -0
  43. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/go.py +0 -0
  44. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/ollama.py +0 -0
  45. {fogies-0.0.0.dev11 → fogies-0.0.0.dev12}/src/fogies/tools/terraform_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fogies
3
- Version: 0.0.0.dev11
3
+ Version: 0.0.0.dev12
4
4
  Summary:
5
5
  Author: James Fogarty
6
6
  Author-email: jayfo@users.noreply.github.com
@@ -12,6 +12,7 @@ Requires-Dist: filelock (>=3.25.2,<4.0.0)
12
12
  Requires-Dist: ollama (>=0.6.2,<0.7.0)
13
13
  Requires-Dist: psutil (>=7.2.2,<8.0.0)
14
14
  Requires-Dist: pydantic (>=2.12.5,<3.0.0)
15
+ Requires-Dist: pyrage (>=1.4.0,<2.0.0)
15
16
  Requires-Dist: requests (>=2.33.0,<2.34.0)
16
17
  Requires-Dist: tenacity (>=9.1.4,<10.0.0)
17
18
  Requires-Dist: tomlkit (>=0.15.1,<0.16.0)
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "fogies"
3
3
  # Version follows semantic versioning <https://semver.org/>.
4
- version = "0.0.0-dev.11"
4
+ version = "0.0.0-dev.12"
5
5
  description = ""
6
6
  authors = [
7
7
  {name = "James Fogarty",email = "jayfo@users.noreply.github.com"}
@@ -18,6 +18,7 @@ include = [
18
18
  {path = "src/fogies/includes/templates/aws.toml.template", format = ["sdist", "wheel"]},
19
19
  {path = "src/fogies/includes/templates/poetry.toml.template", format = ["sdist", "wheel"]},
20
20
  {path = "src/fogies/includes/templates/backend_status.toml.template", format = ["sdist", "wheel"]},
21
+ {path = "src/fogies/includes/templates/claude_sync.toml.template", format = ["sdist", "wheel"]},
21
22
  ]
22
23
 
23
24
  [tool.poetry.dependencies]
@@ -33,6 +34,7 @@ pydantic = "^2.12.5"
33
34
  requests = "~2.33.0"
34
35
  tenacity = "^9.1.4"
35
36
  tomlkit = "^0.15.1"
37
+ pyrage = "^1.4.0"
36
38
 
37
39
  [tool.poetry.group.dev.dependencies]
38
40
  # ^ allows all minor and patch releases.
@@ -0,0 +1,48 @@
1
+ """Age secret keys, which encrypt what claude-sync uploads.
2
+
3
+ An age secret key is a string like AGE-SECRET-KEY-1...: see
4
+ <https://github.com/FiloSottile/age>. pyrage, which binds to a Rust
5
+ implementation of age, creates and checks them. It ships no type stubs, so the
6
+ little of it that is used is given types here and nowhere else.
7
+ """
8
+
9
+ from typing import Protocol, cast
10
+
11
+ import pyrage # pyright: ignore[reportMissingTypeStubs]
12
+
13
+
14
+ class _IdentityFactory(Protocol):
15
+ """The parts of pyrage's x25519 Identity class that are used."""
16
+
17
+ def generate(self) -> object: ...
18
+
19
+ def from_str(self, value: str) -> object: ...
20
+
21
+
22
+ _IDENTITY = cast(
23
+ _IdentityFactory,
24
+ pyrage.x25519.Identity, # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue]
25
+ )
26
+
27
+ _IDENTITY_ERROR = cast(
28
+ type[Exception],
29
+ pyrage.IdentityError, # pyright: ignore[reportAttributeAccessIssue]
30
+ )
31
+
32
+
33
+ def generate_age_key() -> str:
34
+ """Return a new, random age secret key."""
35
+ return str(_IDENTITY.generate())
36
+
37
+
38
+ def validate_age_key(value: str) -> str:
39
+ """Return value, if it is an age secret key.
40
+
41
+ Raises ValueError if it is not, including a key with a mistyped character,
42
+ since the checksum is checked.
43
+ """
44
+ try:
45
+ _ = _IDENTITY.from_str(value)
46
+ except _IDENTITY_ERROR as exc:
47
+ raise ValueError("Not an age secret key") from exc
48
+ return value
@@ -0,0 +1,16 @@
1
+ # Config for claude-sync tasks.
2
+ # See https://github.com/tawanorg/claude-sync
3
+
4
+ # Profile in the AWS config, for an IAM user that can use the bucket.
5
+ aws_profile = "..."
6
+
7
+ # S3 bucket.
8
+ bucket = "..."
9
+ region = "..."
10
+
11
+ # What to sync: "sessions" or "full".
12
+ scope = "sessions"
13
+
14
+ # Encryption key.
15
+ # claude-sync.configure will create, so that all machines have the same key.
16
+ # age_key = "AGE-SECRET-KEY-1..."
@@ -98,6 +98,7 @@ def get_task_create(
98
98
  ) -> Task[Callable[..., None]]:
99
99
  @task(
100
100
  name="create",
101
+ auto_shortflags=False,
101
102
  positional=[],
102
103
  help={
103
104
  "prompt": "Prompt for admin credentials.",
@@ -125,6 +126,7 @@ def get_task_delete(
125
126
  ) -> Task[Callable[..., None]]:
126
127
  @task(
127
128
  name="delete",
129
+ auto_shortflags=False,
128
130
  positional=[],
129
131
  help={
130
132
  "prompt": "Prompt for admin credentials.",
@@ -159,6 +161,7 @@ def get_task_delete_key(
159
161
  ) -> Task[Callable[..., None]]:
160
162
  @task(
161
163
  name="delete-key",
164
+ auto_shortflags=False,
162
165
  positional=[],
163
166
  help={
164
167
  "prompt": "Prompt for admin credentials.",
@@ -206,6 +209,7 @@ def get_task_list(
206
209
  ) -> Task[Callable[..., None]]:
207
210
  @task(
208
211
  name="list",
212
+ auto_shortflags=False,
209
213
  help={
210
214
  "prompt": "Prompt for admin credentials.",
211
215
  },
@@ -235,6 +239,7 @@ def get_task_rotate_key(
235
239
  ) -> Task[Callable[..., None]]:
236
240
  @task(
237
241
  name="rotate-key",
242
+ auto_shortflags=False,
238
243
  positional=[],
239
244
  help={
240
245
  "prompt": "Prompt for admin credentials.",
@@ -0,0 +1,145 @@
1
+ """Tasks for setting up and running claude-sync."""
2
+
3
+ import pathlib
4
+ from collections.abc import Callable
5
+ from typing import cast
6
+
7
+ from invoke.collection import Collection
8
+ from invoke.context import Context
9
+ from invoke.tasks import Task, task
10
+
11
+ from fogies.tools.claude_sync import claude_sync, configure_claude_sync
12
+ from fogies.tools.command import CommandParams
13
+
14
+
15
+ def get_task_configure(
16
+ *,
17
+ binary_cache_path: pathlib.Path,
18
+ claude_sync_config_path: pathlib.Path,
19
+ aws_config_path: pathlib.Path,
20
+ ) -> Task[Callable[..., None]]:
21
+ @task(
22
+ name="configure",
23
+ auto_shortflags=False,
24
+ help={
25
+ "force": "Force overwrite based on current configuration.",
26
+ },
27
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
28
+ def task_configure(context: Context, force: bool = False) -> None:
29
+ """
30
+ Configure claude-sync on this machine.
31
+ """
32
+ setup = configure_claude_sync(
33
+ claude_sync_config_path=claude_sync_config_path,
34
+ aws_config_path=aws_config_path,
35
+ force=force,
36
+ )
37
+
38
+ if setup.generated_age_key:
39
+ print("Generated encryption key.")
40
+ if setup.applied_to_claude_sync_files:
41
+ print("Applied configuration to claude-sync files.")
42
+ else:
43
+ print("Already configured.")
44
+
45
+ # Reads from the bucket and changes nothing, so this fails now, and
46
+ # clearly, if the credentials, bucket or key are wrong.
47
+ print("Checking the connection:")
48
+ with claude_sync(
49
+ binary_cache_path=binary_cache_path, raise_if_env_exists=False
50
+ ) as cs:
51
+ _ = cs.pull(command_params=CommandParams(context=context), dry_run=True)
52
+
53
+ return cast(Task[Callable[..., None]], task_configure)
54
+
55
+
56
+ def get_task_pull(
57
+ *,
58
+ binary_cache_path: pathlib.Path,
59
+ default_dry_run: bool = False,
60
+ default_force: bool = False,
61
+ default_rebuild_history: bool = True,
62
+ ) -> Task[Callable[..., None]]:
63
+ @task(
64
+ name="pull",
65
+ auto_shortflags=False,
66
+ help={
67
+ "dry_run": "Show what would be downloaded.",
68
+ "force": "Force overwrite of local files.",
69
+ "rebuild_history": "Rebuild history from session files.",
70
+ },
71
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
72
+ def task_pull(
73
+ context: Context,
74
+ dry_run: bool = default_dry_run,
75
+ force: bool = default_force,
76
+ rebuild_history: bool = default_rebuild_history,
77
+ ) -> None:
78
+ """
79
+ Download remote changes.
80
+ """
81
+ with claude_sync(
82
+ binary_cache_path=binary_cache_path, raise_if_env_exists=False
83
+ ) as cs:
84
+ _ = cs.pull(
85
+ command_params=CommandParams(context=context),
86
+ dry_run=dry_run,
87
+ force=force,
88
+ rebuild_history=rebuild_history,
89
+ )
90
+
91
+ return cast(Task[Callable[..., None]], task_pull)
92
+
93
+
94
+ def get_task_push(*, binary_cache_path: pathlib.Path) -> Task[Callable[..., None]]:
95
+ @task(
96
+ name="push", auto_shortflags=False
97
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
98
+ def task_push(context: Context) -> None:
99
+ """
100
+ Upload local changes.
101
+ """
102
+ with claude_sync(
103
+ binary_cache_path=binary_cache_path, raise_if_env_exists=False
104
+ ) as cs:
105
+ _ = cs.push(command_params=CommandParams(context=context))
106
+
107
+ return cast(Task[Callable[..., None]], task_push)
108
+
109
+
110
+ def get_task_status(*, binary_cache_path: pathlib.Path) -> Task[Callable[..., None]]:
111
+ @task(
112
+ name="status", auto_shortflags=False
113
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
114
+ def task_status(context: Context) -> None:
115
+ """
116
+ Show local changes since prior synchronization.
117
+ """
118
+ with claude_sync(
119
+ binary_cache_path=binary_cache_path, raise_if_env_exists=False
120
+ ) as cs:
121
+ _ = cs.status(command_params=CommandParams(context=context))
122
+
123
+ return cast(Task[Callable[..., None]], task_status)
124
+
125
+
126
+ def get_collection(
127
+ *,
128
+ collection_name: str,
129
+ binary_cache_path: pathlib.Path,
130
+ claude_sync_config_path: pathlib.Path,
131
+ aws_config_path: pathlib.Path,
132
+ ) -> Collection:
133
+ """Get a collection of tasks for setting up and running claude-sync."""
134
+ collection = Collection(collection_name)
135
+ collection.add_task(
136
+ get_task_configure(
137
+ binary_cache_path=binary_cache_path,
138
+ claude_sync_config_path=claude_sync_config_path,
139
+ aws_config_path=aws_config_path,
140
+ )
141
+ )
142
+ collection.add_task(get_task_pull(binary_cache_path=binary_cache_path))
143
+ collection.add_task(get_task_push(binary_cache_path=binary_cache_path))
144
+ collection.add_task(get_task_status(binary_cache_path=binary_cache_path))
145
+ return collection
@@ -26,7 +26,9 @@ def get_task_format(
26
26
  if not (fmt_black or fmt_isort or fmt_terraform):
27
27
  raise ValueError("At least one fmt flag must be set.")
28
28
 
29
- @task(name="format") # pyright: ignore[reportUntypedFunctionDecorator]
29
+ @task(
30
+ name="format", auto_shortflags=False
31
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
30
32
  def task_format(context: Context) -> None:
31
33
  """
32
34
  Apply code formatting.
@@ -9,7 +9,9 @@ from invoke.tasks import Task, task
9
9
 
10
10
 
11
11
  def get_task_lint() -> Task[Callable[[Context], None]]:
12
- @task(name="lint") # pyright: ignore[reportUntypedFunctionDecorator]
12
+ @task(
13
+ name="lint", auto_shortflags=False
14
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
13
15
  def task_lint(context: Context) -> None:
14
16
  """
15
17
  Run linting.
@@ -14,7 +14,9 @@ from fogies.templates import ensure_from_template, poetry_toml_template_factory
14
14
 
15
15
 
16
16
  def get_task_build() -> Task[Callable[[Context], None]]:
17
- @task(name="build") # pyright: ignore[reportUntypedFunctionDecorator]
17
+ @task(
18
+ name="build", auto_shortflags=False
19
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
18
20
  def task_build(context: Context) -> None:
19
21
  """
20
22
  Build package artifacts.
@@ -33,7 +35,9 @@ def get_task_build() -> Task[Callable[[Context], None]]:
33
35
 
34
36
 
35
37
  def get_task_publish(*, secrets_poetry_path: Path) -> Task[Callable[[Context], None]]:
36
- @task(name="publish") # pyright: ignore[reportUntypedFunctionDecorator]
38
+ @task(
39
+ name="publish", auto_shortflags=False
40
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
37
41
  def task_publish(context: Context) -> None:
38
42
  """
39
43
  Publish package to PyPI.
@@ -21,6 +21,24 @@ from fogies.tools.terraform import (
21
21
  )
22
22
 
23
23
 
24
+ def validate_init_params(*, init: bool, init_params: InitParams) -> None:
25
+ """Raise ValueError for task flags that cannot be given together.
26
+
27
+ The init options only apply when init runs, and Terraform does not allow
28
+ migrating state while also reconfiguring the backend.
29
+ """
30
+ if init_params.migrate_state and not init:
31
+ raise ValueError("--init-migrate-state requires --init")
32
+ if init_params.reconfigure and not init:
33
+ raise ValueError("--init-reconfigure requires --init")
34
+ if init_params.upgrade and not init:
35
+ raise ValueError("--init-upgrade requires --init")
36
+ if init_params.migrate_state and init_params.reconfigure:
37
+ raise ValueError(
38
+ "--init-migrate-state and --init-reconfigure cannot be combined"
39
+ )
40
+
41
+
24
42
  def get_task_apply(
25
43
  *,
26
44
  binary_cache_path: pathlib.Path,
@@ -31,10 +49,11 @@ def get_task_apply(
31
49
  tfbackend_factory: TfbackendFactory | None = None,
32
50
  tfvars_factory: TfvarsFactory | None = None,
33
51
  default_init: bool = True,
34
- default_init_upgrade: bool = False,
52
+ default_init_migrate_state: bool = False,
35
53
  default_init_reconfigure: bool = False,
54
+ default_init_upgrade: bool = False,
36
55
  default_apply_auto_approve: bool = False,
37
- ) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
56
+ ) -> Task[Callable[[Context, bool, bool, bool, bool, bool], None]]:
38
57
  if backend_config is not None and tfbackend_factory is None:
39
58
  raise ValueError("tfbackend_factory is required when backend_config is set")
40
59
  if (backend_config is not None or tfbackend_factory is not None) and (
@@ -50,34 +69,34 @@ def get_task_apply(
50
69
 
51
70
  @task(
52
71
  name="apply",
72
+ auto_shortflags=False,
53
73
  help={
54
74
  "init": "Include terraform init. Skip with --no-init.",
55
- "init_upgrade": "During init, upgrade providers.",
75
+ "init_migrate_state": "During init, migrate state to a changed backend.",
56
76
  "init_reconfigure": "During init, reconfigure the backend.",
77
+ "init_upgrade": "During init, upgrade providers.",
57
78
  "apply_auto_approve": "Skip interactive confirmation.",
58
79
  },
59
80
  ) # pyright: ignore[reportUntypedFunctionDecorator]
60
81
  def task_apply(
61
82
  context: Context,
62
83
  init: bool = default_init,
63
- init_upgrade: bool = default_init_upgrade,
84
+ init_migrate_state: bool = default_init_migrate_state,
64
85
  init_reconfigure: bool = default_init_reconfigure,
86
+ init_upgrade: bool = default_init_upgrade,
65
87
  apply_auto_approve: bool = default_apply_auto_approve,
66
88
  ) -> None:
67
89
  """
68
90
  Apply a Terraform configuration.
69
91
  """
70
- if init_upgrade and not init:
71
- raise ValueError("--init-upgrade requires --init")
72
- if init_reconfigure and not init:
73
- raise ValueError("--init-reconfigure requires --init")
92
+ init_params = InitParams(
93
+ migrate_state=init_migrate_state,
94
+ reconfigure=init_reconfigure,
95
+ upgrade=init_upgrade,
96
+ )
97
+ validate_init_params(init=init, init_params=init_params)
74
98
 
75
99
  command_params = CommandParams(context=context)
76
- init_params = (
77
- InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
78
- if init
79
- else None
80
- )
81
100
 
82
101
  with ExitStack() as stack:
83
102
  aws_environ = (
@@ -113,7 +132,9 @@ def get_task_apply(
113
132
  )
114
133
  )
115
134
 
116
- return cast(Task[Callable[[Context, bool, bool, bool, bool], None]], task_apply)
135
+ return cast(
136
+ Task[Callable[[Context, bool, bool, bool, bool, bool], None]], task_apply
137
+ )
117
138
 
118
139
 
119
140
  def get_task_destroy(
@@ -126,10 +147,11 @@ def get_task_destroy(
126
147
  tfbackend_factory: TfbackendFactory | None = None,
127
148
  tfvars_factory: TfvarsFactory | None = None,
128
149
  default_init: bool = True,
129
- default_init_upgrade: bool = False,
150
+ default_init_migrate_state: bool = False,
130
151
  default_init_reconfigure: bool = False,
152
+ default_init_upgrade: bool = False,
131
153
  default_destroy_auto_approve: bool = False,
132
- ) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
154
+ ) -> Task[Callable[[Context, bool, bool, bool, bool, bool], None]]:
133
155
  if backend_config is not None and tfbackend_factory is None:
134
156
  raise ValueError("tfbackend_factory is required when backend_config is set")
135
157
  if (backend_config is not None or tfbackend_factory is not None) and (
@@ -145,34 +167,34 @@ def get_task_destroy(
145
167
 
146
168
  @task(
147
169
  name="destroy",
170
+ auto_shortflags=False,
148
171
  help={
149
172
  "init": "Include terraform init. Skip with --no-init.",
150
- "init_upgrade": "During init, upgrade providers.",
173
+ "init_migrate_state": "During init, migrate state to a changed backend.",
151
174
  "init_reconfigure": "During init, reconfigure the backend.",
175
+ "init_upgrade": "During init, upgrade providers.",
152
176
  "destroy_auto_approve": "Skip interactive confirmation.",
153
177
  },
154
178
  ) # pyright: ignore[reportUntypedFunctionDecorator]
155
179
  def task_destroy(
156
180
  context: Context,
157
181
  init: bool = default_init,
158
- init_upgrade: bool = default_init_upgrade,
182
+ init_migrate_state: bool = default_init_migrate_state,
159
183
  init_reconfigure: bool = default_init_reconfigure,
184
+ init_upgrade: bool = default_init_upgrade,
160
185
  destroy_auto_approve: bool = default_destroy_auto_approve,
161
186
  ) -> None:
162
187
  """
163
188
  Destroy a Terraform configuration.
164
189
  """
165
- if init_upgrade and not init:
166
- raise ValueError("--init-upgrade requires --init")
167
- if init_reconfigure and not init:
168
- raise ValueError("--init-reconfigure requires --init")
190
+ init_params = InitParams(
191
+ migrate_state=init_migrate_state,
192
+ reconfigure=init_reconfigure,
193
+ upgrade=init_upgrade,
194
+ )
195
+ validate_init_params(init=init, init_params=init_params)
169
196
 
170
197
  command_params = CommandParams(context=context)
171
- init_params = (
172
- InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
173
- if init
174
- else None
175
- )
176
198
 
177
199
  with ExitStack() as stack:
178
200
  aws_environ = (
@@ -207,7 +229,9 @@ def get_task_destroy(
207
229
  )
208
230
  )
209
231
 
210
- return cast(Task[Callable[[Context, bool, bool, bool, bool], None]], task_destroy)
232
+ return cast(
233
+ Task[Callable[[Context, bool, bool, bool, bool, bool], None]], task_destroy
234
+ )
211
235
 
212
236
 
213
237
  def get_collection(
@@ -9,6 +9,7 @@ from invoke.collection import Collection
9
9
  from invoke.context import Context
10
10
  from invoke.tasks import Task, task
11
11
 
12
+ from fogies.tasks.terraform import validate_init_params
12
13
  from fogies.terraform.backend import BackendOutput
13
14
  from fogies.tools.aws_environ import AwsEnvironFactory
14
15
  from fogies.tools.command import CommandParams
@@ -33,40 +34,34 @@ def get_task_backend_apply(
33
34
  output_model: type[TerraformOutputModel],
34
35
  output_model_get_backend: Callable[[TerraformOutputModel], BackendOutput],
35
36
  default_init: bool = True,
36
- default_init_upgrade: bool = False,
37
37
  default_init_reconfigure: bool = False,
38
+ default_init_upgrade: bool = False,
38
39
  default_apply_auto_approve: bool = False,
39
40
  ) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
40
41
  @task(
41
42
  name="apply",
43
+ auto_shortflags=False,
42
44
  help={
43
45
  "init": "Include terraform init. Skip with --no-init.",
44
- "init_upgrade": "During init, upgrade providers.",
45
46
  "init_reconfigure": "During init, reconfigure the backend.",
47
+ "init_upgrade": "During init, upgrade providers.",
46
48
  "apply_auto_approve": "Skip interactive confirmation.",
47
49
  },
48
50
  ) # pyright: ignore[reportUntypedFunctionDecorator]
49
51
  def task_apply(
50
52
  context: Context,
51
53
  init: bool = default_init,
52
- init_upgrade: bool = default_init_upgrade,
53
54
  init_reconfigure: bool = default_init_reconfigure,
55
+ init_upgrade: bool = default_init_upgrade,
54
56
  apply_auto_approve: bool = default_apply_auto_approve,
55
57
  ) -> None:
56
58
  """
57
59
  Apply a Terraform backend configuration, and record it as applied.
58
60
  """
59
- if init_upgrade and not init:
60
- raise ValueError("--init-upgrade requires --init")
61
- if init_reconfigure and not init:
62
- raise ValueError("--init-reconfigure requires --init")
61
+ init_params = InitParams(reconfigure=init_reconfigure, upgrade=init_upgrade)
62
+ validate_init_params(init=init, init_params=init_params)
63
63
 
64
64
  command_params = CommandParams(context=context)
65
- init_params = (
66
- InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
67
- if init
68
- else None
69
- )
70
65
 
71
66
  with ExitStack() as stack:
72
67
  if aws_environ_factory is not None:
@@ -108,24 +103,25 @@ def get_task_backend_destroy(
108
103
  output_model: type[TerraformOutputModel],
109
104
  output_model_get_backend: Callable[[TerraformOutputModel], BackendOutput],
110
105
  default_init: bool = True,
111
- default_init_upgrade: bool = False,
112
106
  default_init_reconfigure: bool = False,
107
+ default_init_upgrade: bool = False,
113
108
  default_destroy_auto_approve: bool = False,
114
109
  ) -> Task[Callable[[Context, bool, bool, bool, bool], None]]:
115
110
  @task(
116
111
  name="destroy",
112
+ auto_shortflags=False,
117
113
  help={
118
114
  "init": "Include terraform init. Skip with --no-init.",
119
- "init_upgrade": "During init, upgrade providers.",
120
115
  "init_reconfigure": "During init, reconfigure the backend.",
116
+ "init_upgrade": "During init, upgrade providers.",
121
117
  "destroy_auto_approve": "Skip interactive confirmation.",
122
118
  },
123
119
  ) # pyright: ignore[reportUntypedFunctionDecorator]
124
120
  def task_destroy(
125
121
  context: Context,
126
122
  init: bool = default_init,
127
- init_upgrade: bool = default_init_upgrade,
128
123
  init_reconfigure: bool = default_init_reconfigure,
124
+ init_upgrade: bool = default_init_upgrade,
129
125
  destroy_auto_approve: bool = default_destroy_auto_approve,
130
126
  ) -> None:
131
127
  """
@@ -136,17 +132,10 @@ def get_task_backend_destroy(
136
132
  objects before destroying it. Refuses (raises) if any declared state
137
133
  still has resources, regardless of how this task is invoked.
138
134
  """
139
- if init_upgrade and not init:
140
- raise ValueError("--init-upgrade requires --init")
141
- if init_reconfigure and not init:
142
- raise ValueError("--init-reconfigure requires --init")
135
+ init_params = InitParams(reconfigure=init_reconfigure, upgrade=init_upgrade)
136
+ validate_init_params(init=init, init_params=init_params)
143
137
 
144
138
  command_params = CommandParams(context=context)
145
- init_params = (
146
- InitParams(upgrade=init_upgrade, reconfigure=init_reconfigure)
147
- if init
148
- else None
149
- )
150
139
 
151
140
  with ExitStack() as stack:
152
141
  if aws_environ_factory is not None:
@@ -14,7 +14,9 @@ from invoke.tasks import Task, task
14
14
  def get_task_test(
15
15
  *, tests_path: pathlib.Path | None = None
16
16
  ) -> Task[Callable[[Context], None]]:
17
- @task(name="test") # pyright: ignore[reportUntypedFunctionDecorator]
17
+ @task(
18
+ name="test", auto_shortflags=False
19
+ ) # pyright: ignore[reportUntypedFunctionDecorator]
18
20
  def task_test(context: Context) -> None:
19
21
  """
20
22
  Run tests.
@@ -56,5 +56,9 @@ def backend_status_toml_template_factory() -> str:
56
56
  return _read_template_from_resources("backend_status.toml.template")
57
57
 
58
58
 
59
+ def claude_sync_toml_template_factory() -> str:
60
+ return _read_template_from_resources("claude_sync.toml.template")
61
+
62
+
59
63
  def poetry_toml_template_factory() -> str:
60
64
  return _read_template_from_resources("poetry.toml.template")
@@ -80,7 +80,7 @@ class BackendOutput(BaseModel):
80
80
  region: str
81
81
  state_keys: dict[str, str]
82
82
 
83
- def config(self, *, state_name: str) -> BackendConfig:
83
+ def backend_config(self, *, state_name: str) -> BackendConfig:
84
84
  """Return connection config for one of this backend's declared states.
85
85
 
86
86
  Raises ValueError if state_name is not declared as part of the backend.
@@ -101,7 +101,7 @@ class BackendOutput(BaseModel):
101
101
  )
102
102
 
103
103
  def __getitem__(self, state_name: str) -> BackendConfig:
104
- return self.config(state_name=state_name)
104
+ return self.backend_config(state_name=state_name)
105
105
 
106
106
 
107
107
  def backend_delete_state_objects(*, output: BackendOutput) -> None:
@@ -137,16 +137,18 @@ def backend_delete_state_objects(*, output: BackendOutput) -> None:
137
137
  )
138
138
 
139
139
 
140
- def backend_state_resources(*, config: BackendConfig, account_id: str) -> list[object]:
141
- """Return config's resources from the backend bucket.
140
+ def backend_state_resources(
141
+ *, backend_config: BackendConfig, account_id: str
142
+ ) -> list[object]:
143
+ """Return backend_config's resources from the backend bucket.
142
144
 
143
145
  Returns an empty list if the state's object is absent (never applied) or
144
146
  has no resources.
145
147
  """
146
148
  return _state_resources(
147
- bucket_name=config.bucket_name(account_id=account_id),
148
- region=config.region,
149
- key=config.key,
149
+ bucket_name=backend_config.bucket_name(account_id=account_id),
150
+ region=backend_config.region,
151
+ key=backend_config.key,
150
152
  )
151
153
 
152
154
 
@@ -0,0 +1,302 @@
1
+ """Tool wrapping claude-sync, built with a portable Go toolchain.
2
+
3
+ See <https://github.com/tawanorg/claude-sync>. claude-sync ships no Windows
4
+ release binary, so it's built from source here rather than downloaded
5
+ pre-built, unlike fogies.tools.terraform. Only push/pull/status are wrapped.
6
+
7
+ claude-sync's own setup wizard (`claude-sync init`) can't run unattended: for
8
+ S3 it prompts for the credentials, region and bucket even when they are given
9
+ as flags, and for the passphrase its encryption key can be derived from. So a
10
+ machine is set up by writing the files claude-sync reads, from one
11
+ gitignored config that is synced between machines like the other secrets. That
12
+ config holds the encryption key itself, not a passphrase; see fogies.age_key.
13
+ The credentials are not in it, but in a profile of the AWS config.
14
+
15
+ The files claude-sync reads are managed only from that config, not with
16
+ claude-sync's own commands. They are compared exactly, so changing them any
17
+ other way makes them differ from it.
18
+ """
19
+
20
+ import dataclasses
21
+ import json
22
+ import pathlib
23
+ import tomllib
24
+ from collections.abc import Generator
25
+ from contextlib import contextmanager
26
+ from typing import Literal
27
+
28
+ import tomlkit
29
+ from invoke.runners import Result
30
+ from pydantic import BaseModel, field_validator
31
+
32
+ from fogies.age_key import generate_age_key, validate_age_key
33
+ from fogies.templates import claude_sync_toml_template_factory, ensure_from_template
34
+ from fogies.tools.aws_environ import AwsProfile, load_aws_profile_from_config
35
+ from fogies.tools.command import CommandParams, command_run
36
+ from fogies.tools.go import go
37
+
38
+ # Most recent first; _KNOWN_VERSIONS[0] is the default.
39
+ _KNOWN_VERSIONS = [
40
+ "1.17.1",
41
+ ]
42
+
43
+ _DEFAULT_VERSION = _KNOWN_VERSIONS[0]
44
+
45
+ _MODULE_TEMPLATE = "github.com/tawanorg/claude-sync/cmd/claude-sync@v{version}"
46
+
47
+ # The claude-sync files, in the home directory.
48
+ _DIRECTORY_NAME = ".claude-sync"
49
+ _YAML_FILE_NAME = "config.yaml"
50
+ _KEY_FILE_NAME = "age-key.txt"
51
+
52
+
53
+ def claude_sync_files_directory() -> pathlib.Path:
54
+ """Where the claude-sync files are: its config.yaml and encryption key."""
55
+ return pathlib.Path.home() / _DIRECTORY_NAME
56
+
57
+
58
+ class ClaudeSyncConfig(BaseModel):
59
+ """Contents of the claude-sync config."""
60
+
61
+ # A profile in the AWS config, with the credentials claude-sync signs in with.
62
+ aws_profile: str
63
+ bucket: str
64
+ region: str
65
+ scope: Literal["full", "sessions"]
66
+ # Absent until created by configure.
67
+ age_key: str | None = None
68
+
69
+ @field_validator("age_key")
70
+ @classmethod
71
+ def _age_key_is_an_age_key(cls, value: str | None) -> str | None:
72
+ return None if value is None else validate_age_key(value)
73
+
74
+
75
+ def load_claude_sync_config(*, path: pathlib.Path) -> ClaudeSyncConfig:
76
+ """Return the config in path.
77
+
78
+ If it doesn't exist yet, it is created from a template and FileNotFoundError
79
+ is raised, so that it is filled in before it is used.
80
+ """
81
+ ensure_from_template(path=path, template_factory=claude_sync_toml_template_factory)
82
+ with path.open("rb") as f:
83
+ return ClaudeSyncConfig.model_validate(tomllib.load(f))
84
+
85
+
86
+ def update_claude_sync_config(*, path: pathlib.Path, age_key: str) -> None:
87
+ """Update the config with age_key, keeping its comments and layout."""
88
+ with path.open("r", encoding="utf-8") as f:
89
+ doc = tomlkit.parse(f.read())
90
+ doc["age_key"] = age_key
91
+ with path.open("w", encoding="utf-8", newline="\n") as f:
92
+ _ = f.write(tomlkit.dumps(doc))
93
+
94
+
95
+ def render_claude_sync_yaml(config: ClaudeSyncConfig, aws_profile: AwsProfile) -> str:
96
+ """Return the contents of claude-sync's config.yaml for config and aws_profile.
97
+
98
+ As `claude-sync init` writes for S3, except that the scope is always
99
+ written, not left out when it's the default. Values are written as JSON
100
+ strings, which are also valid YAML strings, so that none needs escaping.
101
+ """
102
+ lines = [
103
+ "storage:",
104
+ " provider: s3",
105
+ " bucket: " + json.dumps(config.bucket),
106
+ " access_key_id: " + json.dumps(aws_profile.aws_access_key_id),
107
+ " secret_access_key: " + json.dumps(aws_profile.aws_secret_access_key),
108
+ " region: " + json.dumps(config.region),
109
+ "encryption_key_path: "
110
+ + json.dumps("~/{}/{}".format(_DIRECTORY_NAME, _KEY_FILE_NAME)),
111
+ "scope: " + config.scope,
112
+ ]
113
+ return "\n".join(lines) + "\n"
114
+
115
+
116
+ @dataclasses.dataclass(frozen=True)
117
+ class ClaudeSyncConfigureResult:
118
+ """What configure_claude_sync() did."""
119
+
120
+ generated_age_key: bool
121
+ applied_to_claude_sync_files: bool
122
+
123
+
124
+ def configure_claude_sync(
125
+ *, claude_sync_config_path: pathlib.Path, aws_config_path: pathlib.Path, force: bool
126
+ ) -> ClaudeSyncConfigureResult:
127
+ """Apply the config at claude_sync_config_path to the claude-sync files.
128
+
129
+ The credentials are those of the config's profile in the AWS config at
130
+ aws_config_path.
131
+
132
+ The config decides what claude-sync's encryption key and config.yaml
133
+ should be, in claude_sync_files_directory(). If it has no key yet, a new one
134
+ is generated and saved in it, so that other machines use it too.
135
+
136
+ A claude-sync file that is missing is written, and one that already
137
+ matches is left alone. One that differs is an error, and nothing is
138
+ written, unless force is set, which replaces it.
139
+ """
140
+ # Where the claude-sync files are.
141
+ directory = claude_sync_files_directory()
142
+ key_path = directory / _KEY_FILE_NAME
143
+
144
+ # The key in the config, or a new one if it has none.
145
+ config = load_claude_sync_config(path=claude_sync_config_path)
146
+ aws_profile = load_aws_profile_from_config(
147
+ config_path=aws_config_path, profile_name=config.aws_profile
148
+ )
149
+ generated_age_key = config.age_key is None
150
+ age_key = generate_age_key() if config.age_key is None else config.age_key
151
+
152
+ # What the claude-sync files should contain.
153
+ expected_content = {
154
+ key_path: age_key + "\n",
155
+ directory / _YAML_FILE_NAME: render_claude_sync_yaml(config, aws_profile),
156
+ }
157
+ # Which of them are missing or differ.
158
+ paths_to_write = [
159
+ path
160
+ for path, text in expected_content.items()
161
+ if not path.exists() or path.read_text(encoding="utf-8") != text
162
+ ]
163
+ # Which of those exist but differ.
164
+ paths_that_differ = [path for path in paths_to_write if path.exists()]
165
+
166
+ # Refuse to replace what differs, unless forced.
167
+ if paths_that_differ and not force:
168
+ raise ValueError("Config does not match existing claude-sync files.")
169
+
170
+ # Save a generated key in the config, for other machines.
171
+ if generated_age_key:
172
+ update_claude_sync_config(path=claude_sync_config_path, age_key=age_key)
173
+
174
+ # Write the claude-sync files.
175
+ directory.mkdir(parents=True, exist_ok=True)
176
+ for path in paths_to_write:
177
+ _ = path.write_text(expected_content[path], encoding="utf-8", newline="\n")
178
+
179
+ return ClaudeSyncConfigureResult(
180
+ generated_age_key=generated_age_key,
181
+ applied_to_claude_sync_files=bool(paths_to_write),
182
+ )
183
+
184
+
185
+ class _ClaudeSync:
186
+ """Represents a claude-sync binary."""
187
+
188
+ _path: pathlib.Path
189
+
190
+ def __init__(self, *, path: pathlib.Path) -> None:
191
+ self._path = path
192
+
193
+ @property
194
+ def binary_path(self) -> pathlib.Path:
195
+ """The path to the claude-sync executable."""
196
+ return self._path
197
+
198
+ def push(
199
+ self,
200
+ *,
201
+ command_params: CommandParams,
202
+ include_mcp: bool = False,
203
+ quiet: bool = False,
204
+ ) -> Result:
205
+ """Run `claude-sync push`, uploading local changes to cloud storage."""
206
+ push_args = ["push"]
207
+ if include_mcp:
208
+ push_args.append("--include-mcp")
209
+ if quiet:
210
+ push_args.append("-q")
211
+ return command_run(
212
+ command=self.binary_path,
213
+ command_params=command_params,
214
+ args=push_args,
215
+ )
216
+
217
+ def pull(
218
+ self,
219
+ *,
220
+ command_params: CommandParams,
221
+ dry_run: bool = False,
222
+ force: bool = False,
223
+ include_mcp: bool = False,
224
+ quiet: bool = False,
225
+ rebuild_history: bool = False,
226
+ ) -> Result:
227
+ """Run `claude-sync pull`, downloading remote changes."""
228
+ pull_args = ["pull"]
229
+ if dry_run:
230
+ pull_args.append("--dry-run")
231
+ if force:
232
+ pull_args.append("--force")
233
+ if include_mcp:
234
+ pull_args.append("--include-mcp")
235
+ if quiet:
236
+ pull_args.append("-q")
237
+ if rebuild_history:
238
+ pull_args.append("--rebuild-history")
239
+ return command_run(
240
+ command=self.binary_path,
241
+ command_params=command_params,
242
+ args=pull_args,
243
+ )
244
+
245
+ def status(self, *, command_params: CommandParams, quiet: bool = False) -> Result:
246
+ """Run `claude-sync status`, showing pending local changes."""
247
+ status_args = ["status"]
248
+ if quiet:
249
+ status_args.append("-q")
250
+ return command_run(
251
+ command=self.binary_path,
252
+ command_params=command_params,
253
+ args=status_args,
254
+ )
255
+
256
+
257
+ @contextmanager
258
+ def claude_sync(
259
+ *,
260
+ version: str | None = None,
261
+ binary_cache_path: pathlib.Path,
262
+ raise_if_env_exists: bool,
263
+ ) -> Generator[_ClaudeSync]:
264
+ """Build claude-sync with a portable Go toolchain (if not cached) and yield a handle.
265
+
266
+ *version* when None uses the bundled default claude-sync version. Built
267
+ into a version-specific subdirectory of binary_cache_path (matching
268
+ fogies.tools.go's own SDK caching), so bumping the pinned version
269
+ triggers a fresh build rather than silently reusing a stale binary.
270
+
271
+ raise_if_env_exists has no default; see fogies.tools.environ.environ()
272
+ for why. Passed through to go().install()'s own GOBIN isolation.
273
+ """
274
+ if version is None:
275
+ version = _DEFAULT_VERSION
276
+
277
+ if version not in _KNOWN_VERSIONS:
278
+ known = ", ".join(_KNOWN_VERSIONS)
279
+ raise ValueError(
280
+ "Unknown claude-sync version '{}'; known versions: {}".format(
281
+ version, known
282
+ )
283
+ )
284
+
285
+ gobin_path = binary_cache_path / "claude_sync_{}".format(version.replace(".", "_"))
286
+ exe_path = gobin_path / "claude-sync.exe"
287
+
288
+ if not exe_path.exists():
289
+ with go(binary_cache_path=binary_cache_path) as go_tool:
290
+ _ = go_tool.install(
291
+ command_params=CommandParams(in_stream=False),
292
+ module=_MODULE_TEMPLATE.format(version=version),
293
+ gobin_path=gobin_path,
294
+ raise_if_env_exists=raise_if_env_exists,
295
+ )
296
+
297
+ if not exe_path.exists():
298
+ raise RuntimeError(
299
+ "claude-sync executable not found in '{}'".format(gobin_path)
300
+ )
301
+
302
+ yield _ClaudeSync(path=exe_path)
@@ -477,7 +477,8 @@ def terraform(
477
477
  backend_status.states[backend_config.state_name] = BackendStatusEntry(
478
478
  applied=bool(
479
479
  backend_state_resources(
480
- config=backend_config, account_id=aws_environ.account_id
480
+ backend_config=backend_config,
481
+ account_id=aws_environ.account_id,
481
482
  )
482
483
  )
483
484
  )
@@ -501,7 +502,8 @@ def terraform(
501
502
  backend_status.states[backend_config.state_name] = BackendStatusEntry(
502
503
  applied=bool(
503
504
  backend_state_resources(
504
- config=backend_config, account_id=aws_environ.account_id
505
+ backend_config=backend_config,
506
+ account_id=aws_environ.account_id,
505
507
  )
506
508
  )
507
509
  )
@@ -1,130 +0,0 @@
1
- """Tool wrapping claude-sync, built with a portable Go toolchain.
2
-
3
- See <https://github.com/tawanorg/claude-sync>. claude-sync ships no Windows
4
- release binary, so it's built from source here rather than downloaded
5
- pre-built, unlike fogies.tools.terraform. Only push/pull/status are wrapped;
6
- claude-sync's own storage setup (S3/R2/etc. credentials, bucket, encryption
7
- passphrase) is configured through `claude-sync init`'s interactive wizard,
8
- which is out of scope here.
9
- """
10
-
11
- import pathlib
12
- from collections.abc import Generator
13
- from contextlib import contextmanager
14
-
15
- from invoke.runners import Result
16
-
17
- from fogies.tools.command import CommandParams, command_run
18
- from fogies.tools.go import go
19
-
20
- # Most recent first; _KNOWN_VERSIONS[0] is the default.
21
- _KNOWN_VERSIONS = [
22
- "1.17.1",
23
- ]
24
-
25
- _DEFAULT_VERSION = _KNOWN_VERSIONS[0]
26
-
27
- _MODULE_TEMPLATE = "github.com/tawanorg/claude-sync/cmd/claude-sync@v{version}"
28
-
29
-
30
- class _ClaudeSync:
31
- """Represents a claude-sync binary."""
32
-
33
- _path: pathlib.Path
34
-
35
- def __init__(self, *, path: pathlib.Path) -> None:
36
- self._path = path
37
-
38
- @property
39
- def binary_path(self) -> pathlib.Path:
40
- """The path to the claude-sync executable."""
41
- return self._path
42
-
43
- def push(self, *, command_params: CommandParams, quiet: bool = False) -> Result:
44
- """Run `claude-sync push`, uploading local changes to cloud storage."""
45
- push_args = ["push"]
46
- if quiet:
47
- push_args.append("-q")
48
- return command_run(
49
- command=self.binary_path,
50
- command_params=command_params,
51
- args=push_args,
52
- )
53
-
54
- def pull(
55
- self,
56
- *,
57
- command_params: CommandParams,
58
- dry_run: bool = False,
59
- force: bool = False,
60
- quiet: bool = False,
61
- ) -> Result:
62
- """Run `claude-sync pull`, downloading remote changes."""
63
- pull_args = ["pull"]
64
- if dry_run:
65
- pull_args.append("--dry-run")
66
- if force:
67
- pull_args.append("--force")
68
- if quiet:
69
- pull_args.append("-q")
70
- return command_run(
71
- command=self.binary_path,
72
- command_params=command_params,
73
- args=pull_args,
74
- )
75
-
76
- def status(self, *, command_params: CommandParams) -> Result:
77
- """Run `claude-sync status`, showing pending local changes."""
78
- return command_run(
79
- command=self.binary_path,
80
- command_params=command_params,
81
- args=["status"],
82
- )
83
-
84
-
85
- @contextmanager
86
- def claude_sync(
87
- *,
88
- version: str | None = None,
89
- binary_cache_path: pathlib.Path,
90
- raise_if_env_exists: bool,
91
- ) -> Generator[_ClaudeSync]:
92
- """Build claude-sync with a portable Go toolchain (if not cached) and yield a handle.
93
-
94
- *version* when None uses the bundled default claude-sync version. Built
95
- into a version-specific subdirectory of binary_cache_path (matching
96
- fogies.tools.go's own SDK caching), so bumping the pinned version
97
- triggers a fresh build rather than silently reusing a stale binary.
98
-
99
- raise_if_env_exists has no default; see fogies.tools.environ.environ()
100
- for why. Passed through to go().install()'s own GOBIN isolation.
101
- """
102
- if version is None:
103
- version = _DEFAULT_VERSION
104
-
105
- if version not in _KNOWN_VERSIONS:
106
- known = ", ".join(_KNOWN_VERSIONS)
107
- raise ValueError(
108
- "Unknown claude-sync version '{}'; known versions: {}".format(
109
- version, known
110
- )
111
- )
112
-
113
- gobin_path = binary_cache_path / "claude_sync_{}".format(version.replace(".", "_"))
114
- exe_path = gobin_path / "claude-sync.exe"
115
-
116
- if not exe_path.exists():
117
- with go(binary_cache_path=binary_cache_path) as go_tool:
118
- _ = go_tool.install(
119
- command_params=CommandParams(in_stream=False),
120
- module=_MODULE_TEMPLATE.format(version=version),
121
- gobin_path=gobin_path,
122
- raise_if_env_exists=raise_if_env_exists,
123
- )
124
-
125
- if not exe_path.exists():
126
- raise RuntimeError(
127
- "claude-sync executable not found in '{}'".format(gobin_path)
128
- )
129
-
130
- yield _ClaudeSync(path=exe_path)
File without changes