fwdit 0.1.2__py3-none-any.whl
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.
- fwd/SKILL.md +76 -0
- fwd/__init__.py +22 -0
- fwd/agents/README.md +71 -0
- fwd/agents/__init__.py +27 -0
- fwd/agents/base.py +127 -0
- fwd/agents/claude.py +178 -0
- fwd/agents/claude_state.py +625 -0
- fwd/agents/codex.py +102 -0
- fwd/agents/codex_state.py +73 -0
- fwd/agents/remote_state.py +74 -0
- fwd/backends/README.md +71 -0
- fwd/backends/__init__.py +93 -0
- fwd/backends/base.py +328 -0
- fwd/backends/lambda_cloud.py +812 -0
- fwd/backends/runpod.py +926 -0
- fwd/backends/slurm.py +599 -0
- fwd/backends/slurm_job.py +192 -0
- fwd/backends/ssh.py +269 -0
- fwd/cli.py +1041 -0
- fwd/cli_completion.py +225 -0
- fwd/cli_help.py +104 -0
- fwd/command_docs.py +92 -0
- fwd/completion_setup.py +73 -0
- fwd/config.py +674 -0
- fwd/credentials.py +188 -0
- fwd/doctor.py +205 -0
- fwd/github_auth.py +338 -0
- fwd/launch_stream.py +173 -0
- fwd/ops/__init__.py +11 -0
- fwd/ops/attach.py +326 -0
- fwd/ops/configcmd.py +643 -0
- fwd/ops/diff.py +217 -0
- fwd/ops/launch.py +810 -0
- fwd/ops/lifecycle.py +543 -0
- fwd/ops/machines.py +73 -0
- fwd/ops/ports.py +262 -0
- fwd/ops/send.py +536 -0
- fwd/ops/session_select.py +286 -0
- fwd/ops/target_alias.py +177 -0
- fwd/ops/transfer.py +116 -0
- fwd/ops/uninstall.py +277 -0
- fwd/output.py +160 -0
- fwd/port_forwarding.py +164 -0
- fwd/references/agent-transfer.md +83 -0
- fwd/references/commands-and-lifecycle.md +232 -0
- fwd/references/targets-and-config.md +149 -0
- fwd/remote.py +216 -0
- fwd/remote_env.py +19 -0
- fwd/remote_tasks.py +136 -0
- fwd/rsync_transport.py +124 -0
- fwd/scripts/bootstrap.sh +120 -0
- fwd/scripts/codex_tui_send.py +229 -0
- fwd/selection.py +226 -0
- fwd/send_tasks.py +179 -0
- fwd/session_columns.py +50 -0
- fwd/skill_agents/openai.yaml +6 -0
- fwd/skill_setup.py +169 -0
- fwd/ssh_keys.py +232 -0
- fwd/sshexec.py +480 -0
- fwd/state.py +281 -0
- fwd/stop_after.py +220 -0
- fwd/sync.py +765 -0
- fwd/task_stream.py +214 -0
- fwd/tmux_config.py +87 -0
- fwd/toolchains/README.md +120 -0
- fwd/toolchains/__init__.py +31 -0
- fwd/toolchains/javascript.py +38 -0
- fwd/toolchains/python.py +28 -0
- fwd/toolchains/swift.py +27 -0
- fwd/tooling/__init__.py +6 -0
- fwd/tooling/base.py +99 -0
- fwd/tooling/requirements.py +286 -0
- fwd/tooling/resolver.py +86 -0
- fwd/ui.py +376 -0
- fwd/wizard.py +582 -0
- fwd/worktree_safety.py +92 -0
- fwdit-0.1.2.dist-info/METADATA +155 -0
- fwdit-0.1.2.dist-info/RECORD +80 -0
- fwdit-0.1.2.dist-info/WHEEL +4 -0
- fwdit-0.1.2.dist-info/entry_points.txt +2 -0
fwd/SKILL.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fwd
|
|
3
|
+
description: Move a coding project or active Claude Code/Codex workflow to remote compute with fwd. Use for remote development, SSH, RunPod, Lambda Cloud, Slurm, extra CPU/GPU/memory, persistent remote agents, durable commands, synchronization, attaching, stopping, or destroying remote sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# fwd remote development
|
|
7
|
+
|
|
8
|
+
Use `fwd` to provision or reuse remote compute, synchronize the current project, prepare its tools, and run a persistent coding agent or command in tmux. Invoking this skill means the user wants fwd used.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Preserve the user's task, requested target, hardware, and shutdown requirements. Use the caller's agent (`codex` from Codex, `claude` from Claude) unless the user specifies another.
|
|
13
|
+
2. If a provider machine must be chosen, inspect exact values before launch with `fwd up --machines` or `fwd up TARGET --machines`. Prefer CPU unless the user requests a GPU; never guess or abbreviate a provider identifier.
|
|
14
|
+
3. Launch without taking over the terminal: `fwd up --detach --agent AGENT`, adding `--target TARGET` and `--machine MACHINE` only when selected. Never use a bare/reuse/attach form as a tool call.
|
|
15
|
+
4. Read the exact session name and live state from `fwd ls --json`.
|
|
16
|
+
5. Send the preserved task with `fwd send --name SESSION agent "TASK"`. Stream and iterate by default; use `--detach` only when the user asks to background the task.
|
|
17
|
+
6. Inspect changed work with `fwd diff -q SESSION`, use `fwd diff SESSION` when details matter, and retrieve accepted files with `fwd pull --name SESSION`.
|
|
18
|
+
7. Report the result and exact commands the user may need, especially `fwd attach SESSION`, `fwd send --name SESSION --ls`, and `fwd stop SESSION`.
|
|
19
|
+
|
|
20
|
+
If setup is required, follow the exact flags printed by fwd. Do not open an interactive setup wizard or invent target values.
|
|
21
|
+
|
|
22
|
+
For a requested shell command instead of agent work, use `fwd send --name SESSION -- COMMAND...`. Reattach with `fwd send --name SESSION TASK_ID`; cancel only that task with `fwd send --name SESSION TASK_ID --stop`.
|
|
23
|
+
|
|
24
|
+
If `fwd` is unavailable, install the published distribution with `uv tool install fwdit`. If `uv` is unavailable, report that Python 3.12+, `uv`, `ssh`, and `rsync` are required instead of improvising another installer.
|
|
25
|
+
|
|
26
|
+
## Safe automation
|
|
27
|
+
|
|
28
|
+
- Prefer `--json` for `fwd ls`, `fwd doctor`, `fwd info`, and task listings. Diagnostics remain on stderr.
|
|
29
|
+
- Never run bare `fwd`, `fwd TARGET`, `fwd attach`, `fwd a`, `fwd up --reuse`, or `fwd up --attach` as a tool call; they can take over a human terminal. Hand the exact attach command to the user.
|
|
30
|
+
- Do not use `--restart` unless the user authorizes restarting stopped billable compute.
|
|
31
|
+
- Do not use `--creds` unless the user authorizes copying live Claude credentials. GitHub setup defaults on; use `--no-setup-github` when credentials must stay local.
|
|
32
|
+
- Never force `stop`, `rm`, or stop-after past a dirty or unreachable worktree unless the user explicitly accepts losing remote-only changes.
|
|
33
|
+
- Never run `fwd rm --all --force` unless the user explicitly requests destruction of every tracked remote resource.
|
|
34
|
+
- Run `fwd uninstall --force` only for an explicit local-uninstall request after explaining that it does not destroy remote resources; prefer `fwd rm --all` first.
|
|
35
|
+
- Prefer `fwd diff -q` before push or pull. Exit 0 means synchronized, 1 different, and 2 error.
|
|
36
|
+
- If upload exceeds `sync.max_size_gb`, confirm the directory is intentional before using the exact project-scoped limit command printed by fwd.
|
|
37
|
+
- Missing `npx` or a failed optional skill refresh must not block normal fwd commands.
|
|
38
|
+
- If preparation fails after provisioning and sync, give the human `fwd attach SESSION --raw` for a recovery shell. This does not authorize restarting stopped compute.
|
|
39
|
+
|
|
40
|
+
## Stop after work
|
|
41
|
+
|
|
42
|
+
For supported backends, use remote-owned shutdown so it survives local disconnection:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
fwd up --stop-after -- COMMAND...
|
|
46
|
+
fwd send --name SESSION --stop-after agent "TASK"
|
|
47
|
+
fwd send --name SESSION stopafter
|
|
48
|
+
fwd send --name SESSION cancel stopafter
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Confirm the lifecycle task with `fwd send --name SESSION --ls --json`. Stop-after refuses a dirty remote worktree; never force it without explicit acceptance of data loss.
|
|
52
|
+
|
|
53
|
+
Lambda does not support remote stop-after because its broad API key stays local. Retrieve durable results, then tell the user to run `fwd stop SESSION` from a connected machine.
|
|
54
|
+
|
|
55
|
+
## Useful commands
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
fwd up --detach --target runpod --agent codex
|
|
59
|
+
fwd up --detach --new --target runpod --agent codex
|
|
60
|
+
fwd up -- COMMAND...
|
|
61
|
+
fwd send --name SESSION agent "TASK"
|
|
62
|
+
fwd send --name SESSION -- COMMAND...
|
|
63
|
+
fwd send --name SESSION --ls --json
|
|
64
|
+
fwd diff -q SESSION
|
|
65
|
+
fwd pull --name SESSION outputs/
|
|
66
|
+
fwd ls --all-projects --json
|
|
67
|
+
fwd doctor --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## References
|
|
71
|
+
|
|
72
|
+
- Read [targets and configuration](references/targets-and-config.md) for resolution, setup, defaults, machines, and backend behavior.
|
|
73
|
+
- Read [commands and lifecycle](references/commands-and-lifecycle.md) for launch, durable tasks, synchronization, ports, attachment, stopping, and destruction.
|
|
74
|
+
- Read [agent transfer](references/agent-transfer.md) before launching Claude Code or Codex when transcripts, settings, skills, authentication, or remote control matter.
|
|
75
|
+
|
|
76
|
+
`fwd --help` and `fwd COMMAND --help` are authoritative for the installed version.
|
fwd/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""fwd — move local coding work and agent sessions to remote compute.
|
|
2
|
+
|
|
3
|
+
The package is layered deliberately so that each layer can be developed and tested in isolation:
|
|
4
|
+
|
|
5
|
+
- ``sshexec`` : the only place that shells out to ``ssh``; everything remote goes through an ``SSHEndpoint``.
|
|
6
|
+
- ``config``/``state`` : pure data layers (TOML config, ``~/.fwd/state.json``) with no side effects beyond file IO.
|
|
7
|
+
- ``backends/*`` : provisioning strategies behind the ``Provisioner`` protocol; they return a ``TargetInfo`` and know nothing about Claude.
|
|
8
|
+
- ``sync``/``remote`` : mechanical project and remote-environment setup.
|
|
9
|
+
- ``agents/*`` : class-based coding-agent integrations and their state-transfer helpers.
|
|
10
|
+
- ``ops/*`` : orchestration of the above into user-facing operations.
|
|
11
|
+
- ``cli`` : thin Typer surface that only parses flags and delegates to ``ops``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
# The distribution is named fwdit because the fwd name is owned by an unrelated PyPI project; the import package and command intentionally remain fwd.
|
|
19
|
+
__version__ = version("fwdit")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
# Keep source-tree imports useful before the project has been installed, while installed builds always report their tag-derived package metadata.
|
|
22
|
+
__version__ = "0.0.0"
|
fwd/agents/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Adding a coding agent
|
|
2
|
+
|
|
3
|
+
Coding-agent integrations are class-based plugins inside this package. General launch, attach, and send code depends
|
|
4
|
+
only on `Agent`, so a new built-in agent should normally require one new Python module plus one registry entry.
|
|
5
|
+
|
|
6
|
+
## Implement the contract
|
|
7
|
+
|
|
8
|
+
Create `src/fwd/agents/<name>.py` and subclass `Agent` from `base.py`:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from typing import Mapping
|
|
12
|
+
|
|
13
|
+
from fwd.agents.base import Agent
|
|
14
|
+
from fwd.tooling.requirements import MY_AGENT
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MyAgent(Agent):
|
|
18
|
+
name = "my-agent"
|
|
19
|
+
command = ("my-agent",)
|
|
20
|
+
tools = (MY_AGENT,)
|
|
21
|
+
|
|
22
|
+
def startup_command(self, flags: Mapping[str, object]) -> str:
|
|
23
|
+
return "my-agent"
|
|
24
|
+
|
|
25
|
+
def send_command(self, message: str, flags: Mapping[str, object], *, tmux_session: str = "", remote_dir: str = "") -> tuple[str, ...]:
|
|
26
|
+
return ("my-agent", "--print", message)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`name` is the magic `fwd up <name>` selector. `command` is persisted in session state and must uniquely resolve the
|
|
30
|
+
agent. `tools` uses the shared requirement resolver, which reuses working remote binaries before attempting installs.
|
|
31
|
+
|
|
32
|
+
Every agent automatically receives the shared `[agents.<name>]` runtime policy from `Agent.launch_flags()`:
|
|
33
|
+
`full_access`, `args`, and environment defaults. Use `runtime_args()`, `with_environment_defaults()`, and
|
|
34
|
+
`environment_command()` in the implementation so interactive, restarted, and non-interactive send commands behave
|
|
35
|
+
consistently. An implementation owns the exact full-access CLI option and must suppress its default when configured
|
|
36
|
+
arguments already select a permission or sandbox policy.
|
|
37
|
+
|
|
38
|
+
Override `prepare_local()` when an agent must create files before the project sync. The returned object is opaque to
|
|
39
|
+
the launcher and is passed to `prepare_remote()` after bootstrap and tool installation. Override `prepare_remote()`
|
|
40
|
+
to upload settings or import conversation state, and return serializable flags needed by `startup_command()` or a
|
|
41
|
+
later restart. Keep optional state transfer best-effort: failure to copy convenience state should not discard a
|
|
42
|
+
successfully provisioned machine.
|
|
43
|
+
|
|
44
|
+
Set `remote_home_entry` to the product-owned hidden directory when authentication, conversations, or managed payloads
|
|
45
|
+
live beneath remote `$HOME`. Backends with disposable homes ask the shared base implementation to relocate that
|
|
46
|
+
directory beneath the tool prefix before resolving tools; other backends leave the user's home untouched.
|
|
47
|
+
|
|
48
|
+
Override `launch_flags()` only if the agent owns special CLI/config behavior. Agent-independent orchestration should
|
|
49
|
+
never test `agent.name`; put that decision in the implementation instead.
|
|
50
|
+
|
|
51
|
+
Override `prepare_send()` when an agent needs a remote helper before constructing its turn command. `send_command()`
|
|
52
|
+
receives the exact primary tmux session and remote project directory so a pane-backed integration can address the
|
|
53
|
+
long-lived conversation rather than guessing from global history.
|
|
54
|
+
|
|
55
|
+
Optional remote-control setup also belongs in the agent implementation because products expose different models.
|
|
56
|
+
Claude decorates its long-lived interactive command, while Codex starts a separate managed app-server daemon beside
|
|
57
|
+
the primary TUI. Probe both CLI support and compatible account authentication, degrade to the normal terminal session
|
|
58
|
+
when unavailable, and never make remote-control enrollment a prerequisite for launching the agent.
|
|
59
|
+
Codex must invoke the standalone binary at `~/.codex/packages/standalone/current/codex` for daemon probes and startup;
|
|
60
|
+
the npm/Bun CLI exposing the same subcommand help does not establish that the managed daemon payload is installed.
|
|
61
|
+
|
|
62
|
+
## Register and test it
|
|
63
|
+
|
|
64
|
+
Import and instantiate the class in `agents/__init__.py`. The registry is explicit so CLI startup, packaging, and
|
|
65
|
+
tests remain deterministic.
|
|
66
|
+
|
|
67
|
+
Add focused tests for exact command resolution, required tools, startup/send argv, and any state-transfer safety
|
|
68
|
+
rules. If the agent copies user files, use a strict allowlist and exclude authentication by default.
|
|
69
|
+
|
|
70
|
+
Agent-specific state helpers belong in this package (for example `claude_state.py` and `codex_state.py`), beside the
|
|
71
|
+
class that owns them. Do not add agent branches to `ops/launch.py`, `ops/send.py`, or the bootstrap script.
|
fwd/agents/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Class-based coding-agent registry.
|
|
2
|
+
|
|
3
|
+
Each supported coding agent lives in one module and implements :class:`Agent`. The launch and send layers consume
|
|
4
|
+
only that contract, which keeps agent-specific state transfer and command construction out of general orchestration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fwd.agents.base import Agent, AgentLaunchOptions
|
|
10
|
+
from fwd.agents.claude import ClaudeAgent
|
|
11
|
+
from fwd.agents.codex import CodexAgent
|
|
12
|
+
|
|
13
|
+
AGENTS: dict[str, Agent] = {agent.name: agent for agent in (ClaudeAgent(), CodexAgent())}
|
|
14
|
+
|
|
15
|
+
# Compatibility name for integrations written against the original data-only registry. It deliberately aliases the
|
|
16
|
+
# richer class contract rather than preserving a second abstraction.
|
|
17
|
+
AgentSpec = Agent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve(command: tuple[str, ...]) -> Agent | None:
|
|
21
|
+
"""Return the registered agent for an exact magic command, otherwise ``None``."""
|
|
22
|
+
if len(command) != 1:
|
|
23
|
+
return None
|
|
24
|
+
return AGENTS.get(command[0])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["AGENTS", "Agent", "AgentLaunchOptions", "AgentSpec", "resolve"]
|
fwd/agents/base.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Common contract for remotely hosted coding agents.
|
|
2
|
+
|
|
3
|
+
The launch pipeline has three agent extension points: prepare local state before project synchronization, prepare
|
|
4
|
+
remote state after tools are installed, and build the long-lived command placed in tmux. Send support belongs here
|
|
5
|
+
too, so the CLI never needs to branch on an agent name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
from fwd.config import Config
|
|
18
|
+
from fwd.sshexec import SSHEndpoint
|
|
19
|
+
from fwd.tooling import ToolRequirement
|
|
20
|
+
from fwd.agents.remote_state import install_persistent_home
|
|
21
|
+
|
|
22
|
+
ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class AgentLaunchOptions:
|
|
27
|
+
"""Agent-related CLI switches passed to every implementation.
|
|
28
|
+
|
|
29
|
+
The current switches describe Claude's optional transfer modes. Other agents reject unsupported non-default
|
|
30
|
+
options through :meth:`Agent.launch_flags`; a future cross-agent option can be added here without changing launch
|
|
31
|
+
orchestration.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
session: bool = False
|
|
35
|
+
handoff: bool = False
|
|
36
|
+
user_config: bool = False
|
|
37
|
+
creds: bool = False
|
|
38
|
+
|
|
39
|
+
def any(self) -> bool:
|
|
40
|
+
"""Return whether the caller explicitly requested any agent-specific behavior."""
|
|
41
|
+
return any((self.session, self.handoff, self.user_config, self.creds))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Agent(ABC):
|
|
45
|
+
"""Base class every built-in coding agent implements.
|
|
46
|
+
|
|
47
|
+
Implementations own their executable requirements, state/config transfer, startup and resume commands, and send
|
|
48
|
+
protocol. General launch orchestration calls these hooks at fixed lifecycle points and treats the returned local
|
|
49
|
+
state as opaque, so adding an agent does not require editing the launch pipeline.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
command: tuple[str, ...]
|
|
54
|
+
tools: tuple[ToolRequirement, ...]
|
|
55
|
+
remote_home_entry: str | None = None
|
|
56
|
+
|
|
57
|
+
def launch_flags(self, config: Config, options: AgentLaunchOptions) -> dict[str, Any]:
|
|
58
|
+
"""Resolve CLI/config options into serializable session flags.
|
|
59
|
+
|
|
60
|
+
Agents with no special launch flags accept only the default option set. This catches accidental use of
|
|
61
|
+
Claude-only switches without teaching the orchestrator which implementation owns them.
|
|
62
|
+
"""
|
|
63
|
+
if options.any():
|
|
64
|
+
raise ValueError(f"agent {self.name!r} does not support --session, --handoff, --user-config, or --creds")
|
|
65
|
+
runtime = config.agent(self.name)
|
|
66
|
+
invalid_names = sorted(name for name in runtime.environment if not ENVIRONMENT_NAME.fullmatch(name))
|
|
67
|
+
if invalid_names:
|
|
68
|
+
raise ValueError(f"agent {self.name!r} has invalid environment variable name(s): {', '.join(invalid_names)}")
|
|
69
|
+
return {
|
|
70
|
+
"agent_full_access": runtime.full_access,
|
|
71
|
+
"agent_args": list(runtime.args),
|
|
72
|
+
"agent_environment": dict(runtime.environment),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def runtime_args(self, flags: Mapping[str, object]) -> list[str]:
|
|
76
|
+
"""Return the configured argv extension recorded with the session."""
|
|
77
|
+
value = flags.get("agent_args")
|
|
78
|
+
return [str(part) for part in value] if isinstance(value, list) else []
|
|
79
|
+
|
|
80
|
+
def with_environment_defaults(self, command: str, flags: Mapping[str, object]) -> str:
|
|
81
|
+
"""Export configured variables only when the remote shell has not already defined them."""
|
|
82
|
+
value = flags.get("agent_environment")
|
|
83
|
+
if not isinstance(value, dict) or not value:
|
|
84
|
+
return command
|
|
85
|
+
exports = []
|
|
86
|
+
for name, default in value.items():
|
|
87
|
+
if not isinstance(name, str) or not ENVIRONMENT_NAME.fullmatch(name):
|
|
88
|
+
continue
|
|
89
|
+
exports.append(f'[ "${{{name}+x}}" = x ] || export {name}={shlex.quote(str(default))}')
|
|
90
|
+
return f"{'; '.join(exports)}; exec {command}" if exports else command
|
|
91
|
+
|
|
92
|
+
def environment_command(self, command: list[str], flags: Mapping[str, object]) -> tuple[str, ...]:
|
|
93
|
+
"""Return argv for a non-interactive agent command with the same environment defaults as the TUI."""
|
|
94
|
+
plain = shlex.join(command)
|
|
95
|
+
wrapped = self.with_environment_defaults(plain, flags)
|
|
96
|
+
return tuple(command) if wrapped == plain else ("bash", "-lc", wrapped)
|
|
97
|
+
|
|
98
|
+
def prepare_local(self, local_cwd: Path, flags: dict[str, Any]) -> object | None:
|
|
99
|
+
"""Prepare state that must exist before project synchronization and return opaque transfer state."""
|
|
100
|
+
del local_cwd, flags
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def prepare_remote_home(self, endpoint: SSHEndpoint, tool_prefix: str, *, ephemeral: bool) -> None:
|
|
104
|
+
"""Relocate product-owned home state when the backend reports that the normal remote home is disposable."""
|
|
105
|
+
if ephemeral and self.remote_home_entry is not None:
|
|
106
|
+
install_persistent_home(endpoint, tool_prefix, self.remote_home_entry)
|
|
107
|
+
|
|
108
|
+
def prepare_remote(self, endpoint: SSHEndpoint, remote_dir: str, flags: dict[str, Any], local_state: object | None) -> dict[str, Any]:
|
|
109
|
+
"""Install settings/state after bootstrap and return additional serializable session flags."""
|
|
110
|
+
del endpoint, remote_dir, flags, local_state
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
def restart_command(self, flags: Mapping[str, object]) -> str:
|
|
114
|
+
"""Build the command used when restarting an existing session without retransferring state."""
|
|
115
|
+
return self.startup_command(flags)
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def startup_command(self, flags: Mapping[str, object]) -> str:
|
|
119
|
+
"""Build the long-lived command placed in the session's primary tmux pane."""
|
|
120
|
+
|
|
121
|
+
def prepare_send(self, endpoint: SSHEndpoint, flags: Mapping[str, object]) -> None:
|
|
122
|
+
"""Ensure optional remote helpers required by a later send command are ready."""
|
|
123
|
+
del endpoint, flags
|
|
124
|
+
|
|
125
|
+
@abstractmethod
|
|
126
|
+
def send_command(self, message: str, flags: Mapping[str, object], *, tmux_session: str = "", remote_dir: str = "") -> tuple[str, ...]:
|
|
127
|
+
"""Build a command that sends one message into the agent's current conversation."""
|
fwd/agents/claude.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Claude Code agent integration.
|
|
2
|
+
|
|
3
|
+
Claude is the most stateful built-in agent: local preparation may export a transcript or generate a handoff document,
|
|
4
|
+
while remote preparation can upload config, credentials, and the relocated transcript. Those details live here so
|
|
5
|
+
the general launch pipeline sees the same hooks it sees for Codex or any future agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
from fwd import ui
|
|
17
|
+
from fwd.agents.base import Agent, AgentLaunchOptions
|
|
18
|
+
from fwd.agents import claude_state
|
|
19
|
+
from fwd.config import Config
|
|
20
|
+
from fwd.sshexec import SSHEndpoint
|
|
21
|
+
from fwd.tooling.requirements import CLAUDE
|
|
22
|
+
|
|
23
|
+
HANDOFF_PROMPT = "Read HANDOFF.md, then continue the work it describes"
|
|
24
|
+
HANDOFF_MAX_AGE_SECONDS = 15 * 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_command(*, resume_id: str | None, use_handoff: bool, remote_control_name: str | None = None, runtime_args: tuple[str, ...] = ()) -> str:
|
|
28
|
+
"""Build a Claude startup command with the selected context and optional cross-device control."""
|
|
29
|
+
command = ["claude", *runtime_args]
|
|
30
|
+
if resume_id:
|
|
31
|
+
command.extend(("--resume", resume_id))
|
|
32
|
+
elif use_handoff:
|
|
33
|
+
command.append(HANDOFF_PROMPT)
|
|
34
|
+
plain_command = shlex.join(command)
|
|
35
|
+
if not remote_control_name:
|
|
36
|
+
return plain_command
|
|
37
|
+
remote_command = shlex.join(["claude", "--remote-control", remote_control_name, *command[1:]])
|
|
38
|
+
fallback = f"{remote_command} || {{ printf '%s\\n' 'Claude Remote Control unavailable; starting a normal terminal session.' >&2; exec {plain_command}; }}"
|
|
39
|
+
return f"bash -lc {shlex.quote(fallback)}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _remote_control_status(endpoint: SSHEndpoint) -> int:
|
|
43
|
+
"""Return 0 when Remote Control can start, 2 when supported but not account-authenticated, or 1 when absent."""
|
|
44
|
+
probe = endpoint.run(
|
|
45
|
+
"claude --help 2>&1 | grep -q -- '--remote-control' || exit 1; "
|
|
46
|
+
"status=$(claude auth status --json 2>/dev/null) || exit 2; "
|
|
47
|
+
"printf %s \"$status\" | grep -Eq '\"authMethod\"[[:space:]]*:[[:space:]]*\"claude\\.ai\"' || exit 2; "
|
|
48
|
+
"printf %s \"$status\" | grep -Eq '\"subscriptionType\"[[:space:]]*:[[:space:]]*\"(pro|max|team|enterprise)\"' || exit 2; "
|
|
49
|
+
"printf fwd-claude-remote-control-ready",
|
|
50
|
+
check=False,
|
|
51
|
+
)
|
|
52
|
+
if probe.returncode == 2:
|
|
53
|
+
return 2
|
|
54
|
+
return 0 if probe.returncode == 0 and probe.stdout.strip() == "fwd-claude-remote-control-ready" else 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fresh_handoff(local_cwd: Path) -> Path | None:
|
|
58
|
+
"""Return a recent HANDOFF.md so repair launches avoid another expensive local agent run."""
|
|
59
|
+
handoff = local_cwd / "HANDOFF.md"
|
|
60
|
+
if not handoff.is_file():
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
age = time.time() - handoff.stat().st_mtime
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
return handoff if 0 <= age < HANDOFF_MAX_AGE_SECONDS else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClaudeAgent(Agent):
|
|
70
|
+
"""Transfer optional Claude state and construct commands that resume the same conversation."""
|
|
71
|
+
|
|
72
|
+
name = "claude"
|
|
73
|
+
command = ("claude",)
|
|
74
|
+
tools = (CLAUDE,)
|
|
75
|
+
remote_home_entry = ".claude"
|
|
76
|
+
|
|
77
|
+
def launch_flags(self, config: Config, options: AgentLaunchOptions) -> dict[str, Any]:
|
|
78
|
+
"""Merge explicit transfer switches with Claude config, preserving handoff/session precedence."""
|
|
79
|
+
runtime_flags = super().launch_flags(config, AgentLaunchOptions())
|
|
80
|
+
if options.handoff:
|
|
81
|
+
want_session, want_handoff = False, True
|
|
82
|
+
elif options.session:
|
|
83
|
+
want_session, want_handoff = True, config.claude.handoff
|
|
84
|
+
else:
|
|
85
|
+
want_session, want_handoff = config.claude.session, config.claude.handoff
|
|
86
|
+
return {
|
|
87
|
+
**runtime_flags,
|
|
88
|
+
"session": want_session,
|
|
89
|
+
"handoff": want_handoff,
|
|
90
|
+
"user_config": options.user_config or config.claude.user_config,
|
|
91
|
+
"creds": options.creds or config.claude.creds,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def _runtime_args(self, flags: Mapping[str, object]) -> list[str]:
|
|
95
|
+
"""Apply bypassPermissions by default while respecting an explicitly configured permission-mode argument."""
|
|
96
|
+
configured = self.runtime_args(flags)
|
|
97
|
+
permission_flags = {"--permission-mode", "--dangerously-skip-permissions", "--allow-dangerously-skip-permissions"}
|
|
98
|
+
has_permission_mode = any(part in permission_flags or part.startswith("--permission-mode=") for part in configured)
|
|
99
|
+
access = ["--permission-mode", "bypassPermissions"] if bool(flags.get("agent_full_access", True)) and not has_permission_mode else []
|
|
100
|
+
return [*access, *configured]
|
|
101
|
+
|
|
102
|
+
def prepare_local(self, local_cwd: Path, flags: dict[str, Any]) -> object | None:
|
|
103
|
+
"""Export a resumable transcript and ensure any requested handoff is present before project sync."""
|
|
104
|
+
bundle: Path | None = None
|
|
105
|
+
if flags["session"]:
|
|
106
|
+
with ui.step("Exporting Claude session transcript"):
|
|
107
|
+
bundle = claude_state.export_session_bundle(local_cwd, Path(tempfile.mkdtemp(prefix="fwd-session-")))
|
|
108
|
+
if bundle is None:
|
|
109
|
+
flags["session"] = False
|
|
110
|
+
if flags["handoff"]:
|
|
111
|
+
ui.info("falling back to a handoff document")
|
|
112
|
+
if flags["handoff"]:
|
|
113
|
+
existing = fresh_handoff(local_cwd)
|
|
114
|
+
if existing is not None:
|
|
115
|
+
age = (time.time() - existing.stat().st_mtime) / 60
|
|
116
|
+
ui.info(f"reusing HANDOFF.md from {age:.0f} min ago (delete it to force regeneration)")
|
|
117
|
+
else:
|
|
118
|
+
with ui.step("Generating HANDOFF.md"):
|
|
119
|
+
claude_state.make_handoff(local_cwd)
|
|
120
|
+
return bundle
|
|
121
|
+
|
|
122
|
+
def prepare_remote(self, endpoint: SSHEndpoint, remote_dir: str, flags: dict[str, Any], local_state: object | None) -> dict[str, Any]:
|
|
123
|
+
"""Install requested state, import the transcript, and enable supported cross-device control."""
|
|
124
|
+
if flags["user_config"]:
|
|
125
|
+
with ui.step("Uploading Claude user config"):
|
|
126
|
+
claude_state.upload_user_config(endpoint)
|
|
127
|
+
if flags["creds"]:
|
|
128
|
+
creds_json: str | None = None
|
|
129
|
+
with ui.step("Copying Claude credentials"):
|
|
130
|
+
creds_json = claude_state.read_keychain_creds()
|
|
131
|
+
if creds_json:
|
|
132
|
+
claude_state.upload_creds(endpoint, creds_json)
|
|
133
|
+
if creds_json:
|
|
134
|
+
ui.warn("a live Claude token now exists on the remote machine at ~/.claude/.credentials.json (mode 600)")
|
|
135
|
+
else:
|
|
136
|
+
ui.warn("no local Claude credentials found; you will need to log in inside the remote session")
|
|
137
|
+
|
|
138
|
+
resume_id: str | None = None
|
|
139
|
+
if isinstance(local_state, Path):
|
|
140
|
+
with ui.step("Importing Claude session transcript"):
|
|
141
|
+
remote_home = endpoint.run('printf %s "$HOME"').stdout.strip() or f"/home/{endpoint.user}"
|
|
142
|
+
resume_id = claude_state.import_session_bundle(endpoint, local_state, remote_dir, remote_home)
|
|
143
|
+
if flags["session"] and not resume_id:
|
|
144
|
+
if flags["handoff"]:
|
|
145
|
+
ui.warn("could not install the transcript remotely; the session will start from HANDOFF.md instead")
|
|
146
|
+
else:
|
|
147
|
+
ui.warn(f"could not install the transcript remotely; starting a fresh session (try {ui.command('up --handoff')!r})")
|
|
148
|
+
remote_control_name: str | None = None
|
|
149
|
+
remote_control_status = _remote_control_status(endpoint)
|
|
150
|
+
if remote_control_status == 0:
|
|
151
|
+
remote_control_name = f"fwd: {Path(remote_dir).name}"
|
|
152
|
+
ui.info(f"Claude Remote Control enabled as {remote_control_name!r}")
|
|
153
|
+
elif remote_control_status == 2:
|
|
154
|
+
ui.info("Claude Remote Control is installed but requires a claude.ai Pro, Max, Team, or Enterprise login on the remote")
|
|
155
|
+
return {"resume_id": resume_id, "remote_control_name": remote_control_name}
|
|
156
|
+
|
|
157
|
+
def startup_command(self, flags: Mapping[str, object]) -> str:
|
|
158
|
+
"""Start Claude with the context chosen during this launch."""
|
|
159
|
+
resume_id = flags.get("resume_id")
|
|
160
|
+
command = build_command(
|
|
161
|
+
resume_id=resume_id if isinstance(resume_id, str) else None,
|
|
162
|
+
use_handoff=bool(flags.get("handoff")) and not resume_id,
|
|
163
|
+
remote_control_name=flags.get("remote_control_name") if isinstance(flags.get("remote_control_name"), str) else None,
|
|
164
|
+
runtime_args=tuple(self._runtime_args(flags)),
|
|
165
|
+
)
|
|
166
|
+
return self.with_environment_defaults(command, flags)
|
|
167
|
+
|
|
168
|
+
def send_command(self, message: str, flags: Mapping[str, object], *, tmux_session: str = "", remote_dir: str = "") -> tuple[str, ...]:
|
|
169
|
+
"""Send a streaming Claude turn, resuming the exact transferred conversation when known."""
|
|
170
|
+
del tmux_session, remote_dir
|
|
171
|
+
command = ["claude", *self._runtime_args(flags), "--print", "--verbose", "--output-format", "stream-json"]
|
|
172
|
+
resume_id = flags.get("resume_id")
|
|
173
|
+
if isinstance(resume_id, str) and resume_id:
|
|
174
|
+
command.extend(("--resume", resume_id))
|
|
175
|
+
else:
|
|
176
|
+
command.append("--continue")
|
|
177
|
+
command.append(message)
|
|
178
|
+
return self.environment_command(command, flags)
|