dryft 0.1.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.
dryft-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: dryft
3
+ Version: 0.1.1
4
+ Summary: Dryft CLI and daemon — sync agent context with Dryft server
5
+ Author: Dryft
6
+ License: MIT
7
+ Keywords: dryft,ai,agents,coordination,git
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: certifi
18
+
19
+ # Dryft Python client
20
+
21
+ Same CLI as the Node client, installable via **pip** per the PRD:
22
+
23
+ ```bash
24
+ pip install dryft
25
+ dryft init
26
+ dryft start
27
+ ```
28
+
29
+ From source (repo root):
30
+
31
+ ```bash
32
+ pip install -e ./client-py
33
+ ```
34
+
35
+ Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
dryft-0.1.1/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Dryft Python client
2
+
3
+ Same CLI as the Node client, installable via **pip** per the PRD:
4
+
5
+ ```bash
6
+ pip install dryft
7
+ dryft init
8
+ dryft start
9
+ ```
10
+
11
+ From source (repo root):
12
+
13
+ ```bash
14
+ pip install -e ./client-py
15
+ ```
16
+
17
+ Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dryft"
7
+ version = "0.1.1"
8
+ description = "Dryft CLI and daemon — sync agent context with Dryft server"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Dryft" }]
13
+ dependencies = ["certifi"]
14
+ keywords = ["dryft", "ai", "agents", "coordination", "git"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ ]
24
+
25
+ [project.scripts]
26
+ dryft = "dryft.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.setuptools.package-data]
32
+ dryft = ["templates/*.dryft"]
dryft-0.1.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Dryft CLI — sync agent context with Dryft server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,73 @@
1
+ """
2
+ HTTP client for Dryft server: POST /context, GET /context/:projectId, POST /push-notify.
3
+ """
4
+
5
+ import json
6
+ import ssl
7
+ import urllib.error
8
+ import urllib.request
9
+ from urllib.parse import quote
10
+
11
+ try:
12
+ import certifi
13
+ _ssl_context = ssl.create_default_context(cafile=certifi.where())
14
+ except ImportError:
15
+ _ssl_context = ssl.create_default_context()
16
+ # macOS Python often lacks system certs; fall back to unverified if needed
17
+ try:
18
+ urllib.request.urlopen("https://example.com", context=_ssl_context, timeout=5)
19
+ except ssl.SSLCertVerificationError:
20
+ _ssl_context = ssl.create_unverified_context()
21
+ except Exception:
22
+ pass
23
+
24
+
25
+ def _base(base_url: str, path: str) -> str:
26
+ base_url = base_url.rstrip("/")
27
+ path = path if path.startswith("/") else f"/{path}"
28
+ return f"{base_url}{path}"
29
+
30
+
31
+ def post_context(base_url: str, payload: dict) -> None:
32
+ url = _base(base_url, "/context")
33
+ data = json.dumps(payload).encode("utf-8")
34
+ req = urllib.request.Request(url, data=data, method="POST")
35
+ req.add_header("Content-Type", "application/json")
36
+ try:
37
+ with urllib.request.urlopen(req, context=_ssl_context) as res:
38
+ if res.status >= 400:
39
+ raise RuntimeError(f"POST /context failed: {res.status} {res.read().decode()}")
40
+ except urllib.error.HTTPError as e:
41
+ body = e.read().decode() if e.fp else ""
42
+ raise RuntimeError(f"POST /context failed: {e.code} {body}") from e
43
+ except urllib.error.URLError as e:
44
+ raise RuntimeError(f"POST /context failed: {e.reason}") from e
45
+
46
+
47
+ def get_context(base_url: str, project_id: str) -> dict:
48
+ url = _base(base_url, f"/context/{quote(project_id, safe='')}")
49
+ req = urllib.request.Request(url, method="GET")
50
+ try:
51
+ with urllib.request.urlopen(req, context=_ssl_context) as res:
52
+ return json.loads(res.read().decode())
53
+ except urllib.error.HTTPError as e:
54
+ body = e.read().decode() if e.fp else ""
55
+ raise RuntimeError(f"GET /context failed: {e.code} {body}") from e
56
+ except urllib.error.URLError as e:
57
+ raise RuntimeError(f"GET /context failed: {e.reason}") from e
58
+
59
+
60
+ def post_push_notify(base_url: str, body: dict) -> None:
61
+ url = _base(base_url, "/push-notify")
62
+ data = json.dumps(body).encode("utf-8")
63
+ req = urllib.request.Request(url, data=data, method="POST")
64
+ req.add_header("Content-Type", "application/json")
65
+ try:
66
+ with urllib.request.urlopen(req, context=_ssl_context) as res:
67
+ if res.status >= 400:
68
+ raise RuntimeError(f"POST /push-notify failed: {res.status} {res.read().decode()}")
69
+ except urllib.error.HTTPError as e:
70
+ body_text = e.read().decode() if e.fp else ""
71
+ raise RuntimeError(f"POST /push-notify failed: {e.code} {body_text}") from e
72
+ except urllib.error.URLError as e:
73
+ raise RuntimeError(f"POST /push-notify failed: {e.reason}") from e
@@ -0,0 +1,201 @@
1
+ """
2
+ Dryft CLI entry. Commands: init, start, stop, status, config, push-notify.
3
+ Matches PRD §6.1 and §12.1.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import api, config as config_mod
13
+ from .init_ import run_init
14
+ from . import daemon as daemon_mod
15
+
16
+
17
+ def _cwd(args: argparse.Namespace) -> str:
18
+ return getattr(args, "cwd", None) or os.getcwd()
19
+
20
+
21
+ def cmd_init(args: argparse.Namespace) -> int:
22
+ """Create .dryft/, context.md, intent.md, .gitignore; prompt for server URL and project id."""
23
+ try:
24
+ run_init(_cwd(args))
25
+ return 0
26
+ except SystemExit as e:
27
+ print(e, file=sys.stderr)
28
+ return 1
29
+
30
+
31
+ def cmd_start(args: argparse.Namespace) -> int:
32
+ """Start the background daemon (polling + sync). Use --foreground to run in terminal."""
33
+ try:
34
+ daemon_mod.start_daemon(
35
+ cwd=_cwd(args),
36
+ foreground=getattr(args, "foreground", False),
37
+ )
38
+ return 0
39
+ except SystemExit as e:
40
+ print(e, file=sys.stderr)
41
+ return 1
42
+
43
+
44
+ def cmd_stop(args: argparse.Namespace) -> int:
45
+ """Stop the daemon."""
46
+ daemon_mod.stop_daemon(_cwd(args))
47
+ return 0
48
+
49
+
50
+ def cmd_status(args: argparse.Namespace) -> int:
51
+ """Show current sync status and any warnings."""
52
+ cwd = _cwd(args)
53
+ cfg = config_mod.load_config(cwd)
54
+ if not cfg:
55
+ print("Run dryft init first.", file=sys.stderr)
56
+ return 1
57
+ last_sync_path = Path(cwd) / ".dryft" / "last_sync.json"
58
+ try:
59
+ data = json.loads(last_sync_path.read_text(encoding="utf-8"))
60
+ if data.get("last_sync"):
61
+ print("Last sync:", data["last_sync"])
62
+ if data.get("error"):
63
+ print("Last error:", data["error"])
64
+ except (OSError, json.JSONDecodeError):
65
+ print("No sync recorded yet. Run dryft start to sync.")
66
+ try:
67
+ state = api.get_context(cfg["server_url"], cfg["project_id"])
68
+ devs = state.get("developers") or {}
69
+ others = [k for k in devs if k != cfg["developer_id"]]
70
+ if devs:
71
+ print("Other developers:", ", ".join(others) or "none")
72
+ warnings = state.get("warnings") or []
73
+ if warnings:
74
+ print("Warnings:", len(warnings))
75
+ for w in warnings:
76
+ print(" -", w.get("detail") or f"{w.get('file', '?')}: {', '.join(w.get('developers') or [])}")
77
+ except Exception as e:
78
+ print("Could not fetch server state:", e)
79
+ return 0
80
+
81
+
82
+ def cmd_config(args: argparse.Namespace) -> int:
83
+ """View or edit configuration (server URL, developer id, poll interval)."""
84
+ cwd = _cwd(args)
85
+ cfg = config_mod.load_config(cwd)
86
+ if not cfg:
87
+ print("Run dryft init first.", file=sys.stderr)
88
+ return 1
89
+ sub = getattr(args, "config_args", []) or [] # e.g. ["set", "server_url", "http://..."]
90
+ if len(sub) >= 3 and sub[0] == "set" and sub[1] and sub[2] is not None:
91
+ key, value = sub[1], sub[2]
92
+ next_cfg = dict(cfg)
93
+ if key == "server_url":
94
+ next_cfg["server_url"] = value.rstrip("/")
95
+ elif key == "project_id":
96
+ next_cfg["project_id"] = value
97
+ elif key == "developer_id":
98
+ next_cfg["developer_id"] = value
99
+ elif key == "poll_interval_ms":
100
+ next_cfg["poll_interval_ms"] = int(value) or cfg.get("poll_interval_ms") or 60_000
101
+ else:
102
+ print("Unknown key. Use: server_url, project_id, developer_id, poll_interval_ms", file=sys.stderr)
103
+ return 1
104
+ config_mod.save_config(cwd, next_cfg)
105
+ print("Config updated.")
106
+ return 0
107
+ print("server_url:", cfg["server_url"])
108
+ print("project_id:", cfg["project_id"])
109
+ print("developer_id:", cfg["developer_id"])
110
+ print("poll_interval_ms:", cfg.get("poll_interval_ms") or 60_000)
111
+ return 0
112
+
113
+
114
+ def cmd_push_notify(args: argparse.Namespace) -> int:
115
+ """Manually notify the other developer that you've pushed."""
116
+ cwd = _cwd(args)
117
+ cfg = config_mod.load_config(cwd)
118
+ if not cfg:
119
+ print("Run dryft init first.", file=sys.stderr)
120
+ return 1
121
+ try:
122
+ api.post_push_notify(
123
+ cfg["server_url"],
124
+ {"developer": cfg["developer_id"], "project_id": cfg["project_id"]},
125
+ )
126
+ print("Push notification sent.")
127
+ return 0
128
+ except Exception as e:
129
+ print("Push notify failed:", e, file=sys.stderr)
130
+ return 1
131
+
132
+
133
+ def cmd_update(_args: argparse.Namespace) -> int:
134
+ """Upgrade Dryft client to the latest version (pip install --upgrade dryft)."""
135
+ import subprocess
136
+ print("Upgrading Dryft client to latest version...")
137
+ try:
138
+ code = subprocess.run(
139
+ [sys.executable, "-m", "pip", "install", "--upgrade", "dryft"],
140
+ check=False,
141
+ ).returncode
142
+ if code != 0:
143
+ print("Update failed. You can try manually: pip install --upgrade dryft", file=sys.stderr)
144
+ return 1
145
+ print("Dryft updated. Run 'dryft --help' to see commands.")
146
+ return 0
147
+ except FileNotFoundError:
148
+ print("Could not find pip. Try: pip install --upgrade dryft", file=sys.stderr)
149
+ return 1
150
+
151
+
152
+ def print_help(_args: argparse.Namespace) -> int:
153
+ """Print usage."""
154
+ help_text = """Dryft CLI (Stage 1)
155
+ Usage: dryft <command>
156
+
157
+ Commands:
158
+ init Initialize Dryft in the current repo
159
+ start Start the background daemon
160
+ stop Stop the daemon
161
+ status Show sync status and warnings
162
+ config View or edit config (server URL, developer id, poll interval)
163
+ push-notify Notify the other developer that you've pushed
164
+ update Upgrade Dryft client to the latest version (pip install --upgrade dryft)
165
+
166
+ Install: pip install dryft (or npm install -g dryft for Node client)
167
+ See REPO_STRUCTURE.md and the PRD for full details.
168
+ """
169
+ print(help_text)
170
+ return 0
171
+
172
+
173
+ def main() -> int:
174
+ parser = argparse.ArgumentParser(prog="dryft", description="Dryft — agent context sync")
175
+ parser.add_argument("--cwd", default=None, help="Working directory (default: current)")
176
+ subparsers = parser.add_subparsers(dest="command", help="Command")
177
+
178
+ for name, fn in [
179
+ ("init", cmd_init),
180
+ ("start", cmd_start),
181
+ ("stop", cmd_stop),
182
+ ("status", cmd_status),
183
+ ("config", cmd_config),
184
+ ("push-notify", cmd_push_notify),
185
+ ("update", cmd_update),
186
+ ]:
187
+ sub = subparsers.add_parser(name)
188
+ sub.set_defaults(func=fn)
189
+ if name == "start":
190
+ sub.add_argument("--foreground", action="store_true", help="Run daemon in foreground")
191
+ elif name == "config":
192
+ sub.add_argument("config_args", nargs="*", help="get | set key value")
193
+
194
+ args = parser.parse_args()
195
+ if getattr(args, "func", None) is None:
196
+ return print_help(args)
197
+ return args.func(args)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,55 @@
1
+ """
2
+ Load/save .dryft/config.json and ensure .dryft directory exists.
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ # PRD §8.3: default 5 seconds for near-real-time awareness
9
+ DEFAULT_POLL_INTERVAL_MS = 5_000
10
+ CONFIG_FILENAME = "config.json"
11
+ DRYFT_DIR = ".dryft"
12
+
13
+
14
+ def _config_path(cwd: str) -> Path:
15
+ return Path(cwd) / DRYFT_DIR / CONFIG_FILENAME
16
+
17
+
18
+ def ensure_dryft_dir(cwd: str) -> str:
19
+ dir_path = Path(cwd) / DRYFT_DIR
20
+ dir_path.mkdir(parents=True, exist_ok=True)
21
+ return str(dir_path)
22
+
23
+
24
+ def load_config(cwd: str) -> dict | None:
25
+ path = _config_path(cwd)
26
+ try:
27
+ raw = path.read_text(encoding="utf-8")
28
+ data = json.loads(raw)
29
+ if not data or not isinstance(data, dict):
30
+ return None
31
+ if not all(k in data and isinstance(data.get(k), str) for k in ("server_url", "project_id", "developer_id")):
32
+ return None
33
+ poll = data.get("poll_interval_ms")
34
+ if not isinstance(poll, (int, float)) or poll <= 0:
35
+ poll = DEFAULT_POLL_INTERVAL_MS
36
+ return {
37
+ "server_url": (data["server_url"] or "").rstrip("/"),
38
+ "project_id": data["project_id"],
39
+ "developer_id": data["developer_id"],
40
+ "poll_interval_ms": int(poll),
41
+ }
42
+ except (OSError, json.JSONDecodeError):
43
+ return None
44
+
45
+
46
+ def save_config(cwd: str, config: dict) -> None:
47
+ ensure_dryft_dir(cwd)
48
+ path = _config_path(cwd)
49
+ data = {
50
+ "server_url": config["server_url"],
51
+ "project_id": config["project_id"],
52
+ "developer_id": config["developer_id"],
53
+ "poll_interval_ms": config.get("poll_interval_ms") or DEFAULT_POLL_INTERVAL_MS,
54
+ }
55
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
@@ -0,0 +1,176 @@
1
+ """
2
+ Build context payload for POST and render/write .dryft/context.md from GET response.
3
+ PRD §7.1: template with Last synced, Project, Last Activity, Guidelines, severity; §7.4: ~200 line cap.
4
+ """
5
+
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ CONTEXT_LINE_CAP = 200 # PRD §7.4, §17.4
11
+
12
+ GUIDELINES_BLOCK = """
13
+ ## Guidelines for AI Agent
14
+
15
+ ### Before Starting Any Task
16
+ - Read this entire file.
17
+ - Check if any files you plan to modify are listed under "Currently Active Changes" or "Files They Plan to Modify Next."
18
+ - If overlap exists, inform your developer immediately before proceeding.
19
+
20
+ ### If You Share a File With the Other Agent
21
+ - Read "What they changed" to understand exactly which functions/sections they are modifying.
22
+ - Work in DIFFERENT functions or sections of the same file where possible.
23
+ - If you must modify the SAME function they are working on, STOP and inform your developer — this is a high-conflict risk.
24
+ - Do not change function signatures, interfaces, or exports that the other agent's code depends on.
25
+
26
+ ### During Work
27
+ - If this file updates with a new warning, pause and inform your developer.
28
+ - If the other developer completes changes to files you depend on, suggest pulling latest.
29
+ - Keep your .dryft/intent.md updated after every file modification so the other agent can see your progress.
30
+ """.strip()
31
+
32
+
33
+ def _time_ago(iso: str) -> str:
34
+ try:
35
+ then = datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
36
+ sec = int(datetime.now(timezone.utc).timestamp() - then)
37
+ if sec < 60:
38
+ return "just now"
39
+ if sec < 3600:
40
+ return f"{sec // 60} min ago"
41
+ if sec < 86400:
42
+ return f"{sec // 3600} h ago"
43
+ return f"{sec // 86400} d ago"
44
+ except (ValueError, TypeError):
45
+ return ""
46
+
47
+
48
+ def build_payload(
49
+ developer: str,
50
+ git_changes: list[dict],
51
+ intent: dict | None,
52
+ project_id: str | None = None,
53
+ ) -> dict:
54
+ active_changes = [
55
+ {
56
+ "file": c["file"],
57
+ "functions_modified": c.get("functionsModified", []),
58
+ "change_type": c.get("changeType", "modification"),
59
+ }
60
+ for c in git_changes
61
+ ]
62
+ payload: dict[str, Any] = {
63
+ "developer": developer,
64
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
65
+ "active_changes": active_changes,
66
+ }
67
+ if intent and intent.get("dependencies"):
68
+ payload["dependencies_affected"] = intent["dependencies"]
69
+ if intent:
70
+ payload["intent"] = {}
71
+ if intent.get("plannedFiles"):
72
+ payload["intent"]["planned_files"] = intent["plannedFiles"]
73
+ if intent.get("currentTask"):
74
+ payload["intent"]["current_task"] = intent["currentTask"]
75
+ if intent.get("dependencies"):
76
+ payload["intent"]["dependencies"] = intent["dependencies"]
77
+ if project_id:
78
+ payload["project_id"] = project_id
79
+ return payload
80
+
81
+
82
+ def render_context_md(server_state: dict, my_developer_id: str) -> str:
83
+ developers = server_state.get("developers") or {}
84
+ warnings = server_state.get("warnings") or []
85
+ now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
86
+ project_id = server_state.get("project_id") or ""
87
+
88
+ lines = [
89
+ "# Dryft — Shared Development Context",
90
+ "",
91
+ f"> Last synced: {now}",
92
+ ]
93
+ if project_id:
94
+ lines.append(f"> Project: {project_id}")
95
+ lines.append("")
96
+
97
+ others = [(k, v) for k, v in developers.items() if k != my_developer_id]
98
+ if not others:
99
+ lines.append("> No other developers in context yet.")
100
+ else:
101
+ for dev_id, dev in others:
102
+ lines.append(f"## Other Developer Activity ({dev_id})")
103
+ lines.append("")
104
+ if dev.get("last_update"):
105
+ ago = _time_ago(dev["last_update"])
106
+ if ago:
107
+ lines.append("### Last Activity")
108
+ lines.append("")
109
+ lines.append(f"{dev_id} last active — {ago}")
110
+ lines.append("")
111
+ planned = dev.get("intent") or {}
112
+ if planned.get("current_task"):
113
+ lines.append("### Current Task")
114
+ lines.append("")
115
+ lines.append(str(planned["current_task"]))
116
+ lines.append("")
117
+ changes = dev.get("active_changes") or []
118
+ if changes:
119
+ lines.append("### Currently Active Changes")
120
+ lines.append("")
121
+ for c in changes:
122
+ funcs = c.get("functions_modified") or []
123
+ func_str = f" — {', '.join(funcs)}" if funcs else ""
124
+ lines.append(f"- **{c.get('file', '?')}** — {c.get('change_type', 'change')}{func_str}")
125
+ if c.get("summary"):
126
+ lines.append(f" - Why: {c['summary']}")
127
+ lines.append("")
128
+ planned_files = planned.get("planned_files") or []
129
+ if planned_files:
130
+ lines.append("### Files They Plan to Modify Next")
131
+ lines.append("")
132
+ for f in planned_files:
133
+ lines.append(f"- {f}")
134
+ lines.append("")
135
+ deps = dev.get("dependencies_affected") or []
136
+ if deps:
137
+ lines.append("### Dependencies Affected")
138
+ lines.append("")
139
+ for d in deps:
140
+ lines.append(f"- {d}")
141
+ lines.append("")
142
+ if warnings:
143
+ lines.append("---")
144
+ lines.append("")
145
+ lines.append("## Warnings")
146
+ lines.append("")
147
+ for w in warnings:
148
+ severity = "WARNING" if w.get("type") == "overlap" else "INFO"
149
+ detail = w.get("detail") or ", ".join(w.get("developers") or [])
150
+ lines.append(f"- **{severity}** — {w.get('file', '?')}: {detail}")
151
+ lines.append("")
152
+ last_push = server_state.get("last_push")
153
+ if last_push and last_push.get("at"):
154
+ lines.append("---")
155
+ lines.append("")
156
+ dev = last_push.get("developer")
157
+ lines.append(f"**Last push:** {last_push['at']}" + (f" by {dev}" if dev else ""))
158
+ lines.append("")
159
+ lines.append("---")
160
+ lines.append("")
161
+ lines.append(GUIDELINES_BLOCK)
162
+
163
+ out = "\n".join(lines)
164
+ line_list = out.split("\n")
165
+ if len(line_list) > CONTEXT_LINE_CAP:
166
+ kept = line_list[: CONTEXT_LINE_CAP - 2]
167
+ kept.append("")
168
+ kept.append("... (context truncated to 200 lines)")
169
+ return "\n".join(kept)
170
+ return out
171
+
172
+
173
+ def write_context_file(cwd: str, content: str) -> None:
174
+ path = Path(cwd) / ".dryft" / "context.md"
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ path.write_text(content, encoding="utf-8")
@@ -0,0 +1,215 @@
1
+ """
2
+ Background daemon: polling loop and sync cycle.
3
+ Every N seconds: git detection + intent read → POST context → GET state → write context.md.
4
+ PRD §6.2/§6.3: terminal notifications for overlap warnings and push.
5
+ Phase 4: PID file for daemon lifecycle; stop kills the background process.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .api import get_context, post_context
18
+ from .config import load_config
19
+ from .context import build_payload, render_context_md, write_context_file
20
+ from .git import detect_changes
21
+ from .intent import read_intent
22
+
23
+ LAST_SYNC_FILENAME = Path(".dryft") / "last_sync.json"
24
+ DAEMON_PID_FILENAME = Path(".dryft") / "daemon.pid"
25
+
26
+
27
+ def _read_last_sync(cwd: str) -> dict:
28
+ path = Path(cwd) / LAST_SYNC_FILENAME
29
+ try:
30
+ data = json.loads(path.read_text(encoding="utf-8"))
31
+ return data if isinstance(data, dict) else {}
32
+ except (OSError, json.JSONDecodeError):
33
+ return {}
34
+
35
+
36
+ def _run_sync(cwd: str) -> None:
37
+ config = load_config(cwd)
38
+ if not config:
39
+ return
40
+ previous = _read_last_sync(cwd)
41
+ git_changes = detect_changes(cwd)
42
+ intent = read_intent(cwd)
43
+ payload = build_payload(
44
+ config["developer_id"],
45
+ git_changes,
46
+ intent,
47
+ config.get("project_id"),
48
+ )
49
+ post_context(config["server_url"], payload)
50
+ state = get_context(config["server_url"], config["project_id"])
51
+ markdown = render_context_md(state, config["developer_id"])
52
+ write_context_file(cwd, markdown)
53
+
54
+ # PRD §6.3: terminal notification when overlap detected
55
+ warnings = state.get("warnings") or []
56
+ for w in warnings:
57
+ f = w.get("file", "?")
58
+ detail = w.get("detail", "")
59
+ print(f"⚠ Overlap detected on {f}: {detail}", file=sys.stderr)
60
+
61
+ # PRD §6.2 Step 9 / §10.3: terminal notification when other dev pushed
62
+ last_push: dict[str, Any] = state.get("last_push") or {}
63
+ if last_push.get("at") and last_push.get("developer") != config["developer_id"]:
64
+ seen = previous.get("last_push_seen") or {}
65
+ is_new = seen.get("at") != last_push.get("at") or seen.get("developer") != last_push.get("developer")
66
+ if is_new:
67
+ print(f"{last_push.get('developer')} pushed changes. Consider pulling before continuing.", file=sys.stderr)
68
+
69
+ next_data: dict[str, Any] = {
70
+ "last_sync": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
71
+ "error": None,
72
+ }
73
+ if last_push.get("at"):
74
+ next_data["last_push_seen"] = {"at": last_push["at"], "developer": last_push.get("developer")}
75
+ else:
76
+ next_data["last_push_seen"] = previous.get("last_push_seen")
77
+
78
+ last_sync_path = Path(cwd) / LAST_SYNC_FILENAME
79
+ last_sync_path.write_text(json.dumps(next_data, indent=2), encoding="utf-8")
80
+
81
+
82
+ def _run_sync_and_log(cwd: str) -> None:
83
+ last_sync_path = Path(cwd) / LAST_SYNC_FILENAME
84
+ try:
85
+ _run_sync(cwd)
86
+ except Exception as e:
87
+ msg = str(e)
88
+ try:
89
+ last_sync_path.write_text(
90
+ json.dumps(
91
+ {"last_sync": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "error": msg},
92
+ indent=2,
93
+ ),
94
+ encoding="utf-8",
95
+ )
96
+ except OSError:
97
+ pass
98
+ print("Dryft sync error:", msg, file=sys.stderr)
99
+
100
+
101
+ def _pid_path(cwd: str) -> Path:
102
+ return Path(cwd) / DAEMON_PID_FILENAME
103
+
104
+
105
+ def _remove_pid_file(cwd: str) -> None:
106
+ try:
107
+ _pid_path(cwd).unlink()
108
+ except OSError:
109
+ pass
110
+
111
+
112
+ def run_daemon_loop(
113
+ *,
114
+ cwd: str | None = None,
115
+ poll_interval_ms: int | None = None,
116
+ ) -> None:
117
+ """Run the sync loop in the current process (used by --foreground)."""
118
+ import time
119
+
120
+ cwd = cwd or str(Path.cwd())
121
+ if isinstance(cwd, Path):
122
+ cwd = str(cwd)
123
+ config = load_config(cwd)
124
+ if not config:
125
+ raise SystemExit("Run dryft init first.")
126
+ interval_ms = poll_interval_ms or config.get("poll_interval_ms") or 5_000
127
+ interval_sec = interval_ms / 1000.0
128
+
129
+ def shutdown(*_args: object) -> None:
130
+ _remove_pid_file(cwd)
131
+ sys.exit(0)
132
+
133
+ signal.signal(signal.SIGINT, shutdown)
134
+ signal.signal(signal.SIGTERM, shutdown)
135
+
136
+ _run_sync_and_log(cwd)
137
+ print(f"Dryft daemon running. Syncing every {interval_sec:.0f} s. Ctrl+C to stop.")
138
+ try:
139
+ while True:
140
+ time.sleep(interval_sec)
141
+ _run_sync_and_log(cwd)
142
+ except KeyboardInterrupt:
143
+ shutdown()
144
+
145
+
146
+ def start_daemon(
147
+ *,
148
+ cwd: str | None = None,
149
+ poll_interval_ms: int | None = None,
150
+ foreground: bool = False,
151
+ ) -> None:
152
+ cwd = cwd or str(Path.cwd())
153
+ if isinstance(cwd, Path):
154
+ cwd = str(cwd)
155
+
156
+ if foreground:
157
+ run_daemon_loop(cwd=cwd, poll_interval_ms=poll_interval_ms)
158
+ return
159
+
160
+ config = load_config(cwd)
161
+ if not config:
162
+ raise SystemExit("Run dryft init first.")
163
+
164
+ pid_path = _pid_path(cwd)
165
+ if pid_path.exists():
166
+ try:
167
+ existing_pid = int(pid_path.read_text().strip())
168
+ os.kill(existing_pid, 0)
169
+ print("Daemon already running (PID", existing_pid, "). Use 'dryft stop' first.", file=sys.stderr)
170
+ sys.exit(1)
171
+ except (ValueError, ProcessLookupError, OSError):
172
+ pid_path.unlink(missing_ok=True)
173
+
174
+ proc = subprocess.Popen(
175
+ [sys.executable, "-m", "dryft.cli", "start", "--foreground"],
176
+ cwd=cwd,
177
+ start_new_session=True,
178
+ stdout=subprocess.DEVNULL,
179
+ stderr=subprocess.DEVNULL,
180
+ )
181
+ if proc.pid is not None:
182
+ pid_path.parent.mkdir(parents=True, exist_ok=True)
183
+ pid_path.write_text(str(proc.pid), encoding="utf-8")
184
+ print("Dryft daemon started in background (PID", proc.pid, "). Use 'dryft stop' to stop.")
185
+ else:
186
+ print("Failed to start daemon.", file=sys.stderr)
187
+ sys.exit(1)
188
+
189
+
190
+ def stop_daemon(cwd: str | None = None) -> None:
191
+ cwd = cwd or str(Path.cwd())
192
+ if isinstance(cwd, Path):
193
+ cwd = str(cwd)
194
+ pid_path = _pid_path(cwd)
195
+ try:
196
+ pid_str = pid_path.read_text(encoding="utf-8").strip()
197
+ except OSError:
198
+ print("Daemon not running (no PID file).")
199
+ return
200
+ try:
201
+ pid = int(pid_str)
202
+ except ValueError:
203
+ print("Daemon not running (invalid PID file).")
204
+ pid_path.unlink(missing_ok=True)
205
+ return
206
+ try:
207
+ os.kill(pid, signal.SIGTERM)
208
+ pid_path.unlink(missing_ok=True)
209
+ print("Dryft daemon stopped (PID", pid, ").")
210
+ except ProcessLookupError:
211
+ pid_path.unlink(missing_ok=True)
212
+ print("Daemon was not running (stale PID file removed).")
213
+ except OSError as e:
214
+ print("Could not stop daemon:", e, file=sys.stderr)
215
+ sys.exit(1)
@@ -0,0 +1,87 @@
1
+ """
2
+ Git-based change detection: git status, git diff, function/class extraction from diffs.
3
+ """
4
+
5
+ import re
6
+ import subprocess
7
+ from typing import TypedDict
8
+
9
+
10
+ class GitChange(TypedDict):
11
+ file: str
12
+ changeType: str
13
+ functionsModified: list[str]
14
+
15
+
16
+ FUNCTION_REGEXES = [
17
+ re.compile(r"^\s*(?:function|async\s+function)\s+(\w+)\s*\("),
18
+ re.compile(r"^\s*class\s+(\w+)"),
19
+ re.compile(r"^\s*def\s+(\w+)\s*\("),
20
+ re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\("),
21
+ ]
22
+
23
+
24
+ def _extract_functions_from_diff_hunk(hunk_lines: list[str]) -> list[str]:
25
+ names: set[str] = set()
26
+ for line in hunk_lines:
27
+ if line.startswith("+") and not line.startswith("+++"):
28
+ content = line[1:]
29
+ for pattern in FUNCTION_REGEXES:
30
+ m = pattern.search(content)
31
+ if m and m.lastindex:
32
+ names.add(m.group(1))
33
+ return list(names)
34
+
35
+
36
+ def _get_change_type(x: str, y: str) -> str:
37
+ if x == "D" or y == "D":
38
+ return "deleted"
39
+ if x == "A" or x == "?" or y == "?":
40
+ return "new_file"
41
+ return "modification"
42
+
43
+
44
+ def detect_changes(cwd: str) -> list[GitChange]:
45
+ try:
46
+ r = subprocess.run(
47
+ ["git", "status", "--porcelain"],
48
+ capture_output=True,
49
+ text=True,
50
+ cwd=cwd,
51
+ )
52
+ if r.returncode != 0:
53
+ return []
54
+ out = r.stdout or ""
55
+ except (subprocess.SubprocessError, FileNotFoundError):
56
+ return []
57
+ lines = [s for s in out.strip().split("\n") if s]
58
+ results: list[GitChange] = []
59
+ for line in lines:
60
+ xy = line[:2]
61
+ file_path = line[3:].strip()
62
+ if not file_path:
63
+ continue
64
+ x, y = xy[0], xy[1]
65
+ change_type = _get_change_type(x, y)
66
+ functions_modified: list[str] = []
67
+ try:
68
+ r = subprocess.run(
69
+ ["git", "diff", "HEAD", "--", file_path],
70
+ capture_output=True,
71
+ text=True,
72
+ cwd=cwd,
73
+ )
74
+ diff_out = r.stdout or ""
75
+ added_lines = [l for l in diff_out.split("\n") if l.startswith("+")]
76
+ functions_modified = _extract_functions_from_diff_hunk(added_lines)
77
+ if not functions_modified and change_type in ("modification", "new_file"):
78
+ functions_modified = ["(whole file)"]
79
+ except (subprocess.SubprocessError, FileNotFoundError):
80
+ if change_type == "new_file":
81
+ functions_modified = ["(whole file)"]
82
+ results.append({
83
+ "file": file_path,
84
+ "changeType": change_type,
85
+ "functionsModified": functions_modified,
86
+ })
87
+ return results
@@ -0,0 +1,141 @@
1
+ """
2
+ dryft init: create .dryft/, context.md, intent.md, .gitignore, inject agent templates, save config.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from .config import ensure_dryft_dir, save_config
10
+
11
+ DRYFT_DIR = ".dryft"
12
+ DRYFT_BLOCK_MARKER = "## Dryft Integration"
13
+
14
+
15
+ def _read_template(name: str) -> str:
16
+ from importlib.resources import files
17
+ raw = files("dryft").joinpath("templates", name).read_text(encoding="utf-8")
18
+ return _strip_comment_first_line(raw).strip()
19
+
20
+
21
+ def _strip_comment_first_line(content: str) -> str:
22
+ trimmed = content.lstrip()
23
+ if trimmed.startswith("<!--") and "-->" in trimmed:
24
+ after = trimmed[trimmed.index("-->") + 3 :].lstrip()
25
+ return after
26
+ return trimmed
27
+
28
+
29
+ def _inject_into_file(cwd: str, target_filename: str, block: str) -> Literal["injected", "skipped", "created"]:
30
+ target = Path(cwd) / target_filename
31
+ existing = ""
32
+ try:
33
+ existing = target.read_text(encoding="utf-8")
34
+ except OSError:
35
+ pass
36
+ if DRYFT_BLOCK_MARKER in existing:
37
+ return "skipped"
38
+ new_content = f"{existing.rstrip()}\n\n{block}" if existing else block
39
+ target.write_text(new_content, encoding="utf-8")
40
+ return "injected" if existing else "created"
41
+
42
+
43
+ INITIAL_CONTEXT_MD = """# Dryft — Shared Development Context
44
+
45
+ > Not yet synced.
46
+ """
47
+
48
+ INTENT_TEMPLATE = """# Intent
49
+
50
+ Agent-declared plan and active work. Update this file so the other developer's agent can avoid overlap.
51
+
52
+ **Agent:** Update the sections below when you start a task, when you change files, and when you finish a task.
53
+
54
+ ## Current Task
55
+
56
+ (Describe what you're working on.)
57
+
58
+ ## Planned Files
59
+
60
+ (Files you plan to modify next — one per line or bullet.)
61
+
62
+ ## Dependencies
63
+
64
+ (Files or modules your work depends on.)
65
+
66
+ ## Active Changes
67
+
68
+ (After modifying files: list functions/sections you changed.)
69
+
70
+ ## Notes
71
+
72
+ (Optional.)
73
+ """
74
+
75
+
76
+ def _get_default_developer_id() -> str:
77
+ return (
78
+ os.environ.get("DRYFT_DEVELOPER_ID")
79
+ or os.environ.get("USER")
80
+ or os.environ.get("USERNAME")
81
+ or (os.getlogin() if hasattr(os, "getlogin") else "developer")
82
+ )
83
+
84
+
85
+ def run_init(cwd: str) -> None:
86
+ ensure_dryft_dir(cwd)
87
+ base = Path(cwd) / DRYFT_DIR
88
+
89
+ (base / "context.md").write_text(INITIAL_CONTEXT_MD, encoding="utf-8")
90
+ (base / "intent.md").write_text(INTENT_TEMPLATE, encoding="utf-8")
91
+
92
+ gitignore = Path(cwd) / ".gitignore"
93
+ try:
94
+ content = gitignore.read_text(encoding="utf-8")
95
+ if ".dryft" not in content:
96
+ addition = "\n.dryft/\n" if content.endswith("\n") else "\n.dryft/\n"
97
+ gitignore.write_text(content + addition, encoding="utf-8")
98
+ except FileNotFoundError:
99
+ gitignore.write_text(".dryft/\n", encoding="utf-8")
100
+
101
+ env_url = (os.environ.get("DRYFT_SERVER_URL") or "").strip()
102
+ env_project = (os.environ.get("DRYFT_PROJECT_ID") or "").strip()
103
+ default_dev = _get_default_developer_id()
104
+ if env_url and env_project:
105
+ server_url = env_url.rstrip("/")
106
+ project_id = env_project
107
+ developer_id = (os.environ.get("DRYFT_DEVELOPER_ID") or "").strip() or default_dev
108
+ else:
109
+ server_url = (
110
+ input("Server URL (e.g. https://your-app.railway.app) [{}]: ".format(env_url)).strip() or env_url or ""
111
+ ).rstrip("/")
112
+ project_id = input("Project ID [{}]: ".format(env_project)).strip() or env_project or ""
113
+ developer_id = (
114
+ input("Your developer ID [{}]: ".format(default_dev)).strip() or default_dev
115
+ )
116
+
117
+ if not server_url or not project_id:
118
+ raise SystemExit("Server URL and Project ID are required. Re-run dryft config to set them.")
119
+
120
+ config = {
121
+ "server_url": server_url.rstrip("/"),
122
+ "project_id": project_id,
123
+ "developer_id": developer_id or _get_default_developer_id(),
124
+ "poll_interval_ms": 5_000,
125
+ }
126
+ save_config(cwd, config)
127
+
128
+ claude_block = _read_template("CLAUDE.md.dryft")
129
+ cursorrules_block = _read_template("cursorrules.dryft")
130
+ claude_result = _inject_into_file(cwd, "CLAUDE.md", claude_block)
131
+ cursor_result = _inject_into_file(cwd, ".cursorrules", cursorrules_block)
132
+
133
+ print("Dryft initialized in", base)
134
+ if claude_result in ("injected", "created"):
135
+ print("Dryft block added to CLAUDE.md")
136
+ elif claude_result == "skipped":
137
+ print("CLAUDE.md already contains Dryft integration (unchanged).")
138
+ if cursor_result in ("injected", "created"):
139
+ print("Dryft block added to .cursorrules")
140
+ elif cursor_result == "skipped":
141
+ print(".cursorrules already contains Dryft integration (unchanged).")
@@ -0,0 +1,62 @@
1
+ """
2
+ Read and parse .dryft/intent.md (agent-declared plan, expected files, dependencies).
3
+ """
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import TypedDict
8
+
9
+ INTENT_FILENAME = Path(".dryft") / "intent.md"
10
+
11
+
12
+ def _parse_list_lines(text: str) -> list[str]:
13
+ out = []
14
+ for line in text.split("\n"):
15
+ line = re.sub(r"^\s*[-*]\s+", "", re.sub(r"^\s*\d+\.\s*", "", line)).strip()
16
+ if line:
17
+ out.append(line)
18
+ return out
19
+
20
+
21
+ def _extract_section(content: str, heading: str) -> str:
22
+ pattern = re.compile(
23
+ r"##\s+" + re.escape(heading) + r"\s*\n([\s\S]*?)(?=##\s|$)",
24
+ re.IGNORECASE,
25
+ )
26
+ m = pattern.search(content)
27
+ return m.group(1).strip() if m else ""
28
+
29
+
30
+ class IntentData(TypedDict, total=False):
31
+ currentTask: str
32
+ plannedFiles: list[str]
33
+ dependencies: list[str]
34
+ activeChanges: str
35
+ notes: str
36
+
37
+
38
+ def read_intent(cwd: str) -> IntentData | None:
39
+ path = Path(cwd) / INTENT_FILENAME
40
+ try:
41
+ content = path.read_text(encoding="utf-8")
42
+ except OSError:
43
+ return None
44
+ current_task = _extract_section(content, "Current Task")
45
+ planned_files_text = _extract_section(content, "Planned Files")
46
+ dependencies_text = _extract_section(content, "Dependencies")
47
+ active_changes = _extract_section(content, "Active Changes")
48
+ notes = _extract_section(content, "Notes")
49
+ planned = _parse_list_lines(planned_files_text)
50
+ deps = _parse_list_lines(dependencies_text)
51
+ out: IntentData = {}
52
+ if current_task:
53
+ out["currentTask"] = current_task
54
+ if planned:
55
+ out["plannedFiles"] = planned
56
+ if deps:
57
+ out["dependencies"] = deps
58
+ if active_changes:
59
+ out["activeChanges"] = active_changes
60
+ if notes:
61
+ out["notes"] = notes
62
+ return out if out else None
@@ -0,0 +1,30 @@
1
+ <!-- Insert this block into your project's CLAUDE.md for Dryft integration. -->
2
+
3
+ ## Dryft Integration
4
+
5
+ **CRITICAL:** You MUST read .dryft/context.md before starting and MUST update .dryft/intent.md after any file change and when a task is complete. Do not skip these steps.
6
+
7
+ BEFORE starting any task:
8
+ 1. Read .dryft/context.md to understand what the other developer's AI is working on.
9
+ 2. If your task is the same or equivalent to another developer's task in context.md (their "Current Task" or work they list in "Currently Active Changes" / "Files They Plan to Modify Next" / or marked "Complete"): (a) Check whether the files, directories, or functions that work would touch exist locally. (b) If they exist locally — do NOT redo the work; tell your developer the task appears already done. (c) If they do NOT exist locally — do NOT implement. Dryft is preventing duplicate work: the changes are completed elsewhere but not on your branch. Tell your developer: "The other developer's work for this task is marked complete, but the relevant files don't exist on your local branch yet. Update your local branch (e.g. git pull) to get their changes, then you can proceed or ask me again."
10
+ 3. Check "Currently Active Changes" AND "Files They Plan to Modify Next" for overlap with your planned files.
11
+ 4. If overlap exists, tell your developer immediately before proceeding.
12
+ 5. Write your plan to .dryft/intent.md — include your task, plan, expected files, and dependencies.
13
+
14
+ AFTER modifying any file:
15
+ 1. Update .dryft/intent.md "Active Changes" section with what you changed — functions added/modified, exports changed, interfaces altered.
16
+ 2. This is critical — the other agent relies on this to avoid conflicts.
17
+
18
+ IF you share a file with the other agent:
19
+ 1. Read context.md to see exactly WHICH functions/sections they are modifying.
20
+ 2. Work in DIFFERENT functions or sections of the same file.
21
+ 3. If you must modify the SAME function, STOP and tell your developer — this is a merge conflict risk.
22
+ 4. Do NOT change function signatures, interfaces, or exports that the other agent depends on.
23
+
24
+ DURING work:
25
+ 1. Re-read .dryft/context.md periodically (at least before each new file modification).
26
+ 2. If context.md shows a new warning, pause and inform your developer.
27
+ 3. If the other developer completes changes to files you depend on, suggest pulling latest.
28
+
29
+ AFTER completing a task:
30
+ 1. Update .dryft/intent.md to mark the task as complete.
@@ -0,0 +1,30 @@
1
+ <!-- Insert this block into your project's .cursorrules for Dryft integration. -->
2
+
3
+ ## Dryft Integration
4
+
5
+ **CRITICAL:** You MUST read .dryft/context.md before starting and MUST update .dryft/intent.md after any file change and when a task is complete. Do not skip these steps.
6
+
7
+ BEFORE starting any task:
8
+ 1. Read .dryft/context.md to understand what the other developer's AI is working on.
9
+ 2. If your task is the same or equivalent to another developer's task in context.md (their "Current Task" or work they list in "Currently Active Changes" / "Files They Plan to Modify Next" / or marked "Complete"): (a) Check whether the files, directories, or functions that work would touch exist locally. (b) If they exist locally — do NOT redo the work; tell your developer the task appears already done. (c) If they do NOT exist locally — do NOT implement. Dryft is preventing duplicate work: the changes are completed elsewhere but not on your branch. Tell your developer: "The other developer's work for this task is marked complete, but the relevant files don't exist on your local branch yet. Update your local branch (e.g. git pull) to get their changes, then you can proceed or ask me again."
10
+ 3. Check "Currently Active Changes" AND "Files They Plan to Modify Next" for overlap with your planned files.
11
+ 4. If overlap exists, tell your developer immediately before proceeding.
12
+ 5. Write your plan to .dryft/intent.md — include your task, plan, expected files, and dependencies.
13
+
14
+ AFTER modifying any file:
15
+ 1. Update .dryft/intent.md "Active Changes" section with what you changed — functions added/modified, exports changed, interfaces altered.
16
+ 2. This is critical — the other agent relies on this to avoid conflicts.
17
+
18
+ IF you share a file with the other agent:
19
+ 1. Read context.md to see exactly WHICH functions/sections they are modifying.
20
+ 2. Work in DIFFERENT functions or sections of the same file.
21
+ 3. If you must modify the SAME function, STOP and tell your developer — this is a merge conflict risk.
22
+ 4. Do NOT change function signatures, interfaces, or exports that the other agent depends on.
23
+
24
+ DURING work:
25
+ 1. Re-read .dryft/context.md periodically (at least before each new file modification).
26
+ 2. If context.md shows a new warning, pause and inform your developer.
27
+ 3. If the other developer completes changes to files you depend on, suggest pulling latest.
28
+
29
+ AFTER completing a task:
30
+ 1. Update .dryft/intent.md to mark the task as complete.
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: dryft
3
+ Version: 0.1.1
4
+ Summary: Dryft CLI and daemon — sync agent context with Dryft server
5
+ Author: Dryft
6
+ License: MIT
7
+ Keywords: dryft,ai,agents,coordination,git
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: certifi
18
+
19
+ # Dryft Python client
20
+
21
+ Same CLI as the Node client, installable via **pip** per the PRD:
22
+
23
+ ```bash
24
+ pip install dryft
25
+ dryft init
26
+ dryft start
27
+ ```
28
+
29
+ From source (repo root):
30
+
31
+ ```bash
32
+ pip install -e ./client-py
33
+ ```
34
+
35
+ Commands: `init`, `start`, `stop`, `status`, `config`, `push-notify`. Implementation follows the same phases as the Node client in `client/`.
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/dryft/__init__.py
4
+ src/dryft/api.py
5
+ src/dryft/cli.py
6
+ src/dryft/config.py
7
+ src/dryft/context.py
8
+ src/dryft/daemon.py
9
+ src/dryft/git.py
10
+ src/dryft/init_.py
11
+ src/dryft/intent.py
12
+ src/dryft.egg-info/PKG-INFO
13
+ src/dryft.egg-info/SOURCES.txt
14
+ src/dryft.egg-info/dependency_links.txt
15
+ src/dryft.egg-info/entry_points.txt
16
+ src/dryft.egg-info/requires.txt
17
+ src/dryft.egg-info/top_level.txt
18
+ src/dryft/templates/CLAUDE.md.dryft
19
+ src/dryft/templates/cursorrules.dryft
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dryft = dryft.cli:main
@@ -0,0 +1 @@
1
+ certifi
@@ -0,0 +1 @@
1
+ dryft